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,1727 @@
1
+ """
2
+ Single-table / whole-database row validators.
3
+
4
+ AzureCsvValidator validates one Azure Blob CSV file against one Databricks
5
+ table. AzureSqlValidator validates every common table between an Azure SQL
6
+ Database and a Databricks catalog (matched by name). Both run the same
7
+ validation stages as CatalogValidator (column names/order, data types, row
8
+ counts, null/distinct/min-max statistics, row-hash comparison, row-level
9
+ data mismatch detail) and return the same CatalogValidationResponse shape,
10
+ so reports/excel_report.py needs no changes to handle either path.
11
+
12
+ Row-hash comparison is the one stage that genuinely differs per validator:
13
+ - AzureCsvValidator: the CSV side has no SQL engine behind it, so its rows
14
+ are hashed in Python (hashlib.sha256, verified empirically to match
15
+ Databricks' sha2() output for the same input string). Every CSV cell is
16
+ first formatted to match Databricks' CAST(col AS STRING) semantics for
17
+ that column's *target* data type.
18
+ - AzureSqlValidator: both sides are real SQL engines, so row-hash
19
+ comparison is pushed down on BOTH sides - AzureSqlConnector.get_row_hashes
20
+ (T-SQL HASHBYTES) for the source, DatabricksConnector.get_row_hashes
21
+ (sha2/concat_ws) for the target.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import datetime
27
+ import hashlib
28
+ import logging
29
+ import time
30
+ from decimal import Decimal
31
+ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
32
+
33
+ import numpy as np
34
+ import pandas as pd
35
+
36
+ from table_validator.connectors.azure_connector import AzureConnector, AzureSqlConnector
37
+ from table_validator.connectors.databricks_connector import DatabricksConnector, values_differ
38
+ from table_validator.models import (
39
+ AzureSqlValidationRequest,
40
+ CatalogValidationResponse,
41
+ ColumnValidationResult,
42
+ CsvTableValidationRequest,
43
+ DataCompareMode,
44
+ DataValidationResult,
45
+ RowHashMismatch,
46
+ RowMismatchDetail,
47
+ SchemaValidationResult,
48
+ TableValidationResult,
49
+ ValidationStatus,
50
+ ValidationSummary,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ _NULL_SENTINEL = "\x01NULL\x01"
56
+
57
+
58
+ def _calculate_overall_status(statuses: List[Optional[ValidationStatus]]) -> ValidationStatus:
59
+ """Same precedence rule as CatalogValidator.calculate_overall_status."""
60
+ clean = [s for s in statuses if s is not None]
61
+ if not clean:
62
+ return ValidationStatus.SKIPPED
63
+ if any(s == ValidationStatus.ERROR for s in clean):
64
+ return ValidationStatus.ERROR
65
+ if any(s == ValidationStatus.FAIL for s in clean):
66
+ return ValidationStatus.FAIL
67
+ if all(s == ValidationStatus.SKIPPED for s in clean):
68
+ return ValidationStatus.SKIPPED
69
+ return ValidationStatus.PASS
70
+
71
+
72
+ class CatalogValidatorLikeStatus:
73
+ """Reuses CatalogValidator's status-aggregation rule without importing
74
+ the whole class (avoids a circular import with validators.catalog_validator)."""
75
+
76
+ @staticmethod
77
+ def calculate_overall_status(
78
+ statuses: List[Optional[ValidationStatus]],
79
+ ) -> ValidationStatus:
80
+ return _calculate_overall_status(statuses)
81
+
82
+
83
+ class AzureCsvValidator:
84
+ """
85
+ Validates one Azure Blob CSV file against one Databricks table.
86
+
87
+ Column/type/nullable/row-count/statistics/row-hash comparison logic
88
+ lives here (mirrors CatalogValidator); AzureConnector and
89
+ DatabricksConnector only do I/O.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ azure_connector: AzureConnector,
95
+ databricks_connector: DatabricksConnector,
96
+ ) -> None:
97
+ self.azure = azure_connector
98
+ self.databricks = databricks_connector
99
+ logger.debug("AzureCsvValidator initialised")
100
+
101
+ # ------------------------------------------------------------------
102
+ # Public entry point
103
+ # ------------------------------------------------------------------
104
+ def validate(self, request: CsvTableValidationRequest) -> CatalogValidationResponse:
105
+ start = time.perf_counter()
106
+ run_timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
107
+
108
+ logger.info(
109
+ "Starting CSV validation | source=%s | target=%s.%s.%s",
110
+ request.source_blob_path,
111
+ request.target_catalog, request.target_schema, request.target_table,
112
+ )
113
+
114
+ try:
115
+ source_df = self.azure.read_csv(request.source_blob_path)
116
+ except Exception as exc:
117
+ logger.exception("Failed to read source CSV '%s'", request.source_blob_path)
118
+ return self._error_response(request, run_timestamp, start,
119
+ f"Unable to read source CSV: {exc}")
120
+
121
+ try:
122
+ target_schema_df = self.databricks.get_table_schema(
123
+ request.target_catalog, request.target_schema, request.target_table
124
+ )
125
+ except Exception as exc:
126
+ logger.exception(
127
+ "Failed to retrieve target table schema for '%s.%s.%s'",
128
+ request.target_catalog, request.target_schema, request.target_table,
129
+ )
130
+ return self._error_response(request, run_timestamp, start,
131
+ f"Unable to retrieve target table schema: {exc}")
132
+
133
+ table_result = self._validate_table(request, source_df, target_schema_df)
134
+
135
+ summary = ValidationSummary(
136
+ total_schemas=1,
137
+ passed_schemas=1 if table_result.status == ValidationStatus.PASS else 0,
138
+ failed_schemas=0 if table_result.status == ValidationStatus.PASS else 1,
139
+ total_tables=1,
140
+ passed_tables=1 if table_result.status == ValidationStatus.PASS else 0,
141
+ failed_tables=0 if table_result.status == ValidationStatus.PASS else 1,
142
+ error_tables=1 if table_result.status == ValidationStatus.ERROR else 0,
143
+ )
144
+
145
+ schema_result = SchemaValidationResult(
146
+ schema_name=request.target_schema,
147
+ status=table_result.status,
148
+ tables=[table_result],
149
+ )
150
+
151
+ execution_time = round(time.perf_counter() - start, 3)
152
+
153
+ logger.info(
154
+ "CSV validation finished | status=%s | duration=%.3fs",
155
+ table_result.status, execution_time,
156
+ )
157
+
158
+ return CatalogValidationResponse(
159
+ source_catalog=request.source_blob_path,
160
+ target_catalog=f"{request.target_catalog}.{request.target_schema}.{request.target_table}",
161
+ status=table_result.status,
162
+ validation_timestamp=run_timestamp,
163
+ execution_time_seconds=execution_time,
164
+ summary=summary,
165
+ schemas=[schema_result],
166
+ )
167
+
168
+ def _error_response(
169
+ self,
170
+ request: CsvTableValidationRequest,
171
+ run_timestamp: str,
172
+ start: float,
173
+ error: str,
174
+ ) -> CatalogValidationResponse:
175
+ return CatalogValidationResponse(
176
+ source_catalog=request.source_blob_path,
177
+ target_catalog=f"{request.target_catalog}.{request.target_schema}.{request.target_table}",
178
+ status=ValidationStatus.ERROR,
179
+ validation_timestamp=run_timestamp,
180
+ execution_time_seconds=round(time.perf_counter() - start, 3),
181
+ error=error,
182
+ )
183
+
184
+ # ------------------------------------------------------------------
185
+ # Per-table pipeline (mirrors CatalogValidator._validate_table)
186
+ # ------------------------------------------------------------------
187
+ def _validate_table(
188
+ self,
189
+ request: CsvTableValidationRequest,
190
+ source_df: pd.DataFrame,
191
+ target_schema_df: pd.DataFrame,
192
+ ) -> TableValidationResult:
193
+
194
+ result = TableValidationResult(
195
+ schema_name=request.target_schema, table=request.target_table,
196
+ )
197
+
198
+ ignore = {c.lower() for c in (request.ignore_columns or [])}
199
+
200
+ def norm(name: str) -> str:
201
+ return name if request.case_sensitive_columns else name.lower()
202
+
203
+ src_cols = {
204
+ norm(str(c)): str(c) for c in source_df.columns
205
+ if norm(str(c)) not in ignore and str(c).lower() not in ignore
206
+ }
207
+ tgt_cols = {
208
+ norm(str(c)): str(c) for c in target_schema_df["column_name"]
209
+ if norm(str(c)) not in ignore and str(c).lower() not in ignore
210
+ }
211
+
212
+ missing_cols = sorted(set(src_cols) - set(tgt_cols))
213
+ extra_cols = sorted(set(tgt_cols) - set(src_cols))
214
+ common_cols = sorted(src_cols[k] for k in (set(src_cols) & set(tgt_cols)))
215
+
216
+ result.missing_columns = missing_cols
217
+ result.extra_columns = extra_cols
218
+ result.columns_status = (
219
+ ValidationStatus.FAIL if (missing_cols or extra_cols) else ValidationStatus.PASS
220
+ )
221
+
222
+ if not common_cols:
223
+ result.status = ValidationStatus.FAIL
224
+ result.error = "No common columns between source CSV and target table"
225
+ return result
226
+
227
+ # Column order (source CSV order vs target ordinal order)
228
+ source_order = [c for c in source_df.columns if c in common_cols]
229
+ target_order = [
230
+ c for c in target_schema_df["column_name"].tolist() if c in common_cols
231
+ ]
232
+ result.source_column_order = source_order
233
+ result.target_column_order = target_order
234
+
235
+ if request.validate_column_order:
236
+ order_matches = [c.lower() for c in source_order] == [
237
+ c.lower() for c in target_order
238
+ ]
239
+ result.column_order_status = (
240
+ ValidationStatus.PASS if order_matches else ValidationStatus.FAIL
241
+ )
242
+ else:
243
+ result.column_order_status = ValidationStatus.SKIPPED
244
+
245
+ primary_key = request.primary_key
246
+ missing_keys = [k for k in primary_key if k.lower() not in {c.lower() for c in common_cols}]
247
+ if missing_keys:
248
+ result.status = ValidationStatus.ERROR
249
+ result.error = f"Configured primary key column(s) not found as common columns: {missing_keys}"
250
+ return result
251
+
252
+ value_columns = sorted(
253
+ c for c in common_cols if c.lower() not in {k.lower() for k in primary_key}
254
+ )
255
+
256
+ tgt_by_col = {
257
+ str(r["column_name"]).lower(): r for _, r in target_schema_df.iterrows()
258
+ }
259
+
260
+ min_max_columns = [
261
+ c for c in common_cols
262
+ if self.databricks.is_min_max_eligible(
263
+ str(tgt_by_col.get(c.lower(), {}).get("data_type", ""))
264
+ )
265
+ ]
266
+
267
+ # Per-column: data type + null/distinct/min-max stats
268
+ try:
269
+ source_stats = self._csv_column_statistics(source_df, common_cols, min_max_columns)
270
+ target_stats = self.databricks.get_column_statistics(
271
+ request.target_catalog, request.target_schema, request.target_table,
272
+ common_cols, min_max_columns,
273
+ )
274
+ stats_error = None
275
+ except Exception as exc:
276
+ logger.exception(
277
+ "Failed to compute column statistics for '%s'", request.target_table
278
+ )
279
+ source_stats, target_stats = {}, {}
280
+ stats_error = str(exc)
281
+
282
+ column_results: List[ColumnValidationResult] = []
283
+ dtype_statuses, null_statuses, distinct_statuses, minmax_statuses = [], [], [], []
284
+
285
+ for col in common_cols:
286
+ tgt_row = tgt_by_col.get(col.lower(), {})
287
+ col_result = ColumnValidationResult(column=col, status=ValidationStatus.PASS)
288
+
289
+ # Data type: compare the CSV column's inferred pandas dtype
290
+ # (mapped to a Databricks-equivalent name) against the target's
291
+ # real Databricks type. Informational only - not pushed down,
292
+ # since the CSV has no declared schema of its own.
293
+ src_type = self._infer_databricks_type(source_df[col])
294
+ tgt_type = str(tgt_row.get("data_type"))
295
+ col_result.source_data_type = src_type
296
+ col_result.target_data_type = tgt_type
297
+ col_result.data_type_status = (
298
+ ValidationStatus.PASS if self._types_compatible(src_type, tgt_type)
299
+ else ValidationStatus.FAIL
300
+ )
301
+ dtype_statuses.append(col_result.data_type_status)
302
+ col_result.nullable_status = ValidationStatus.SKIPPED
303
+
304
+ if stats_error:
305
+ col_result.null_count_status = ValidationStatus.ERROR
306
+ col_result.distinct_count_status = ValidationStatus.ERROR
307
+ col_result.error = stats_error
308
+ else:
309
+ s_stat = source_stats.get(col, {})
310
+ t_stat = target_stats.get(col, {})
311
+
312
+ col_result.source_null_count = s_stat.get("null_count")
313
+ col_result.target_null_count = t_stat.get("null_count")
314
+ col_result.null_count_status = (
315
+ ValidationStatus.PASS
316
+ if col_result.source_null_count == col_result.target_null_count
317
+ else ValidationStatus.FAIL
318
+ )
319
+
320
+ col_result.source_distinct_count = s_stat.get("distinct_count")
321
+ col_result.target_distinct_count = t_stat.get("distinct_count")
322
+ col_result.distinct_count_status = (
323
+ ValidationStatus.PASS
324
+ if col_result.source_distinct_count == col_result.target_distinct_count
325
+ else ValidationStatus.FAIL
326
+ )
327
+
328
+ if col in min_max_columns:
329
+ col_result.source_min = s_stat.get("min")
330
+ col_result.source_max = s_stat.get("max")
331
+ col_result.target_min = t_stat.get("min")
332
+ col_result.target_max = t_stat.get("max")
333
+ col_result.min_max_status = (
334
+ ValidationStatus.PASS
335
+ if (col_result.source_min == col_result.target_min
336
+ and col_result.source_max == col_result.target_max)
337
+ else ValidationStatus.FAIL
338
+ )
339
+ else:
340
+ col_result.min_max_status = ValidationStatus.SKIPPED
341
+
342
+ null_statuses.append(col_result.null_count_status)
343
+ distinct_statuses.append(col_result.distinct_count_status)
344
+ minmax_statuses.append(col_result.min_max_status)
345
+
346
+ col_result.status = CatalogValidatorLikeStatus.calculate_overall_status(
347
+ [
348
+ col_result.data_type_status,
349
+ col_result.nullable_status,
350
+ col_result.null_count_status,
351
+ col_result.distinct_count_status,
352
+ col_result.min_max_status,
353
+ ]
354
+ )
355
+ column_results.append(col_result)
356
+
357
+ result.columns = column_results
358
+ result.data_types_status = CatalogValidatorLikeStatus.calculate_overall_status(dtype_statuses)
359
+ result.nullable_status = ValidationStatus.SKIPPED
360
+ result.null_counts_status = CatalogValidatorLikeStatus.calculate_overall_status(null_statuses)
361
+ result.distinct_counts_status = CatalogValidatorLikeStatus.calculate_overall_status(distinct_statuses)
362
+ result.min_max_status = CatalogValidatorLikeStatus.calculate_overall_status(minmax_statuses)
363
+
364
+ # Row counts
365
+ try:
366
+ src_count = len(source_df)
367
+ tgt_count = self.databricks.get_row_count(
368
+ request.target_catalog, request.target_schema, request.target_table
369
+ )
370
+ result.row_count_source = src_count
371
+ result.row_count_target = tgt_count
372
+ result.row_count_difference = tgt_count - src_count
373
+ result.row_count_status = (
374
+ ValidationStatus.PASS if src_count == tgt_count else ValidationStatus.FAIL
375
+ )
376
+ except Exception as exc:
377
+ logger.exception("Failed to compute row counts for '%s'", request.target_table)
378
+ result.row_count_status = ValidationStatus.ERROR
379
+ result.error = f"Row count failed: {exc}"
380
+
381
+ # Row-hash comparison + data mismatches
382
+ result.data = self._compare_data(request, source_df, target_schema_df, common_cols, value_columns)
383
+
384
+ result.status = CatalogValidatorLikeStatus.calculate_overall_status(
385
+ [
386
+ result.columns_status,
387
+ result.column_order_status,
388
+ result.row_count_status,
389
+ result.data_types_status,
390
+ result.null_counts_status,
391
+ result.distinct_counts_status,
392
+ result.min_max_status,
393
+ result.data.status if result.data else ValidationStatus.SKIPPED,
394
+ ]
395
+ )
396
+
397
+ return result
398
+
399
+ # ------------------------------------------------------------------
400
+ # CSV-side column statistics (mirrors DatabricksConnector.get_column_statistics)
401
+ # ------------------------------------------------------------------
402
+ @staticmethod
403
+ def _csv_column_statistics(
404
+ df: pd.DataFrame,
405
+ columns: Sequence[str],
406
+ min_max_columns: Sequence[str],
407
+ ) -> Dict[str, Dict[str, Any]]:
408
+ min_max_set = {c.lower() for c in min_max_columns}
409
+ result: Dict[str, Dict[str, Any]] = {}
410
+
411
+ for col in columns:
412
+ series = df[col]
413
+ entry: Dict[str, Any] = {
414
+ "null_count": int(series.isna().sum()),
415
+ "distinct_count": int(series.nunique(dropna=True)),
416
+ "min": None,
417
+ "max": None,
418
+ }
419
+ if col.lower() in min_max_set:
420
+ non_null = series.dropna()
421
+ if not non_null.empty:
422
+ entry["min"] = non_null.min()
423
+ entry["max"] = non_null.max()
424
+ result[col] = entry
425
+
426
+ return result
427
+
428
+ @staticmethod
429
+ def _infer_databricks_type(series: pd.Series) -> str:
430
+ dtype = str(series.dtype)
431
+ if dtype.startswith("int"):
432
+ return "bigint"
433
+ if dtype.startswith("float"):
434
+ return "double"
435
+ if dtype == "bool":
436
+ return "boolean"
437
+ if dtype.startswith("datetime"):
438
+ return "timestamp"
439
+ return "string"
440
+
441
+ @staticmethod
442
+ def _types_compatible(source_type: str, target_type: str) -> bool:
443
+ if source_type == target_type:
444
+ return True
445
+ numeric = {"bigint", "int", "smallint", "tinyint", "double", "float", "decimal"}
446
+ target_base = target_type.split("(")[0].lower()
447
+ if source_type in numeric and target_base in numeric:
448
+ return True
449
+ if source_type == "string" and target_base in ("string", "date", "timestamp", "varchar", "char"):
450
+ # CSV columns are read as plain strings/objects for date-like
451
+ # target columns (no schema to infer from) - don't fail solely
452
+ # on that; the actual values still get compared via row-hash.
453
+ return True
454
+ return False
455
+
456
+ # ------------------------------------------------------------------
457
+ # Row-hash comparison + row-level data mismatch detail
458
+ # ------------------------------------------------------------------
459
+ def _compare_data(
460
+ self,
461
+ request: CsvTableValidationRequest,
462
+ source_df: pd.DataFrame,
463
+ target_schema_df: pd.DataFrame,
464
+ common_columns: List[str],
465
+ value_columns: List[str],
466
+ ) -> DataValidationResult:
467
+
468
+ mode = request.data_compare_mode
469
+ primary_key = request.primary_key
470
+ using_row_number_fallback = not primary_key
471
+
472
+ tgt_by_col = {
473
+ str(r["column_name"]).lower(): str(r["data_type"])
474
+ for _, r in target_schema_df.iterrows()
475
+ }
476
+
477
+ logger.info(
478
+ "[csv-row-hash] table=%s.%s.%s | key_columns=%s | value_columns=%s",
479
+ request.target_catalog, request.target_schema, request.target_table,
480
+ primary_key or "<none - row-number fallback>", value_columns,
481
+ )
482
+
483
+ if using_row_number_fallback:
484
+ hash_value_columns = sorted(value_columns)
485
+ try:
486
+ source_hashes = self._hash_csv_rows_by_row_number(
487
+ source_df, hash_value_columns, tgt_by_col,
488
+ )
489
+ except Exception as exc:
490
+ logger.exception("Failed to hash source CSV rows by row number")
491
+ return DataValidationResult(
492
+ mode=mode, status=ValidationStatus.ERROR,
493
+ key_columns=["row_number"], error=f"CSV row-number hashing failed: {exc}",
494
+ )
495
+
496
+ try:
497
+ target_hashes = self.databricks.get_row_hashes_by_row_number(
498
+ request.target_catalog, request.target_schema, request.target_table,
499
+ hash_value_columns,
500
+ )
501
+ except Exception as exc:
502
+ logger.exception("Failed to fetch target row-number hashes")
503
+ return DataValidationResult(
504
+ mode=mode, status=ValidationStatus.ERROR,
505
+ key_columns=["row_number"], error=f"Target row-number hashing failed: {exc}",
506
+ )
507
+
508
+ effective_key = ["row_number"]
509
+ else:
510
+ try:
511
+ source_hashes = self._hash_csv_rows(source_df, value_columns, primary_key, tgt_by_col)
512
+ except Exception as exc:
513
+ logger.exception("Failed to hash source CSV rows")
514
+ return DataValidationResult(
515
+ mode=mode, status=ValidationStatus.ERROR,
516
+ key_columns=primary_key, error=f"CSV row hashing failed: {exc}",
517
+ )
518
+
519
+ try:
520
+ target_hashes = self.databricks.get_row_hashes(
521
+ request.target_catalog, request.target_schema, request.target_table,
522
+ value_columns, primary_key,
523
+ )
524
+ except Exception as exc:
525
+ logger.exception("Failed to fetch target row hashes")
526
+ return DataValidationResult(
527
+ mode=mode, status=ValidationStatus.ERROR,
528
+ key_columns=primary_key, error=f"Target row hashing failed: {exc}",
529
+ )
530
+
531
+ effective_key = primary_key
532
+
533
+ logger.info(
534
+ "[csv-row-hash] fetched | source_rows=%d | target_rows=%d | row_number_fallback=%s",
535
+ len(source_hashes), len(target_hashes), using_row_number_fallback,
536
+ )
537
+
538
+ mismatches, mismatch_count, mismatch_pct = self._compare_row_hashes(
539
+ source_hashes, target_hashes, effective_key,
540
+ )
541
+
542
+ logger.info(
543
+ "[csv-row-hash] mismatch_count=%d | mismatch_pct=%.2f%%",
544
+ mismatch_count, mismatch_pct,
545
+ )
546
+
547
+ data_result = DataValidationResult(
548
+ mode=mode,
549
+ status=ValidationStatus.FAIL if mismatch_count > 0 else ValidationStatus.PASS,
550
+ key_columns=effective_key,
551
+ row_hash_mismatches=mismatches,
552
+ row_hash_mismatch_count=mismatch_count,
553
+ row_hash_mismatch_percentage=mismatch_pct,
554
+ note=(
555
+ "No primary key configured - row-level comparison used a synthetic "
556
+ "row-number match (CSV file order vs. Databricks ROW_NUMBER(), both "
557
+ "sorted by every common column) instead of a real key. Only reliable "
558
+ "when both sides contain the same set of rows; cannot pinpoint which "
559
+ "specific record changed the way a real key can."
560
+ if using_row_number_fallback else None
561
+ ),
562
+ )
563
+
564
+ if mode == DataCompareMode.FULL and mismatch_count > 0 and not using_row_number_fallback:
565
+ data_result.sample_changed_detail = self._changed_row_detail(
566
+ request, source_df, source_hashes, target_hashes, primary_key, value_columns,
567
+ )
568
+
569
+ return data_result
570
+
571
+ @staticmethod
572
+ def _format_value_for_hash(value: Any, databricks_type: str) -> str:
573
+ """
574
+ Format one cell to match Databricks' CAST(col AS STRING) output for
575
+ the given target column type, so a Python-computed hash lines up
576
+ with the SQL-computed hash for the same logical value.
577
+ """
578
+ if value is None or (isinstance(value, float) and np.isnan(value)) or pd.isna(value):
579
+ return _NULL_SENTINEL
580
+
581
+ base_type = databricks_type.split("(")[0].lower()
582
+
583
+ if base_type in ("double", "float", "decimal"):
584
+ f = float(value)
585
+ # Databricks always prints a decimal point for floating types,
586
+ # even for whole numbers (158068.0, never 158068).
587
+ if f == int(f) and "e" not in repr(f).lower():
588
+ return f"{f:.1f}" if base_type != "decimal" else str(Decimal(str(value)))
589
+ return repr(f)
590
+
591
+ if base_type in ("bigint", "int", "smallint", "tinyint"):
592
+ return str(int(value))
593
+
594
+ if base_type == "boolean":
595
+ if isinstance(value, str):
596
+ return value.strip().lower()
597
+ return "true" if bool(value) else "false"
598
+
599
+ if base_type == "date":
600
+ if isinstance(value, (datetime.date, datetime.datetime)):
601
+ d = value.date() if isinstance(value, datetime.datetime) else value
602
+ return d.isoformat()
603
+ return str(value).strip()
604
+
605
+ if base_type == "timestamp":
606
+ if isinstance(value, (datetime.date, datetime.datetime)):
607
+ return str(value)
608
+ return str(value).strip()
609
+
610
+ return str(value)
611
+
612
+ def _hash_csv_rows(
613
+ self,
614
+ df: pd.DataFrame,
615
+ value_columns: Sequence[str],
616
+ primary_key: Sequence[str],
617
+ target_types_by_col: Dict[str, str],
618
+ ) -> pd.DataFrame:
619
+ rows = []
620
+ for _, row in df.iterrows():
621
+ parts = [
622
+ self._format_value_for_hash(
623
+ row.get(col), target_types_by_col.get(col.lower(), "string")
624
+ )
625
+ for col in value_columns
626
+ ]
627
+ digest = hashlib.sha256("||".join(parts).encode("utf-8")).hexdigest()
628
+ entry = {k: row.get(k) for k in primary_key}
629
+ entry["row_hash"] = digest
630
+ rows.append(entry)
631
+
632
+ if not rows:
633
+ return pd.DataFrame(columns=list(primary_key) + ["row_hash"])
634
+
635
+ return pd.DataFrame(rows)
636
+
637
+ def _hash_csv_rows_by_row_number(
638
+ self,
639
+ df: pd.DataFrame,
640
+ value_columns: Sequence[str],
641
+ target_types_by_col: Dict[str, str],
642
+ ) -> pd.DataFrame:
643
+ """
644
+ Fallback for when no primary key is configured: sorts by every
645
+ value column (matching the ORDER BY used by
646
+ DatabricksConnector.get_row_hashes_by_row_number so both sides
647
+ assign the same row a matching number regardless of storage/file
648
+ order), assigns a 1-based row_number, then hashes each row the
649
+ same way _hash_csv_rows does. See that method's caveat: this only
650
+ gives meaningful results when both sides contain the same set of
651
+ rows.
652
+ """
653
+ sorted_df = df.sort_values(by=list(value_columns), kind="stable").reset_index(drop=True)
654
+
655
+ rows = []
656
+ for i, row in sorted_df.iterrows():
657
+ parts = [
658
+ self._format_value_for_hash(
659
+ row.get(col), target_types_by_col.get(col.lower(), "string")
660
+ )
661
+ for col in value_columns
662
+ ]
663
+ digest = hashlib.sha256("||".join(parts).encode("utf-8")).hexdigest()
664
+ rows.append({"row_number": i + 1, "row_hash": digest})
665
+
666
+ if not rows:
667
+ return pd.DataFrame(columns=["row_number", "row_hash"])
668
+
669
+ return pd.DataFrame(rows)
670
+
671
+ @staticmethod
672
+ def _compare_row_hashes(
673
+ source_hashes: pd.DataFrame,
674
+ target_hashes: pd.DataFrame,
675
+ primary_key_cols: Sequence[str],
676
+ ) -> Tuple[List[RowHashMismatch], int, float]:
677
+ # Identical join/classify logic to CatalogValidator.compare_row_hashes.
678
+ def _display_key(row: pd.Series) -> str:
679
+ return "|".join(str(row[k]) for k in primary_key_cols)
680
+
681
+ def _key_tuple(row: pd.Series) -> tuple:
682
+ return tuple(row[k] for k in primary_key_cols)
683
+
684
+ source_by_key = {_key_tuple(r): r for _, r in source_hashes.iterrows()}
685
+ target_by_key = {_key_tuple(r): r for _, r in target_hashes.iterrows()}
686
+
687
+ all_keys = set(source_by_key) | set(target_by_key)
688
+ mismatches: List[RowHashMismatch] = []
689
+
690
+ for key_tuple in all_keys:
691
+ src_row = source_by_key.get(key_tuple)
692
+ tgt_row = target_by_key.get(key_tuple)
693
+
694
+ if src_row is not None and tgt_row is None:
695
+ mismatches.append(RowHashMismatch(
696
+ primary_key=_display_key(src_row),
697
+ source_hash=str(src_row["row_hash"]), target_hash="",
698
+ status="MISSING_IN_TARGET",
699
+ ))
700
+ elif src_row is None and tgt_row is not None:
701
+ mismatches.append(RowHashMismatch(
702
+ primary_key=_display_key(tgt_row),
703
+ source_hash="", target_hash=str(tgt_row["row_hash"]),
704
+ status="MISSING_IN_SOURCE",
705
+ ))
706
+ elif src_row is not None and tgt_row is not None:
707
+ if src_row["row_hash"] != tgt_row["row_hash"]:
708
+ mismatches.append(RowHashMismatch(
709
+ primary_key=_display_key(src_row),
710
+ source_hash=str(src_row["row_hash"]),
711
+ target_hash=str(tgt_row["row_hash"]),
712
+ status="MISMATCH",
713
+ ))
714
+
715
+ total = len(all_keys)
716
+ count = len(mismatches)
717
+ pct = (count / total) * 100 if total else 0.0
718
+ return mismatches, count, pct
719
+
720
+ def _changed_row_detail(
721
+ self,
722
+ request: CsvTableValidationRequest,
723
+ source_df: pd.DataFrame,
724
+ source_hashes: pd.DataFrame,
725
+ target_hashes: pd.DataFrame,
726
+ primary_key: Sequence[str],
727
+ value_columns: Sequence[str],
728
+ ) -> List[RowMismatchDetail]:
729
+ """
730
+ For MISMATCH keys (present, differing hash, on both sides), fetch
731
+ the actual target row values for those keys in one batched query
732
+ and diff column-by-column against the source CSV row - same
733
+ approach as DatabricksConnector._changed_row_detail, but the
734
+ source side comes from the already-loaded CSV DataFrame instead
735
+ of a second SQL query.
736
+ """
737
+ limit_samples = request.max_sample_rows
738
+
739
+ src_hash_by_key = {
740
+ tuple(r[k] for k in primary_key): r["row_hash"]
741
+ for _, r in source_hashes.iterrows()
742
+ }
743
+ tgt_hash_by_key = {
744
+ tuple(r[k] for k in primary_key): r["row_hash"]
745
+ for _, r in target_hashes.iterrows()
746
+ }
747
+
748
+ mismatch_keys: List[tuple] = []
749
+ for key_tuple, src_hash in src_hash_by_key.items():
750
+ tgt_hash = tgt_hash_by_key.get(key_tuple)
751
+ if tgt_hash is not None and tgt_hash != src_hash:
752
+ mismatch_keys.append(key_tuple)
753
+ if len(mismatch_keys) >= limit_samples:
754
+ break
755
+
756
+ if not mismatch_keys:
757
+ return []
758
+
759
+ mismatch_key_set = set(mismatch_keys)
760
+ source_by_key: Dict[tuple, pd.Series] = {}
761
+ for _, row in source_df.iterrows():
762
+ key_tuple = tuple(row[k] for k in primary_key)
763
+ if key_tuple in mismatch_key_set:
764
+ source_by_key[key_tuple] = row
765
+ if len(source_by_key) >= len(mismatch_key_set):
766
+ break
767
+
768
+ target_rows = self._fetch_target_rows_for_keys(
769
+ request, primary_key, value_columns, mismatch_keys,
770
+ )
771
+ target_by_key = {
772
+ tuple(r[k] for k in primary_key): r for r in target_rows
773
+ }
774
+
775
+ detail: List[RowMismatchDetail] = []
776
+ for key_tuple in mismatch_keys:
777
+ src_row = source_by_key.get(key_tuple)
778
+ tgt_row = target_by_key.get(key_tuple)
779
+ if src_row is None or tgt_row is None:
780
+ continue
781
+
782
+ mismatched_columns = [
783
+ col for col in value_columns
784
+ if values_differ(src_row.get(col), tgt_row.get(col))
785
+ ]
786
+ if not mismatched_columns:
787
+ mismatched_columns = list(value_columns)
788
+
789
+ key_dict = {k: src_row.get(k) for k in primary_key}
790
+
791
+ for col in mismatched_columns:
792
+ detail.append(
793
+ RowMismatchDetail(
794
+ schema_name=request.target_schema,
795
+ table=request.target_table,
796
+ primary_key=key_dict,
797
+ mismatch_column=col,
798
+ source_value=src_row.get(col),
799
+ target_value=tgt_row.get(col),
800
+ source_row_hash=src_hash_by_key.get(key_tuple),
801
+ target_row_hash=tgt_hash_by_key.get(key_tuple),
802
+ )
803
+ )
804
+
805
+ return detail
806
+
807
+ def _fetch_target_rows_for_keys(
808
+ self,
809
+ request: CsvTableValidationRequest,
810
+ primary_key: Sequence[str],
811
+ value_columns: Sequence[str],
812
+ keys: Sequence[tuple],
813
+ ) -> List[Dict[str, Any]]:
814
+ """Batched fetch of target rows for a bounded set of primary keys."""
815
+ if not keys:
816
+ return []
817
+
818
+ key_idents = [self.databricks._quote_ident(k) for k in primary_key]
819
+ key_list = ", ".join(key_idents)
820
+ value_list = ", ".join(self.databricks._quote_ident(c) for c in value_columns)
821
+ table_fqtn = self.databricks._qualify(
822
+ request.target_catalog, request.target_schema, request.target_table
823
+ )
824
+
825
+ if len(primary_key) == 1:
826
+ values_sql = ", ".join(self._sql_literal(k[0]) for k in keys)
827
+ where_clause = f"{key_idents[0]} IN ({values_sql})"
828
+ else:
829
+ tuples_sql = ", ".join(
830
+ "(" + ", ".join(self._sql_literal(v) for v in k) + ")" for k in keys
831
+ )
832
+ where_clause = f"({key_list}) IN ({tuples_sql})"
833
+
834
+ query = f"SELECT {key_list}, {value_list} FROM {table_fqtn} WHERE {where_clause}"
835
+ return self.databricks.execute_query(query).to_dict(orient="records")
836
+
837
+ @staticmethod
838
+ def _sql_literal(value: Any) -> str:
839
+ if value is None:
840
+ return "NULL"
841
+ if isinstance(value, (int, float)):
842
+ return str(value)
843
+ escaped = str(value).replace("'", "''")
844
+ return f"'{escaped}'"
845
+
846
+
847
+ class AzureSqlValidator:
848
+ """
849
+ Validates every common table between an Azure SQL Database and a
850
+ Databricks catalog. Responsible for comparison/decision logic only;
851
+ AzureSqlConnector and DatabricksConnector only do I/O.
852
+ """
853
+
854
+ def __init__(
855
+ self,
856
+ azure_sql_connector: AzureSqlConnector,
857
+ databricks_connector: DatabricksConnector,
858
+ ) -> None:
859
+ self.azure_sql = azure_sql_connector
860
+ self.databricks = databricks_connector
861
+ logger.debug("AzureSqlValidator initialised")
862
+
863
+ # ------------------------------------------------------------------
864
+ # Public entry point
865
+ # ------------------------------------------------------------------
866
+ def validate(self, request: AzureSqlValidationRequest) -> CatalogValidationResponse:
867
+ start = time.perf_counter()
868
+ run_timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
869
+
870
+ logger.info(
871
+ "Starting Azure SQL validation | target_catalog=%s", request.target_catalog,
872
+ )
873
+
874
+ try:
875
+ common_pairs, missing_schemas, extra_schemas = self._compare_schemas(request)
876
+ except Exception as exc:
877
+ logger.exception("Failed to compare schemas")
878
+ return CatalogValidationResponse(
879
+ source_catalog="azure_sql",
880
+ target_catalog=request.target_catalog,
881
+ status=ValidationStatus.ERROR,
882
+ validation_timestamp=run_timestamp,
883
+ execution_time_seconds=round(time.perf_counter() - start, 3),
884
+ error=f"Unable to compare schemas: {exc}",
885
+ )
886
+
887
+ if request.schemas:
888
+ # Explicit schema scope: missing_schemas/extra_schemas must be
889
+ # restricted to it too, not just common_pairs - otherwise an
890
+ # unrelated schema elsewhere in the database falsely fails
891
+ # this targeted run. Mirrors the same fix in
892
+ # catalog_validator.py's compare_catalogs.
893
+ wanted = {s.lower() for s in request.schemas}
894
+ common_pairs = [
895
+ (src, tgt) for src, tgt in common_pairs if src.lower() in wanted
896
+ ]
897
+ missing_schemas = [s for s in missing_schemas if s.lower() in wanted]
898
+ extra_schemas = [s for s in extra_schemas if s.lower() in wanted]
899
+
900
+ schema_results: List[SchemaValidationResult] = []
901
+ for source_schema, target_schema in common_pairs:
902
+ schema_results.append(self._validate_schema(request, source_schema, target_schema))
903
+
904
+ summary = self._build_summary(schema_results, missing_schemas, extra_schemas)
905
+ overall_status = _calculate_overall_status(
906
+ [s.status for s in schema_results]
907
+ + ([ValidationStatus.FAIL] if missing_schemas else [])
908
+ )
909
+
910
+ execution_time = round(time.perf_counter() - start, 3)
911
+
912
+ logger.info(
913
+ "Azure SQL validation finished | status=%s | duration=%.3fs",
914
+ overall_status, execution_time,
915
+ )
916
+
917
+ return CatalogValidationResponse(
918
+ source_catalog="azure_sql",
919
+ target_catalog=request.target_catalog,
920
+ status=overall_status,
921
+ validation_timestamp=run_timestamp,
922
+ execution_time_seconds=execution_time,
923
+ missing_schemas=missing_schemas,
924
+ extra_schemas=extra_schemas,
925
+ summary=summary,
926
+ schemas=schema_results,
927
+ )
928
+
929
+ # ------------------------------------------------------------------
930
+ # Schema / table matching (mirrors CatalogValidator.compare_schemas/tables)
931
+ # ------------------------------------------------------------------
932
+ def _compare_schemas(
933
+ self, request: AzureSqlValidationRequest,
934
+ ) -> Tuple[List[Tuple[str, str]], List[str], List[str]]:
935
+ """
936
+ Returns (common_pairs, missing, extra) where common_pairs is a list
937
+ of (source_schema, target_schema) name pairs - either identical
938
+ names, or resolved via request.schema_map when the Azure SQL and
939
+ Databricks sides use different schema names for the same logical
940
+ target. `missing` (present in Azure SQL, no match in Databricks)
941
+ and `extra` (present in Databricks, unmatched) are still reported
942
+ by their own-side name for display.
943
+ """
944
+ source_schemas = set(self.azure_sql.get_schemas())
945
+ target_schemas = set(self.databricks.get_schemas(request.target_catalog))
946
+
947
+ schema_map_lower = {k.lower(): v for k, v in request.schema_map.items()}
948
+
949
+ common_pairs: List[Tuple[str, str]] = []
950
+ matched_source: Set[str] = set()
951
+ matched_target: Set[str] = set()
952
+
953
+ for src in source_schemas:
954
+ mapped_target = schema_map_lower.get(src.lower())
955
+ if mapped_target is not None:
956
+ candidate = next(
957
+ (t for t in target_schemas if t.lower() == mapped_target.lower()), None
958
+ )
959
+ else:
960
+ candidate = next(
961
+ (t for t in target_schemas if t.lower() == src.lower()), None
962
+ )
963
+ if candidate is not None:
964
+ common_pairs.append((src, candidate))
965
+ matched_source.add(src)
966
+ matched_target.add(candidate)
967
+
968
+ missing = sorted(source_schemas - matched_source)
969
+ extra = sorted(target_schemas - matched_target)
970
+ common_pairs.sort(key=lambda pair: pair[0])
971
+ return common_pairs, missing, extra
972
+
973
+ def _compare_tables(
974
+ self, request: AzureSqlValidationRequest, source_schema: str, target_schema: str,
975
+ ) -> Tuple[List[Tuple[str, str]], List[str], List[str]]:
976
+ """
977
+ Returns (common_pairs, missing, extra) where common_pairs is a list
978
+ of (source_table_name, target_table_name) pairs preserving each
979
+ side's own casing - table names are matched case-insensitively,
980
+ but the two sides' real casing must both be preserved and used
981
+ for their own subsequent lookups (Databricks' information_schema
982
+ table-name match is case-sensitive, and Azure SQL's default
983
+ collation usually is too).
984
+
985
+ request.table_map resolves an explicit source table name -> target
986
+ table name pair without requiring identical names (same idea as
987
+ schema_map in _compare_schemas) - a mapped source table is always
988
+ paired with its mapped target, bypassing name-based matching for
989
+ that pair entirely, even if a same-named table also exists on the
990
+ target side.
991
+ """
992
+ source_tables = {t.lower(): t for t in self.azure_sql.get_tables(source_schema)}
993
+ target_tables = {
994
+ t.lower(): t for t in self.databricks.get_tables(request.target_catalog, target_schema)
995
+ }
996
+
997
+ table_map_lower = {k.lower(): v for k, v in request.table_map.items()}
998
+
999
+ common_pairs: List[Tuple[str, str]] = []
1000
+ matched_source: set = set()
1001
+ matched_target: set = set()
1002
+
1003
+ for key, src_name in source_tables.items():
1004
+ mapped_target = table_map_lower.get(key)
1005
+ if mapped_target is not None:
1006
+ candidate = target_tables.get(mapped_target.lower())
1007
+ else:
1008
+ candidate = target_tables.get(key)
1009
+ if candidate is not None:
1010
+ common_pairs.append((src_name, candidate))
1011
+ matched_source.add(key)
1012
+ matched_target.add(candidate.lower())
1013
+
1014
+ missing = sorted(
1015
+ src_name for key, src_name in source_tables.items() if key not in matched_source
1016
+ )
1017
+ extra = sorted(
1018
+ tgt_name for key, tgt_name in target_tables.items() if key not in matched_target
1019
+ )
1020
+ common_pairs.sort(key=lambda pair: pair[0])
1021
+ return common_pairs, missing, extra
1022
+
1023
+ def _validate_schema(
1024
+ self, request: AzureSqlValidationRequest, source_schema: str, target_schema: str,
1025
+ ) -> SchemaValidationResult:
1026
+ try:
1027
+ common_table_pairs, missing_tables, extra_tables = self._compare_tables(
1028
+ request, source_schema, target_schema,
1029
+ )
1030
+ except Exception as exc:
1031
+ logger.exception("Failed to compare tables for schema '%s'", source_schema)
1032
+ return SchemaValidationResult(
1033
+ schema_name=target_schema,
1034
+ status=ValidationStatus.ERROR,
1035
+ error=f"Unable to compare tables: {exc}",
1036
+ )
1037
+
1038
+ if request.tables:
1039
+ # Explicit table scope: missing_tables/extra_tables must be
1040
+ # restricted to it too, not just common_table_pairs -
1041
+ # otherwise an unrelated table elsewhere in the same schema
1042
+ # (one the user never asked to compare) falsely fails this
1043
+ # targeted run. Mirrors the same fix in
1044
+ # catalog_validator.py's _validate_schema.
1045
+ wanted = {t.lower() for t in request.tables}
1046
+ common_table_pairs = [
1047
+ (src, tgt) for src, tgt in common_table_pairs if src.lower() in wanted
1048
+ ]
1049
+ missing_tables = [t for t in missing_tables if t.lower() in wanted]
1050
+ extra_tables = [t for t in extra_tables if t.lower() in wanted]
1051
+
1052
+ table_results: List[TableValidationResult] = []
1053
+ for source_table, target_table in common_table_pairs:
1054
+ table_results.append(
1055
+ self._validate_table(request, source_schema, target_schema, source_table, target_table)
1056
+ )
1057
+
1058
+ statuses = [t.status for t in table_results]
1059
+ if missing_tables:
1060
+ statuses.append(ValidationStatus.FAIL)
1061
+
1062
+ status = _calculate_overall_status(statuses)
1063
+
1064
+ return SchemaValidationResult(
1065
+ schema_name=target_schema,
1066
+ status=status,
1067
+ missing_tables=missing_tables,
1068
+ extra_tables=extra_tables,
1069
+ tables=table_results,
1070
+ )
1071
+
1072
+ # ------------------------------------------------------------------
1073
+ # Per-table pipeline
1074
+ # ------------------------------------------------------------------
1075
+ def _validate_table(
1076
+ self,
1077
+ request: AzureSqlValidationRequest,
1078
+ source_schema: str,
1079
+ target_schema: str,
1080
+ source_table: str,
1081
+ target_table: str,
1082
+ ) -> TableValidationResult:
1083
+ result = TableValidationResult(schema_name=target_schema, table=target_table)
1084
+
1085
+ try:
1086
+ source_schema_df = self.azure_sql.get_table_schema(source_schema, source_table)
1087
+ target_schema_df = self.databricks.get_table_schema(
1088
+ request.target_catalog, target_schema, target_table
1089
+ )
1090
+ except Exception as exc:
1091
+ logger.exception(
1092
+ "Failed to retrieve column metadata for '%s.%s'", source_schema, source_table
1093
+ )
1094
+ result.status = ValidationStatus.ERROR
1095
+ result.error = f"Unable to retrieve column metadata: {exc}"
1096
+ return result
1097
+
1098
+ ignore = {c.lower() for c in (request.ignore_columns or [])}
1099
+
1100
+ missing_cols, extra_cols, common_cols = self._compare_columns(
1101
+ source_schema_df, target_schema_df, request.case_sensitive_columns, ignore,
1102
+ )
1103
+ result.missing_columns = missing_cols
1104
+ result.extra_columns = extra_cols
1105
+ result.columns_status = (
1106
+ ValidationStatus.FAIL if (missing_cols or extra_cols) else ValidationStatus.PASS
1107
+ )
1108
+
1109
+ if not common_cols:
1110
+ result.status = ValidationStatus.FAIL
1111
+ result.error = "No common columns between source and target"
1112
+ return result
1113
+
1114
+ source_order = [
1115
+ c for c in source_schema_df["column_name"].tolist()
1116
+ if c.lower() in {x.lower() for x in common_cols}
1117
+ ]
1118
+ target_order = [
1119
+ c for c in target_schema_df["column_name"].tolist()
1120
+ if c.lower() in {x.lower() for x in common_cols}
1121
+ ]
1122
+ result.source_column_order = source_order
1123
+ result.target_column_order = target_order
1124
+
1125
+ if request.validate_column_order:
1126
+ order_matches = [c.lower() for c in source_order] == [c.lower() for c in target_order]
1127
+ result.column_order_status = (
1128
+ ValidationStatus.PASS if order_matches else ValidationStatus.FAIL
1129
+ )
1130
+ else:
1131
+ result.column_order_status = ValidationStatus.SKIPPED
1132
+
1133
+ src_by_col = {
1134
+ str(r["column_name"]).lower(): r for _, r in source_schema_df.iterrows()
1135
+ }
1136
+ tgt_by_col = {
1137
+ str(r["column_name"]).lower(): r for _, r in target_schema_df.iterrows()
1138
+ }
1139
+
1140
+ min_max_columns = [
1141
+ c for c in common_cols
1142
+ if self.azure_sql.is_min_max_eligible(str(src_by_col.get(c.lower(), {}).get("data_type", "")))
1143
+ ]
1144
+
1145
+ try:
1146
+ source_stats = self.azure_sql.get_column_statistics(
1147
+ source_schema, source_table, common_cols, min_max_columns,
1148
+ )
1149
+ target_stats = self.databricks.get_column_statistics(
1150
+ request.target_catalog, target_schema, target_table, common_cols, min_max_columns,
1151
+ )
1152
+ stats_error = None
1153
+ except Exception as exc:
1154
+ logger.exception(
1155
+ "Failed to compute column statistics for '%s.%s'", source_schema, source_table
1156
+ )
1157
+ source_stats, target_stats = {}, {}
1158
+ stats_error = str(exc)
1159
+
1160
+ column_results: List[ColumnValidationResult] = []
1161
+ dtype_statuses, nullable_statuses = [], []
1162
+ null_statuses, distinct_statuses, minmax_statuses = [], [], []
1163
+
1164
+ for col in common_cols:
1165
+ key = col.lower()
1166
+ src_row = src_by_col.get(key, {})
1167
+ tgt_row = tgt_by_col.get(key, {})
1168
+
1169
+ col_result = ColumnValidationResult(column=col, status=ValidationStatus.PASS)
1170
+
1171
+ src_type = str(src_row.get("data_type"))
1172
+ tgt_type = str(tgt_row.get("data_type"))
1173
+ col_result.source_data_type = src_type
1174
+ col_result.target_data_type = tgt_type
1175
+ col_result.data_type_status = (
1176
+ ValidationStatus.PASS if self._types_compatible(src_type, tgt_type)
1177
+ else ValidationStatus.FAIL
1178
+ )
1179
+ dtype_statuses.append(col_result.data_type_status)
1180
+
1181
+ src_null = bool(src_row.get("is_nullable"))
1182
+ tgt_null = bool(tgt_row.get("is_nullable"))
1183
+ col_result.source_nullable = src_null
1184
+ col_result.target_nullable = tgt_null
1185
+ col_result.nullable_status = (
1186
+ ValidationStatus.PASS if src_null == tgt_null else ValidationStatus.FAIL
1187
+ )
1188
+ nullable_statuses.append(col_result.nullable_status)
1189
+
1190
+ if stats_error:
1191
+ col_result.null_count_status = ValidationStatus.ERROR
1192
+ col_result.distinct_count_status = ValidationStatus.ERROR
1193
+ col_result.error = stats_error
1194
+ else:
1195
+ s_stat = source_stats.get(col, {})
1196
+ t_stat = target_stats.get(col, {})
1197
+
1198
+ col_result.source_null_count = s_stat.get("null_count")
1199
+ col_result.target_null_count = t_stat.get("null_count")
1200
+ col_result.null_count_status = (
1201
+ ValidationStatus.PASS
1202
+ if col_result.source_null_count == col_result.target_null_count
1203
+ else ValidationStatus.FAIL
1204
+ )
1205
+
1206
+ col_result.source_distinct_count = s_stat.get("distinct_count")
1207
+ col_result.target_distinct_count = t_stat.get("distinct_count")
1208
+ col_result.distinct_count_status = (
1209
+ ValidationStatus.PASS
1210
+ if col_result.source_distinct_count == col_result.target_distinct_count
1211
+ else ValidationStatus.FAIL
1212
+ )
1213
+
1214
+ if col in min_max_columns:
1215
+ col_result.source_min = s_stat.get("min")
1216
+ col_result.source_max = s_stat.get("max")
1217
+ col_result.target_min = t_stat.get("min")
1218
+ col_result.target_max = t_stat.get("max")
1219
+ col_result.min_max_status = (
1220
+ ValidationStatus.FAIL
1221
+ if (values_differ(col_result.source_min, col_result.target_min)
1222
+ or values_differ(col_result.source_max, col_result.target_max))
1223
+ else ValidationStatus.PASS
1224
+ )
1225
+ else:
1226
+ col_result.min_max_status = ValidationStatus.SKIPPED
1227
+
1228
+ null_statuses.append(col_result.null_count_status)
1229
+ distinct_statuses.append(col_result.distinct_count_status)
1230
+ minmax_statuses.append(col_result.min_max_status)
1231
+
1232
+ col_result.status = _calculate_overall_status(
1233
+ [
1234
+ col_result.data_type_status,
1235
+ col_result.nullable_status,
1236
+ col_result.null_count_status,
1237
+ col_result.distinct_count_status,
1238
+ col_result.min_max_status,
1239
+ ]
1240
+ )
1241
+ column_results.append(col_result)
1242
+
1243
+ result.columns = column_results
1244
+ result.data_types_status = _calculate_overall_status(dtype_statuses)
1245
+ result.nullable_status = _calculate_overall_status(nullable_statuses)
1246
+ result.null_counts_status = _calculate_overall_status(null_statuses)
1247
+ result.distinct_counts_status = _calculate_overall_status(distinct_statuses)
1248
+ result.min_max_status = _calculate_overall_status(minmax_statuses)
1249
+
1250
+ try:
1251
+ src_count = self.azure_sql.get_row_count(source_schema, source_table)
1252
+ tgt_count = self.databricks.get_row_count(request.target_catalog, target_schema, target_table)
1253
+ result.row_count_source = src_count
1254
+ result.row_count_target = tgt_count
1255
+ result.row_count_difference = tgt_count - src_count
1256
+ result.row_count_status = (
1257
+ ValidationStatus.PASS if src_count == tgt_count else ValidationStatus.FAIL
1258
+ )
1259
+ except Exception as exc:
1260
+ logger.exception("Failed to compute row counts for '%s.%s'", source_schema, source_table)
1261
+ result.row_count_status = ValidationStatus.ERROR
1262
+ result.error = f"Row count failed: {exc}"
1263
+
1264
+ result.data = self._compare_data(
1265
+ request, source_schema, target_schema, source_table, target_table,
1266
+ common_cols, src_by_col, tgt_by_col,
1267
+ )
1268
+
1269
+ result.status = _calculate_overall_status(
1270
+ [
1271
+ result.columns_status,
1272
+ result.column_order_status,
1273
+ result.row_count_status,
1274
+ result.data_types_status,
1275
+ result.nullable_status,
1276
+ result.null_counts_status,
1277
+ result.distinct_counts_status,
1278
+ result.min_max_status,
1279
+ result.data.status if result.data else ValidationStatus.SKIPPED,
1280
+ ]
1281
+ )
1282
+
1283
+ return result
1284
+
1285
+ # ------------------------------------------------------------------
1286
+ # Column comparison (mirrors CatalogValidator.compare_columns)
1287
+ # ------------------------------------------------------------------
1288
+ @staticmethod
1289
+ def _compare_columns(
1290
+ source_schema_df: pd.DataFrame,
1291
+ target_schema_df: pd.DataFrame,
1292
+ case_sensitive: bool,
1293
+ ignore: Set[str],
1294
+ ) -> Tuple[List[str], List[str], List[str]]:
1295
+ def norm(name: str) -> str:
1296
+ return name if case_sensitive else name.lower()
1297
+
1298
+ src_cols = {
1299
+ norm(str(c)): str(c) for c in source_schema_df["column_name"]
1300
+ if norm(str(c)) not in ignore and str(c).lower() not in ignore
1301
+ }
1302
+ tgt_cols = {
1303
+ norm(str(c)): str(c) for c in target_schema_df["column_name"]
1304
+ if norm(str(c)) not in ignore and str(c).lower() not in ignore
1305
+ }
1306
+
1307
+ missing = sorted(set(src_cols) - set(tgt_cols))
1308
+ extra = sorted(set(tgt_cols) - set(src_cols))
1309
+ common = sorted(src_cols[k] for k in (set(src_cols) & set(tgt_cols)))
1310
+ return missing, extra, common
1311
+
1312
+ @staticmethod
1313
+ def _types_compatible(source_type: str, target_type: str) -> bool:
1314
+ """
1315
+ SQL Server and Databricks use different type-name vocabularies for
1316
+ the same underlying kind of value (e.g. SQL Server 'varchar' vs
1317
+ Databricks 'string', 'int' vs 'int'/'bigint') - this is
1318
+ informational, not authoritative; the actual values are still
1319
+ compared via row-hash regardless of what this reports.
1320
+ """
1321
+ s = source_type.strip().lower()
1322
+ t = target_type.split("(")[0].strip().lower()
1323
+
1324
+ if s == t:
1325
+ return True
1326
+
1327
+ numeric = {
1328
+ "tinyint", "smallint", "int", "bigint",
1329
+ "float", "real", "decimal", "numeric", "money", "smallmoney",
1330
+ "double",
1331
+ }
1332
+ stringy = {"char", "varchar", "nchar", "nvarchar", "text", "ntext", "string"}
1333
+ datey = {"date", "datetime", "datetime2", "smalldatetime", "timestamp"}
1334
+ booly = {"bit", "boolean"}
1335
+
1336
+ for group in (numeric, stringy, datey, booly):
1337
+ if s in group and t in group:
1338
+ return True
1339
+ return False
1340
+
1341
+ _SQL_DECIMAL_TYPES = {"decimal", "numeric", "money", "smallmoney"}
1342
+ _SQL_INTEGER_TARGET_TYPES = {"tinyint", "smallint", "int", "bigint"}
1343
+
1344
+ @classmethod
1345
+ def _effective_source_types(
1346
+ cls,
1347
+ value_columns: List[str],
1348
+ src_by_col: Dict[str, Any],
1349
+ tgt_by_col: Dict[str, Any],
1350
+ ) -> Dict[str, str]:
1351
+ """
1352
+ Build the {column: sql_server_type} map passed to
1353
+ AzureSqlConnector.get_row_hashes/get_row_hashes_by_row_number,
1354
+ substituting the synthetic type "decimal_as_integer" whenever the
1355
+ source is decimal/numeric/money but the target's real Databricks
1356
+ type is a plain integer (bigint/int/smallint/tinyint) - otherwise
1357
+ every row would hash as "changed" purely from Azure SQL keeping
1358
+ a fractional format (e.g. '45000.00') that Databricks' integer
1359
+ column never produces ('45000'), even when the numeric value is
1360
+ identical (found empirically comparing HASHBYTES vs sha2() output
1361
+ for a decimal-vs-bigint Salary column on real data).
1362
+ """
1363
+ result: Dict[str, str] = {}
1364
+ for col in value_columns:
1365
+ src_type = str(src_by_col.get(col.lower(), {}).get("data_type", "")).strip().lower()
1366
+ tgt_type = str(tgt_by_col.get(col.lower(), {}).get("data_type", "")).split("(")[0].strip().lower()
1367
+
1368
+ if src_type in cls._SQL_DECIMAL_TYPES and tgt_type in cls._SQL_INTEGER_TARGET_TYPES:
1369
+ result[col] = "decimal_as_integer"
1370
+ else:
1371
+ result[col] = src_type
1372
+
1373
+ return result
1374
+
1375
+ # ------------------------------------------------------------------
1376
+ # Row-hash comparison + row-level data mismatch detail
1377
+ # ------------------------------------------------------------------
1378
+ def _compare_data(
1379
+ self,
1380
+ request: AzureSqlValidationRequest,
1381
+ source_schema: str,
1382
+ target_schema: str,
1383
+ source_table: str,
1384
+ target_table: str,
1385
+ common_columns: List[str],
1386
+ src_by_col: Dict[str, Any],
1387
+ tgt_by_col: Dict[str, Any],
1388
+ ) -> DataValidationResult:
1389
+ mode = request.data_compare_mode
1390
+ key = f"{target_schema}.{target_table}"
1391
+ primary_key = request.primary_keys.get(key) or request.primary_keys.get(target_table)
1392
+
1393
+ logger.info(
1394
+ "[azuresql-row-hash] table=%s.%s | resolved_key_columns=%s",
1395
+ target_schema, target_table, primary_key,
1396
+ )
1397
+
1398
+ using_row_number_fallback = False
1399
+
1400
+ if not primary_key:
1401
+ using_row_number_fallback = True
1402
+ key_columns_for_result: List[str] = ["row_number"]
1403
+ value_columns = sorted(common_columns)
1404
+
1405
+ logger.info(
1406
+ "[azuresql-row-hash] no key configured for '%s' - falling back to "
1407
+ "ROW_NUMBER()-based comparison (ORDER BY every common column). This "
1408
+ "is a best-effort fallback, not a substitute for a real key: it can "
1409
+ "only meaningfully compare tables whose row SETS are otherwise "
1410
+ "identical.", key,
1411
+ )
1412
+
1413
+ source_types = self._effective_source_types(value_columns, src_by_col, tgt_by_col)
1414
+
1415
+ try:
1416
+ source_hashes = self.azure_sql.get_row_hashes_by_row_number(
1417
+ source_schema, source_table, value_columns, source_types,
1418
+ )
1419
+ except Exception as exc:
1420
+ logger.exception("Failed to compute source row-number hashes for '%s'", key)
1421
+ return DataValidationResult(
1422
+ mode=mode, status=ValidationStatus.ERROR, key_columns=key_columns_for_result,
1423
+ error=f"Source row-number hashing failed: {exc}",
1424
+ )
1425
+
1426
+ try:
1427
+ target_hashes = self.databricks.get_row_hashes_by_row_number(
1428
+ request.target_catalog, target_schema, target_table, value_columns,
1429
+ )
1430
+ except Exception as exc:
1431
+ logger.exception("Failed to fetch target row-number hashes for '%s'", key)
1432
+ return DataValidationResult(
1433
+ mode=mode, status=ValidationStatus.ERROR, key_columns=key_columns_for_result,
1434
+ error=f"Target row-number hashing failed: {exc}",
1435
+ )
1436
+
1437
+ primary_key = ["row_number"]
1438
+ else:
1439
+ key_columns_for_result = primary_key
1440
+
1441
+ missing_keys = [
1442
+ k for k in primary_key if k.lower() not in {c.lower() for c in common_columns}
1443
+ ]
1444
+ if missing_keys:
1445
+ return DataValidationResult(
1446
+ mode=mode, status=ValidationStatus.ERROR, key_columns=primary_key,
1447
+ error=f"Configured key column(s) not found as common columns: {missing_keys}",
1448
+ )
1449
+
1450
+ value_columns = sorted(
1451
+ c for c in common_columns if c.lower() not in {k.lower() for k in primary_key}
1452
+ )
1453
+
1454
+ source_types = self._effective_source_types(value_columns, src_by_col, tgt_by_col)
1455
+
1456
+ try:
1457
+ source_hashes = self.azure_sql.get_row_hashes(
1458
+ source_schema, source_table, value_columns, primary_key, source_types,
1459
+ )
1460
+ except Exception as exc:
1461
+ logger.exception("Failed to compute source row hashes for '%s'", key)
1462
+ return DataValidationResult(
1463
+ mode=mode, status=ValidationStatus.ERROR, key_columns=primary_key,
1464
+ error=f"Source row hashing failed: {exc}",
1465
+ )
1466
+
1467
+ try:
1468
+ target_hashes = self.databricks.get_row_hashes(
1469
+ request.target_catalog, target_schema, target_table, value_columns, primary_key,
1470
+ )
1471
+ except Exception as exc:
1472
+ logger.exception("Failed to fetch target row hashes for '%s'", key)
1473
+ return DataValidationResult(
1474
+ mode=mode, status=ValidationStatus.ERROR, key_columns=primary_key,
1475
+ error=f"Target row hashing failed: {exc}",
1476
+ )
1477
+
1478
+ logger.info(
1479
+ "[azuresql-row-hash] fetched | table=%s.%s | source_rows=%d | target_rows=%d | "
1480
+ "row_number_fallback=%s",
1481
+ target_schema, target_table, len(source_hashes), len(target_hashes),
1482
+ using_row_number_fallback,
1483
+ )
1484
+
1485
+ mismatches, mismatch_count, mismatch_pct = self._compare_row_hashes(
1486
+ source_hashes, target_hashes, primary_key,
1487
+ )
1488
+
1489
+ logger.info(
1490
+ "[azuresql-row-hash] table=%s.%s | mismatch_count=%d | mismatch_pct=%.2f%%",
1491
+ target_schema, target_table, mismatch_count, mismatch_pct,
1492
+ )
1493
+
1494
+ data_result = DataValidationResult(
1495
+ mode=mode,
1496
+ status=ValidationStatus.FAIL if mismatch_count > 0 else ValidationStatus.PASS,
1497
+ key_columns=key_columns_for_result,
1498
+ row_hash_mismatches=mismatches,
1499
+ row_hash_mismatch_count=mismatch_count,
1500
+ row_hash_mismatch_percentage=mismatch_pct,
1501
+ note=(
1502
+ "No primary key configured - row-level comparison used a synthetic "
1503
+ "ROW_NUMBER() (ORDER BY every common column) instead of a real key. "
1504
+ "This only detects differences reliably when both sides contain the "
1505
+ "same set of rows; it cannot pinpoint which specific record changed "
1506
+ "the way a real key can, and row numbers are not stable identifiers "
1507
+ "across runs."
1508
+ if using_row_number_fallback else None
1509
+ ),
1510
+ )
1511
+
1512
+ if mode == DataCompareMode.FULL and mismatch_count > 0 and not using_row_number_fallback:
1513
+ data_result.sample_changed_detail = self._changed_row_detail(
1514
+ request, source_schema, target_schema, source_table, target_table,
1515
+ primary_key, value_columns, source_hashes, target_hashes,
1516
+ )
1517
+
1518
+ return data_result
1519
+
1520
+ @staticmethod
1521
+ def _compare_row_hashes(
1522
+ source_hashes: pd.DataFrame,
1523
+ target_hashes: pd.DataFrame,
1524
+ primary_key_cols: List[str],
1525
+ ) -> Tuple[List[RowHashMismatch], int, float]:
1526
+ # Identical join/classify logic to CatalogValidator.compare_row_hashes.
1527
+ def _display_key(row: pd.Series) -> str:
1528
+ return "|".join(str(row[k]) for k in primary_key_cols)
1529
+
1530
+ def _key_tuple(row: pd.Series) -> tuple:
1531
+ return tuple(row[k] for k in primary_key_cols)
1532
+
1533
+ source_by_key = {_key_tuple(r): r for _, r in source_hashes.iterrows()}
1534
+ target_by_key = {_key_tuple(r): r for _, r in target_hashes.iterrows()}
1535
+
1536
+ all_keys = set(source_by_key) | set(target_by_key)
1537
+ mismatches: List[RowHashMismatch] = []
1538
+
1539
+ for key_tuple in all_keys:
1540
+ src_row = source_by_key.get(key_tuple)
1541
+ tgt_row = target_by_key.get(key_tuple)
1542
+
1543
+ if src_row is not None and tgt_row is None:
1544
+ mismatches.append(RowHashMismatch(
1545
+ primary_key=_display_key(src_row),
1546
+ source_hash=str(src_row["row_hash"]), target_hash="",
1547
+ status="MISSING_IN_TARGET",
1548
+ ))
1549
+ elif src_row is None and tgt_row is not None:
1550
+ mismatches.append(RowHashMismatch(
1551
+ primary_key=_display_key(tgt_row),
1552
+ source_hash="", target_hash=str(tgt_row["row_hash"]),
1553
+ status="MISSING_IN_SOURCE",
1554
+ ))
1555
+ elif src_row is not None and tgt_row is not None:
1556
+ if src_row["row_hash"] != tgt_row["row_hash"]:
1557
+ mismatches.append(RowHashMismatch(
1558
+ primary_key=_display_key(src_row),
1559
+ source_hash=str(src_row["row_hash"]),
1560
+ target_hash=str(tgt_row["row_hash"]),
1561
+ status="MISMATCH",
1562
+ ))
1563
+
1564
+ total = len(all_keys)
1565
+ count = len(mismatches)
1566
+ pct = (count / total) * 100 if total else 0.0
1567
+ return mismatches, count, pct
1568
+
1569
+ def _changed_row_detail(
1570
+ self,
1571
+ request: AzureSqlValidationRequest,
1572
+ source_schema: str,
1573
+ target_schema: str,
1574
+ source_table: str,
1575
+ target_table: str,
1576
+ primary_key: List[str],
1577
+ value_columns: List[str],
1578
+ source_hashes: pd.DataFrame,
1579
+ target_hashes: pd.DataFrame,
1580
+ ) -> List[RowMismatchDetail]:
1581
+ """
1582
+ For MISMATCH keys, fetch the actual source and target row values
1583
+ for a bounded sample and diff column-by-column, same approach as
1584
+ DatabricksConnector._changed_row_detail.
1585
+ """
1586
+ limit_samples = request.max_sample_rows
1587
+
1588
+ src_hash_by_key = {
1589
+ tuple(r[k] for k in primary_key): r["row_hash"] for _, r in source_hashes.iterrows()
1590
+ }
1591
+ tgt_hash_by_key = {
1592
+ tuple(r[k] for k in primary_key): r["row_hash"] for _, r in target_hashes.iterrows()
1593
+ }
1594
+
1595
+ mismatch_keys: List[tuple] = []
1596
+ for key_tuple, src_hash in src_hash_by_key.items():
1597
+ tgt_hash = tgt_hash_by_key.get(key_tuple)
1598
+ if tgt_hash is not None and tgt_hash != src_hash:
1599
+ mismatch_keys.append(key_tuple)
1600
+ if len(mismatch_keys) >= limit_samples:
1601
+ break
1602
+
1603
+ if not mismatch_keys:
1604
+ return []
1605
+
1606
+ source_rows = self._fetch_rows_for_keys(
1607
+ self.azure_sql, source_schema, source_table, primary_key, value_columns, mismatch_keys,
1608
+ )
1609
+ target_rows = self._fetch_rows_for_keys(
1610
+ self.databricks, target_schema, target_table, primary_key, value_columns, mismatch_keys,
1611
+ catalog=request.target_catalog,
1612
+ )
1613
+
1614
+ source_by_key = {tuple(r[k] for k in primary_key): r for r in source_rows}
1615
+ target_by_key = {tuple(r[k] for k in primary_key): r for r in target_rows}
1616
+
1617
+ detail: List[RowMismatchDetail] = []
1618
+ for key_tuple in mismatch_keys:
1619
+ src_row = source_by_key.get(key_tuple)
1620
+ tgt_row = target_by_key.get(key_tuple)
1621
+ if src_row is None or tgt_row is None:
1622
+ continue
1623
+
1624
+ mismatched_columns = [
1625
+ col for col in value_columns
1626
+ if values_differ(src_row.get(col), tgt_row.get(col))
1627
+ ]
1628
+ if not mismatched_columns:
1629
+ mismatched_columns = list(value_columns)
1630
+
1631
+ key_dict = {k: src_row.get(k) for k in primary_key}
1632
+
1633
+ for col in mismatched_columns:
1634
+ detail.append(
1635
+ RowMismatchDetail(
1636
+ schema_name=target_schema,
1637
+ table=target_table,
1638
+ primary_key=key_dict,
1639
+ mismatch_column=col,
1640
+ source_value=src_row.get(col),
1641
+ target_value=tgt_row.get(col),
1642
+ source_row_hash=src_hash_by_key.get(key_tuple),
1643
+ target_row_hash=tgt_hash_by_key.get(key_tuple),
1644
+ )
1645
+ )
1646
+
1647
+ return detail
1648
+
1649
+ @staticmethod
1650
+ def _fetch_rows_for_keys(
1651
+ connector,
1652
+ schema_name: str,
1653
+ table_name: str,
1654
+ primary_key: List[str],
1655
+ value_columns: List[str],
1656
+ keys: List[tuple],
1657
+ catalog: Optional[str] = None,
1658
+ ) -> List[Dict[str, Any]]:
1659
+ """
1660
+ Batched fetch of rows for a bounded set of primary keys, from
1661
+ either connector (AzureSqlConnector or DatabricksConnector - both
1662
+ expose _quote_ident/_qualify/execute_query with compatible
1663
+ signatures, just different quoting/qualification conventions).
1664
+ """
1665
+ if not keys:
1666
+ return []
1667
+
1668
+ key_idents = [connector._quote_ident(k) for k in primary_key]
1669
+ key_list = ", ".join(key_idents)
1670
+ value_list = ", ".join(connector._quote_ident(c) for c in value_columns)
1671
+
1672
+ if catalog is not None:
1673
+ table_fqtn = connector._qualify(catalog, schema_name, table_name)
1674
+ else:
1675
+ table_fqtn = connector._qualify(schema_name, table_name)
1676
+
1677
+ def _sql_literal(value: Any) -> str:
1678
+ if value is None:
1679
+ return "NULL"
1680
+ if isinstance(value, (int, float)):
1681
+ return str(value)
1682
+ return "'" + str(value).replace("'", "''") + "'"
1683
+
1684
+ if len(primary_key) == 1:
1685
+ values_sql = ", ".join(_sql_literal(k[0]) for k in keys)
1686
+ where_clause = f"{key_idents[0]} IN ({values_sql})"
1687
+ else:
1688
+ tuples_sql = ", ".join(
1689
+ "(" + ", ".join(_sql_literal(v) for v in k) + ")" for k in keys
1690
+ )
1691
+ where_clause = f"({key_list}) IN ({tuples_sql})"
1692
+
1693
+ query = f"SELECT {key_list}, {value_list} FROM {table_fqtn} WHERE {where_clause}"
1694
+ return connector.execute_query(query).to_dict(orient="records")
1695
+
1696
+ # ------------------------------------------------------------------
1697
+ # Summary
1698
+ # ------------------------------------------------------------------
1699
+ @staticmethod
1700
+ def _build_summary(
1701
+ schema_results: List[SchemaValidationResult],
1702
+ missing_schemas: List[str],
1703
+ extra_schemas: List[str],
1704
+ ) -> ValidationSummary:
1705
+ summary = ValidationSummary()
1706
+
1707
+ summary.total_schemas = len(schema_results) + len(missing_schemas)
1708
+ summary.failed_schemas = sum(
1709
+ 1 for s in schema_results if s.status in (ValidationStatus.FAIL, ValidationStatus.ERROR)
1710
+ ) + len(missing_schemas)
1711
+ summary.passed_schemas = summary.total_schemas - summary.failed_schemas
1712
+
1713
+ for schema_result in schema_results:
1714
+ summary.total_tables += len(schema_result.tables)
1715
+ summary.missing_tables += len(schema_result.missing_tables)
1716
+ summary.extra_tables += len(schema_result.extra_tables)
1717
+
1718
+ for table in schema_result.tables:
1719
+ if table.status == ValidationStatus.PASS:
1720
+ summary.passed_tables += 1
1721
+ elif table.status == ValidationStatus.ERROR:
1722
+ summary.error_tables += 1
1723
+ summary.failed_tables += 1
1724
+ else:
1725
+ summary.failed_tables += 1
1726
+
1727
+ return summary