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,1863 @@
1
+ """
2
+ Databricks catalog-to-catalog validator.
3
+
4
+ Recursive Databricks catalog-to-catalog validation: catalog -> schemas ->
5
+ tables -> columns -> data, per the 15-stage validation sequence. All
6
+ comparisons are pushed down to Databricks SQL via DatabricksConnector; this
7
+ class never loads a full table into pandas.
8
+
9
+ Responsible for the comparison/decision logic only (PASS/FAIL/ERROR/
10
+ SKIPPED). All data retrieval is delegated to DatabricksConnector - this
11
+ class never talks to Databricks directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import time
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
20
+
21
+ import pandas as pd
22
+
23
+ from table_validator.config.schema import ValidationType
24
+ from table_validator.connectors.databricks_connector import DatabricksConnector
25
+ from table_validator.models import (
26
+ CatalogValidationRequest,
27
+ CatalogValidationResponse,
28
+ ColumnValidationResult,
29
+ DataCompareMode,
30
+ DataValidationResult,
31
+ PartitionPromptContext,
32
+ RowHashMismatch,
33
+ RowMismatchDetail,
34
+ SchemaValidationResult,
35
+ TableValidationResult,
36
+ ValidationStatus,
37
+ ValidationSummary,
38
+ ValidationTier,
39
+ )
40
+
41
+ PartitionPromptCallback = Callable[[PartitionPromptContext], Optional[str]]
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Type families for Tier 0's BLOCKING/NON-BLOCKING classification. Grouped
46
+ # by prefix match against the raw Databricks type string (e.g.
47
+ # "decimal(10,2)" -> matches the "decimal" family). A change *within* a
48
+ # family (e.g. int -> bigint) is a safe widening, reported but
49
+ # NON-BLOCKING; a change *across* families (e.g. string -> int) is
50
+ # BLOCKING, since the two sides may not even be comparable.
51
+ _TYPE_FAMILIES: List[Tuple[str, Tuple[str, ...]]] = [
52
+ ("integer", ("tinyint", "smallint", "int", "bigint")),
53
+ ("floating", ("float", "double", "decimal", "numeric")),
54
+ ("string", ("string", "varchar", "char")),
55
+ ("datetime", ("date", "timestamp")),
56
+ ("boolean", ("boolean",)),
57
+ ("binary", ("binary",)),
58
+ ]
59
+
60
+
61
+ def _type_family(data_type: str) -> Optional[str]:
62
+ dt = (data_type or "").strip().lower()
63
+ for family, prefixes in _TYPE_FAMILIES:
64
+ if any(dt.startswith(p) for p in prefixes):
65
+ return family
66
+ return None
67
+
68
+
69
+ class CatalogValidator:
70
+ """
71
+ Recursive Databricks catalog-to-catalog validator.
72
+
73
+ Usage:
74
+ validator = CatalogValidator(databricks_connector)
75
+ result = validator.compare_catalogs(request)
76
+
77
+ # Optionally, to offer partitioned Tier 4 on large mismatched
78
+ # tables (see _validate_table's Tier 3 branch):
79
+ validator = CatalogValidator(databricks_connector, partition_prompt=my_callback)
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ databricks_connector: DatabricksConnector,
85
+ partition_prompt: Optional[PartitionPromptCallback] = None,
86
+ ) -> None:
87
+ self.databricks = databricks_connector
88
+ self.partition_prompt = partition_prompt
89
+ logger.debug("CatalogValidator initialised")
90
+
91
+ # ------------------------------------------------------------------
92
+ # Stage 1 + top-level orchestration
93
+ # ------------------------------------------------------------------
94
+ def compare_catalogs(
95
+ self,
96
+ request: CatalogValidationRequest,
97
+ ) -> CatalogValidationResponse:
98
+
99
+ start = time.perf_counter()
100
+ run_timestamp = datetime.now(timezone.utc).isoformat()
101
+
102
+ logger.info(
103
+ "Starting catalog validation | source=%s | target=%s",
104
+ request.source_catalog,
105
+ request.target_catalog,
106
+ )
107
+
108
+ # Stage 1: catalog exists
109
+ try:
110
+ source_exists = self.databricks.catalog_exists(request.source_catalog)
111
+ target_exists = self.databricks.catalog_exists(request.target_catalog)
112
+ except Exception as exc:
113
+ logger.exception("Failed to verify catalog existence")
114
+ return CatalogValidationResponse(
115
+ source_catalog=request.source_catalog,
116
+ target_catalog=request.target_catalog,
117
+ status=ValidationStatus.ERROR,
118
+ validation_timestamp=run_timestamp,
119
+ execution_time_seconds=round(time.perf_counter() - start, 3),
120
+ error=f"Unable to verify catalog existence: {exc}",
121
+ )
122
+
123
+ if not source_exists or not target_exists:
124
+ missing = []
125
+ if not source_exists:
126
+ missing.append(request.source_catalog)
127
+ if not target_exists:
128
+ missing.append(request.target_catalog)
129
+ return CatalogValidationResponse(
130
+ source_catalog=request.source_catalog,
131
+ target_catalog=request.target_catalog,
132
+ status=ValidationStatus.FAIL,
133
+ validation_timestamp=run_timestamp,
134
+ execution_time_seconds=round(time.perf_counter() - start, 3),
135
+ error=f"Catalog(s) do not exist: {', '.join(missing)}",
136
+ )
137
+
138
+ # Stage 2/3/4: schemas
139
+ try:
140
+ common_schemas, missing_schemas, extra_schemas = self.compare_schemas(
141
+ request.source_catalog, request.target_catalog
142
+ )
143
+ except Exception as exc:
144
+ logger.exception("Failed to compare schemas")
145
+ return CatalogValidationResponse(
146
+ source_catalog=request.source_catalog,
147
+ target_catalog=request.target_catalog,
148
+ status=ValidationStatus.ERROR,
149
+ validation_timestamp=run_timestamp,
150
+ execution_time_seconds=round(time.perf_counter() - start, 3),
151
+ error=f"Unable to compare schemas: {exc}",
152
+ )
153
+
154
+ if request.schemas:
155
+ # Explicit schema scope: missing_schemas/extra_schemas must be
156
+ # restricted to it too, not just common_schemas - otherwise an
157
+ # unrelated schema difference elsewhere in the catalog (one
158
+ # the user never asked to compare) falsely fails this
159
+ # targeted run.
160
+ wanted = {s.lower() for s in request.schemas}
161
+ common_schemas = [s for s in common_schemas if s.lower() in wanted]
162
+ missing_schemas = [s for s in missing_schemas if s.lower() in wanted]
163
+ extra_schemas = [s for s in extra_schemas if s.lower() in wanted]
164
+ else:
165
+ # No schema restriction -> comparing every schema common to
166
+ # both catalogs. Surface scope + anything present on only one
167
+ # side up front, so a catalog-wide run is never a silent
168
+ # surprise (missing_schemas/extra_schemas are also still
169
+ # carried on the final response for programmatic access).
170
+ if missing_schemas:
171
+ logger.warning(
172
+ "Schemas present in source catalog '%s' but not in target "
173
+ "'%s' (skipped): %s",
174
+ request.source_catalog, request.target_catalog, missing_schemas,
175
+ )
176
+ if extra_schemas:
177
+ logger.warning(
178
+ "Schemas present in target catalog '%s' but not in source "
179
+ "'%s' (skipped): %s",
180
+ request.target_catalog, request.source_catalog, extra_schemas,
181
+ )
182
+ logger.info(
183
+ "Found %d matching schema(s) across both catalogs - comparing all.",
184
+ len(common_schemas),
185
+ )
186
+
187
+ schema_results: List[SchemaValidationResult] = []
188
+
189
+ for schema_name in common_schemas:
190
+ schema_results.append(
191
+ self._validate_schema(request, schema_name)
192
+ )
193
+
194
+ if not request.schemas:
195
+ total_tables = sum(len(s.tables) for s in schema_results)
196
+ logger.info(
197
+ "Catalog-wide comparison scope: %d schema(s), %d table(s) total. "
198
+ "This may take a while.",
199
+ len(common_schemas), total_tables,
200
+ )
201
+
202
+ summary = self._build_summary(schema_results, missing_schemas, extra_schemas)
203
+ schema_enabled = ValidationType.SCHEMA in request.enabled_validations
204
+ overall_status = self.calculate_overall_status(
205
+ [s.status for s in schema_results]
206
+ + ([ValidationStatus.FAIL] if (missing_schemas and schema_enabled) else [])
207
+ )
208
+
209
+ execution_time = round(time.perf_counter() - start, 3)
210
+
211
+ tier_counts: Dict[str, int] = {}
212
+ for schema_result in schema_results:
213
+ for table_result in schema_result.tables:
214
+ tier_name = table_result.tier_reached.name
215
+ tier_counts[tier_name] = tier_counts.get(tier_name, 0) + 1
216
+ if tier_counts:
217
+ logger.info(
218
+ "Tier distribution across %d table(s): %s",
219
+ sum(tier_counts.values()),
220
+ ", ".join(f"{name}={count}" for name, count in tier_counts.items()),
221
+ )
222
+
223
+ logger.info(
224
+ "Catalog validation finished | status=%s | duration=%.3fs",
225
+ overall_status,
226
+ execution_time,
227
+ )
228
+
229
+ return CatalogValidationResponse(
230
+ source_catalog=request.source_catalog,
231
+ target_catalog=request.target_catalog,
232
+ status=overall_status,
233
+ validation_timestamp=run_timestamp,
234
+ execution_time_seconds=execution_time,
235
+ missing_schemas=missing_schemas,
236
+ extra_schemas=extra_schemas,
237
+ summary=summary,
238
+ schemas=schema_results,
239
+ )
240
+
241
+ # Backward/spec-friendly alias
242
+ def validate(self, request: CatalogValidationRequest) -> CatalogValidationResponse:
243
+ return self.compare_catalogs(request)
244
+
245
+ # Databricks-managed system schema, present in every catalog - never a
246
+ # real migration target, so it is excluded from validation entirely
247
+ # (not just skipped: never counted as common/missing/extra either).
248
+ _EXCLUDED_SCHEMAS = {"information_schema"}
249
+
250
+ # ------------------------------------------------------------------
251
+ # Stage 2/3/4: schema comparison
252
+ # ------------------------------------------------------------------
253
+ def compare_schemas(
254
+ self,
255
+ source_catalog: str,
256
+ target_catalog: str,
257
+ ) -> Tuple[List[str], List[str], List[str]]:
258
+
259
+ source_schemas = set(self.databricks.get_schemas(source_catalog))
260
+ target_schemas = set(self.databricks.get_schemas(target_catalog))
261
+
262
+ source_schemas -= {
263
+ s for s in source_schemas if s.lower() in self._EXCLUDED_SCHEMAS
264
+ }
265
+ target_schemas -= {
266
+ s for s in target_schemas if s.lower() in self._EXCLUDED_SCHEMAS
267
+ }
268
+
269
+ common = sorted(source_schemas & target_schemas)
270
+ missing = sorted(source_schemas - target_schemas) # in source, not target
271
+ extra = sorted(target_schemas - source_schemas) # in target, not source
272
+
273
+ return common, missing, extra
274
+
275
+ def _validate_schema(
276
+ self,
277
+ request: CatalogValidationRequest,
278
+ schema_name: str,
279
+ ) -> SchemaValidationResult:
280
+
281
+ try:
282
+ common_tables, missing_tables, extra_tables = self.compare_tables(
283
+ request.source_catalog, request.target_catalog, schema_name
284
+ )
285
+ except Exception as exc:
286
+ logger.exception("Failed to compare tables for schema '%s'", schema_name)
287
+ return SchemaValidationResult(
288
+ schema_name=schema_name,
289
+ status=ValidationStatus.ERROR,
290
+ error=f"Unable to compare tables: {exc}",
291
+ )
292
+
293
+ if request.tables:
294
+ # Explicit table scope: missing_tables/extra_tables must be
295
+ # restricted to it too, not just common_tables - otherwise an
296
+ # unrelated table difference elsewhere in the same schema
297
+ # (one the user never asked to compare) falsely fails this
298
+ # targeted run.
299
+ wanted = {t.lower() for t in request.tables}
300
+ common_tables = [t for t in common_tables if t.lower() in wanted]
301
+ missing_tables = [t for t in missing_tables if t.lower() in wanted]
302
+ extra_tables = [t for t in extra_tables if t.lower() in wanted]
303
+ else:
304
+ # No table restriction -> comparing every table common to this
305
+ # schema on both sides. Surface anything present on only one
306
+ # side rather than silently skipping it.
307
+ if missing_tables:
308
+ logger.warning(
309
+ "Tables present in source schema '%s.%s' but not in "
310
+ "target (skipped): %s",
311
+ request.source_catalog, schema_name, missing_tables,
312
+ )
313
+ if extra_tables:
314
+ logger.warning(
315
+ "Tables present in target schema '%s.%s' but not in "
316
+ "source (skipped): %s",
317
+ request.target_catalog, schema_name, extra_tables,
318
+ )
319
+
320
+ table_results: List[TableValidationResult] = []
321
+
322
+ for table_name in common_tables:
323
+ table_results.append(
324
+ self._validate_table(request, schema_name, table_name)
325
+ )
326
+
327
+ statuses = [t.status for t in table_results]
328
+ if missing_tables and ValidationType.SCHEMA in request.enabled_validations:
329
+ statuses.append(ValidationStatus.FAIL)
330
+
331
+ status = self.calculate_overall_status(statuses)
332
+
333
+ return SchemaValidationResult(
334
+ schema_name=schema_name,
335
+ status=status,
336
+ missing_tables=missing_tables,
337
+ extra_tables=extra_tables,
338
+ tables=table_results,
339
+ )
340
+
341
+ # ------------------------------------------------------------------
342
+ # Stage 3/4: table comparison
343
+ # ------------------------------------------------------------------
344
+ def compare_tables(
345
+ self,
346
+ source_catalog: str,
347
+ target_catalog: str,
348
+ schema_name: str,
349
+ ) -> Tuple[List[str], List[str], List[str]]:
350
+
351
+ source_tables = set(self.databricks.get_tables(source_catalog, schema_name))
352
+ target_tables = set(self.databricks.get_tables(target_catalog, schema_name))
353
+
354
+ common = sorted(source_tables & target_tables)
355
+ missing = sorted(source_tables - target_tables)
356
+ extra = sorted(target_tables - source_tables)
357
+
358
+ return common, missing, extra
359
+
360
+ # ------------------------------------------------------------------
361
+ # Per-table pipeline: stages 5-15
362
+ # ------------------------------------------------------------------
363
+ def _validate_table(
364
+ self,
365
+ request: CatalogValidationRequest,
366
+ schema_name: str,
367
+ table_name: str,
368
+ ) -> TableValidationResult:
369
+
370
+ # Deliberately a plain, uncluttered progress line (unlike the
371
+ # detailed [row-hash]/stats logging further down) - this is what
372
+ # a user watching the console during a large catalog-wide run
373
+ # needs to see to know the tool is progressing, not stalled.
374
+ logger.info("Validating table '%s.%s' ...", schema_name, table_name)
375
+
376
+ result = TableValidationResult(schema_name=schema_name, table=table_name)
377
+
378
+ try:
379
+ source_schema_df = self.databricks.get_table_schema(
380
+ request.source_catalog, schema_name, table_name
381
+ )
382
+ target_schema_df = self.databricks.get_table_schema(
383
+ request.target_catalog, schema_name, table_name
384
+ )
385
+ except Exception as exc:
386
+ logger.exception(
387
+ "Failed to retrieve column metadata for '%s.%s'", schema_name, table_name
388
+ )
389
+ result.status = ValidationStatus.ERROR
390
+ result.error = f"Unable to retrieve column metadata: {exc}"
391
+ return result
392
+
393
+ row_enabled = ValidationType.ROW in request.enabled_validations
394
+
395
+ # Tier 0: schema comparison. A BLOCKING difference (missing/extra
396
+ # column, cross-family type change, missing configured PK column)
397
+ # aborts here - no further tier runs, no further SQL. A
398
+ # NON-BLOCKING difference (nullable, column order) is recorded
399
+ # but execution continues into Tier 1+.
400
+ blocking, common_cols = self._tier0_schema(
401
+ request, schema_name, table_name, source_schema_df, target_schema_df, result,
402
+ )
403
+
404
+ if blocking:
405
+ result.tier_reached = ValidationTier.SCHEMA_BLOCKED
406
+ result.schema_blocking = True
407
+ logger.info(
408
+ "[tier0-schema] table=%s.%s | BLOCKING schema difference - aborting, "
409
+ "no further tier will run",
410
+ schema_name, table_name,
411
+ )
412
+ if not common_cols:
413
+ result.error = "No common columns between source and target"
414
+ result.status = self.calculate_overall_status(
415
+ [
416
+ result.columns_status,
417
+ result.column_order_status,
418
+ result.data_types_status,
419
+ result.nullable_status,
420
+ ]
421
+ )
422
+ return result
423
+
424
+ if not row_enabled:
425
+ # ROW deselected: schema-only verdict, no row-level SQL at all.
426
+ result.tier_reached = ValidationTier.SCHEMA_ONLY
427
+ result.row_count_status = ValidationStatus.SKIPPED
428
+ result.data = DataValidationResult(
429
+ mode=request.data_compare_mode,
430
+ status=ValidationStatus.SKIPPED,
431
+ note="Row-level comparison skipped - 'row' validation not selected.",
432
+ )
433
+ result.status = self.calculate_overall_status(
434
+ [
435
+ result.columns_status,
436
+ result.column_order_status,
437
+ result.data_types_status,
438
+ result.nullable_status,
439
+ ]
440
+ )
441
+ return result
442
+
443
+ # Tier 1: statistical profile. Only the explicit --mode=stats
444
+ # ceiling stops the funnel here. A real statistical mismatch
445
+ # (row count, null/distinct count, min/max) no longer stops the
446
+ # funnel by itself - it now falls through to Tier 2+ so the
447
+ # actual differing row(s) get surfaced in the Data Mismatches /
448
+ # Row Hash Mismatches sheets, rather than leaving a confirmed
449
+ # difference with no row-level detail at all.
450
+ stats_mismatch = self._tier1_statistics(
451
+ request, schema_name, table_name, common_cols, source_schema_df, result,
452
+ )
453
+
454
+ stats_only_ceiling = request.max_tier == ValidationTier.STATISTICAL
455
+
456
+ if stats_only_ceiling:
457
+ result.tier_reached = ValidationTier.STATISTICAL
458
+ logger.info(
459
+ "[tier1-statistics] table=%s.%s | stopping here - --mode=stats requested",
460
+ schema_name, table_name,
461
+ )
462
+ result.data = DataValidationResult(
463
+ mode=request.data_compare_mode,
464
+ status=ValidationStatus.SKIPPED,
465
+ note="Statistical-only mode requested - row-level comparison not run.",
466
+ )
467
+ result.status = self.calculate_overall_status(
468
+ [
469
+ result.columns_status,
470
+ result.column_order_status,
471
+ result.row_count_status,
472
+ result.data_types_status,
473
+ result.nullable_status,
474
+ result.null_counts_status,
475
+ result.distinct_counts_status,
476
+ result.min_max_status,
477
+ ]
478
+ )
479
+ return result
480
+
481
+ if stats_mismatch:
482
+ logger.info(
483
+ "[tier1-statistics] table=%s.%s | statistical mismatch found - "
484
+ "proceeding to Tier 2+ to locate the exact differing row(s)",
485
+ schema_name, table_name,
486
+ )
487
+
488
+ # Tier 2: whole-table fingerprint. Match -> tables are equal per
489
+ # the fingerprint, but a confirmed Tier 1 mismatch always wins:
490
+ # trust the cheaper, already-confirmed finding and still proceed
491
+ # to Tier 4 rather than reporting PASS on a fingerprint that
492
+ # happens to collide (e.g. a min/max-only mismatch on a column
493
+ # excluded from hashing).
494
+ fingerprint_matches = self._tier2_fingerprint(
495
+ request, schema_name, table_name, common_cols, result,
496
+ )
497
+
498
+ if fingerprint_matches and not stats_mismatch:
499
+ result.tier_reached = ValidationTier.FINGERPRINT
500
+ logger.info(
501
+ "[tier2-fingerprint] table=%s.%s | stopping here - fingerprint "
502
+ "matched, tables are equal (no row-hash SQL will run)",
503
+ schema_name, table_name,
504
+ )
505
+ else:
506
+ logger.info(
507
+ "[tier2-fingerprint] table=%s.%s | %s - proceeding to Tier 4 row-hash diff",
508
+ schema_name, table_name,
509
+ "mismatch" if not fingerprint_matches else "fingerprint matched but "
510
+ "Tier 1 already confirmed a mismatch",
511
+ )
512
+ if fingerprint_matches and result.data is not None:
513
+ # The fingerprint matched but Tier 1 already proved a real
514
+ # difference exists - don't let the fingerprint's PASS
515
+ # verdict survive into Tier 4. Reset to SKIPPED so Tier 4's
516
+ # own status logic (FAIL on a real mismatch, PASS only if
517
+ # it genuinely finds none) is what actually decides this,
518
+ # not a stale, now-overridden fingerprint result.
519
+ result.data.status = ValidationStatus.SKIPPED
520
+ result.data.fingerprint_status = ValidationStatus.PASS
521
+ result.data.note = (
522
+ "Whole-table fingerprint matched, but a statistical "
523
+ "mismatch (row count, null count, distinct count, or "
524
+ "min/max) was already found at Tier 1 - proceeding to "
525
+ "row-hash comparison to locate it, since it may involve "
526
+ "a column excluded from the fingerprint's hash."
527
+ )
528
+ # Tier 3 (optional, large confirmed-mismatched tables only) +
529
+ # Tier 4 (+ Tier 5 for any ROW_HASH_MISMATCH keys).
530
+ self._dispatch_tier4(
531
+ request, schema_name, table_name, common_cols, result,
532
+ stats_mismatch=stats_mismatch,
533
+ )
534
+
535
+ # Overall table status
536
+ result.status = self.calculate_overall_status(
537
+ [
538
+ result.columns_status,
539
+ result.column_order_status,
540
+ result.row_count_status,
541
+ result.data_types_status,
542
+ result.nullable_status,
543
+ result.null_counts_status,
544
+ result.distinct_counts_status,
545
+ result.min_max_status,
546
+ result.data.status if result.data else ValidationStatus.SKIPPED,
547
+ ]
548
+ )
549
+
550
+ return result
551
+
552
+ # ------------------------------------------------------------------
553
+ # Stage 5/6: column name comparison
554
+ # ------------------------------------------------------------------
555
+ def compare_columns(
556
+ self,
557
+ source_schema_df: pd.DataFrame,
558
+ target_schema_df: pd.DataFrame,
559
+ case_sensitive: bool,
560
+ ignore: Set[str],
561
+ ) -> Tuple[List[str], List[str], List[str]]:
562
+
563
+ def norm(name: str) -> str:
564
+ return name if case_sensitive else name.lower()
565
+
566
+ src_cols = {
567
+ norm(str(c)): str(c) for c in source_schema_df["column_name"]
568
+ if norm(str(c)) not in ignore and str(c).lower() not in ignore
569
+ }
570
+ tgt_cols = {
571
+ norm(str(c)): str(c) for c in target_schema_df["column_name"]
572
+ if norm(str(c)) not in ignore and str(c).lower() not in ignore
573
+ }
574
+
575
+ missing = sorted(set(src_cols) - set(tgt_cols))
576
+ extra = sorted(set(tgt_cols) - set(src_cols))
577
+ common = sorted(
578
+ src_cols[k] for k in (set(src_cols) & set(tgt_cols))
579
+ )
580
+
581
+ return missing, extra, common
582
+
583
+ # ------------------------------------------------------------------
584
+ # Stage 7: data types (per-column, used above; exposed for reuse/tests)
585
+ # ------------------------------------------------------------------
586
+ def compare_data_types(self, source_type: str, target_type: str) -> ValidationStatus:
587
+ return ValidationStatus.PASS if source_type == target_type else ValidationStatus.FAIL
588
+
589
+ # ------------------------------------------------------------------
590
+ # Stage 8: nullable (exposed for reuse/tests)
591
+ # ------------------------------------------------------------------
592
+ def compare_nullable(self, source_nullable: bool, target_nullable: bool) -> ValidationStatus:
593
+ return ValidationStatus.PASS if source_nullable == target_nullable else ValidationStatus.FAIL
594
+
595
+ # ------------------------------------------------------------------
596
+ # Stage 9: column order (exposed for reuse/tests)
597
+ # ------------------------------------------------------------------
598
+ def compare_column_order(
599
+ self, source_order: List[str], target_order: List[str]
600
+ ) -> ValidationStatus:
601
+ return (
602
+ ValidationStatus.PASS
603
+ if [c.lower() for c in source_order] == [c.lower() for c in target_order]
604
+ else ValidationStatus.FAIL
605
+ )
606
+
607
+ # ------------------------------------------------------------------
608
+ # Stage 10: row counts (exposed for reuse/tests)
609
+ # ------------------------------------------------------------------
610
+ def compare_row_counts(self, source_count: int, target_count: int) -> ValidationStatus:
611
+ return ValidationStatus.PASS if source_count == target_count else ValidationStatus.FAIL
612
+
613
+ # ------------------------------------------------------------------
614
+ # Stage 11/12: null + distinct counts (exposed for reuse/tests)
615
+ # ------------------------------------------------------------------
616
+ def compare_null_counts(self, source_nulls: int, target_nulls: int) -> ValidationStatus:
617
+ return ValidationStatus.PASS if source_nulls == target_nulls else ValidationStatus.FAIL
618
+
619
+ def compare_distinct_counts(self, source_distinct: int, target_distinct: int) -> ValidationStatus:
620
+ return ValidationStatus.PASS if source_distinct == target_distinct else ValidationStatus.FAIL
621
+
622
+ # ------------------------------------------------------------------
623
+ # Stage 13: min/max (exposed for reuse/tests)
624
+ # ------------------------------------------------------------------
625
+ def compare_min_max(
626
+ self, source_min: Any, source_max: Any, target_min: Any, target_max: Any
627
+ ) -> ValidationStatus:
628
+ return (
629
+ ValidationStatus.PASS
630
+ if source_min == target_min and source_max == target_max
631
+ else ValidationStatus.FAIL
632
+ )
633
+
634
+ # ------------------------------------------------------------------
635
+ # Tier 0 classification: same-family widening (NON-BLOCKING) vs.
636
+ # cross-family type change (BLOCKING). Static/pure so it's directly
637
+ # unit-testable without a connector mock.
638
+ # ------------------------------------------------------------------
639
+ @staticmethod
640
+ def _classify_type_family(source_type: str, target_type: str) -> ValidationStatus:
641
+ if source_type == target_type:
642
+ return ValidationStatus.PASS
643
+
644
+ src_family = _type_family(source_type)
645
+ tgt_family = _type_family(target_type)
646
+
647
+ if src_family is not None and src_family == tgt_family:
648
+ # Same family (e.g. int -> bigint): a real, reportable
649
+ # difference, but not one that invalidates row-level
650
+ # comparison downstream.
651
+ return ValidationStatus.PASS
652
+
653
+ return ValidationStatus.FAIL
654
+
655
+ # ------------------------------------------------------------------
656
+ # Tier 0: schema comparison (always runs first). Classifies every
657
+ # difference as BLOCKING (abort the whole table - never run Tier 1+)
658
+ # or NON-BLOCKING (record the finding, continue). A schema difference
659
+ # must never, by itself, prevent statistical/fingerprint/row-level
660
+ # tiers from running unless it's BLOCKING.
661
+ # ------------------------------------------------------------------
662
+ def _tier0_schema(
663
+ self,
664
+ request: CatalogValidationRequest,
665
+ schema_name: str,
666
+ table_name: str,
667
+ source_schema_df: pd.DataFrame,
668
+ target_schema_df: pd.DataFrame,
669
+ result: TableValidationResult,
670
+ ) -> Tuple[bool, List[str]]:
671
+ """
672
+ Returns (blocking, common_cols). When blocking is True, the caller
673
+ must abort the table immediately without running any further tier.
674
+ """
675
+ ignore = {c.lower() for c in (request.ignore_columns or [])}
676
+ column_enabled = ValidationType.COLUMN in request.enabled_validations
677
+
678
+ missing_cols, extra_cols, common_cols = self.compare_columns(
679
+ source_schema_df, target_schema_df, request.case_sensitive_columns, ignore
680
+ )
681
+
682
+ if column_enabled:
683
+ result.missing_columns = missing_cols
684
+ result.extra_columns = extra_cols
685
+ result.columns_status = (
686
+ ValidationStatus.FAIL if (missing_cols or extra_cols) else ValidationStatus.PASS
687
+ )
688
+ else:
689
+ result.columns_status = ValidationStatus.SKIPPED
690
+
691
+ if missing_cols or extra_cols or not common_cols:
692
+ # Missing/extra columns are always BLOCKING, regardless of
693
+ # whether COLUMN reporting is enabled - Tier 0's detection is
694
+ # a correctness prerequisite for every downstream tier, same
695
+ # as compare_columns() always running today for common_cols.
696
+ return True, common_cols
697
+
698
+ # Configured PK column missing from either side is also BLOCKING -
699
+ # every later tier depends on being able to resolve a usable key.
700
+ key_lookup = f"{schema_name}.{table_name}"
701
+ configured_key = request.primary_keys.get(key_lookup) or request.primary_keys.get(
702
+ table_name
703
+ )
704
+ if configured_key:
705
+ common_lower = {c.lower() for c in common_cols}
706
+ if any(k.lower() not in common_lower for k in configured_key):
707
+ return True, common_cols
708
+
709
+ # Per-column NON-BLOCKING checks: type family, nullable, order.
710
+ src_by_col = {
711
+ str(r["column_name"]).lower(): r for _, r in source_schema_df.iterrows()
712
+ }
713
+ tgt_by_col = {
714
+ str(r["column_name"]).lower(): r for _, r in target_schema_df.iterrows()
715
+ }
716
+
717
+ if column_enabled:
718
+ source_order = [
719
+ c for c in source_schema_df["column_name"].tolist()
720
+ if c.lower() in {x.lower() for x in common_cols}
721
+ ]
722
+ target_order = [
723
+ c for c in target_schema_df["column_name"].tolist()
724
+ if c.lower() in {x.lower() for x in common_cols}
725
+ ]
726
+ result.source_column_order = source_order
727
+ result.target_column_order = target_order
728
+
729
+ if request.validate_column_order:
730
+ result.column_order_status = self.compare_column_order(source_order, target_order)
731
+ else:
732
+ result.column_order_status = ValidationStatus.SKIPPED
733
+
734
+ dtype_statuses, nullable_statuses = [], []
735
+ column_results: List[ColumnValidationResult] = []
736
+
737
+ for col in common_cols:
738
+ key = col.lower()
739
+ src_row = src_by_col.get(key, {})
740
+ tgt_row = tgt_by_col.get(key, {})
741
+
742
+ col_result = ColumnValidationResult(column=col, status=ValidationStatus.PASS)
743
+
744
+ src_type = str(src_row.get("data_type"))
745
+ tgt_type = str(tgt_row.get("data_type"))
746
+ col_result.source_data_type = src_type
747
+ col_result.target_data_type = tgt_type
748
+ col_result.data_type_status = self._classify_type_family(src_type, tgt_type)
749
+ dtype_statuses.append(col_result.data_type_status)
750
+
751
+ if request.validate_nullable:
752
+ src_null = bool(src_row.get("is_nullable"))
753
+ tgt_null = bool(tgt_row.get("is_nullable"))
754
+ col_result.source_nullable = src_null
755
+ col_result.target_nullable = tgt_null
756
+ col_result.nullable_status = self.compare_nullable(src_null, tgt_null)
757
+ else:
758
+ col_result.nullable_status = ValidationStatus.SKIPPED
759
+ nullable_statuses.append(col_result.nullable_status)
760
+
761
+ col_result.status = self.calculate_overall_status(
762
+ [col_result.data_type_status, col_result.nullable_status]
763
+ )
764
+ column_results.append(col_result)
765
+
766
+ result.columns = column_results
767
+ result.data_types_status = self.calculate_overall_status(dtype_statuses)
768
+ result.nullable_status = self.calculate_overall_status(nullable_statuses)
769
+ else:
770
+ result.column_order_status = ValidationStatus.SKIPPED
771
+ result.data_types_status = ValidationStatus.SKIPPED
772
+ result.nullable_status = ValidationStatus.SKIPPED
773
+
774
+ # Cross-family type change is BLOCKING even when COLUMN reporting
775
+ # is disabled (detection always runs; only reporting is gated).
776
+ for col in common_cols:
777
+ key = col.lower()
778
+ src_type = str(src_by_col.get(key, {}).get("data_type"))
779
+ tgt_type = str(tgt_by_col.get(key, {}).get("data_type"))
780
+ if self._classify_type_family(src_type, tgt_type) == ValidationStatus.FAIL:
781
+ return True, common_cols
782
+
783
+ return False, common_cols
784
+
785
+ # ------------------------------------------------------------------
786
+ # Tier 1: statistical profile. One aggregate query per side (row
787
+ # count already available via get_row_count; null/distinct/min-max
788
+ # via get_column_statistics). Any mismatch -> stop before Tier 2.
789
+ # ------------------------------------------------------------------
790
+ def _tier1_statistics(
791
+ self,
792
+ request: CatalogValidationRequest,
793
+ schema_name: str,
794
+ table_name: str,
795
+ common_cols: List[str],
796
+ source_schema_df: pd.DataFrame,
797
+ result: TableValidationResult,
798
+ ) -> bool:
799
+ """Returns True if a statistical mismatch was found (stop the funnel here)."""
800
+ column_enabled = ValidationType.COLUMN in request.enabled_validations
801
+ mismatch = False
802
+
803
+ # Row count (also serves stage "row_count_status" as before).
804
+ try:
805
+ src_count = self.databricks.get_row_count(
806
+ request.source_catalog, schema_name, table_name
807
+ )
808
+ tgt_count = self.databricks.get_row_count(
809
+ request.target_catalog, schema_name, table_name
810
+ )
811
+ result.row_count_source = src_count
812
+ result.row_count_target = tgt_count
813
+ result.row_count_difference = tgt_count - src_count
814
+ result.row_count_status = self.compare_row_counts(src_count, tgt_count)
815
+ if result.row_count_status == ValidationStatus.FAIL:
816
+ mismatch = True
817
+ except Exception as exc:
818
+ logger.exception(
819
+ "Failed to compute row counts for '%s.%s'", schema_name, table_name
820
+ )
821
+ result.row_count_status = ValidationStatus.ERROR
822
+ result.error = f"Row count failed: {exc}"
823
+ mismatch = True
824
+
825
+ src_by_col = {
826
+ str(r["column_name"]).lower(): r for _, r in source_schema_df.iterrows()
827
+ }
828
+ min_max_columns = [
829
+ c for c in common_cols
830
+ if self.databricks.is_min_max_eligible(
831
+ str(src_by_col.get(c.lower(), {}).get("data_type", ""))
832
+ )
833
+ ]
834
+
835
+ try:
836
+ source_stats = self.databricks.get_column_statistics(
837
+ request.source_catalog, schema_name, table_name,
838
+ common_cols, min_max_columns,
839
+ )
840
+ target_stats = self.databricks.get_column_statistics(
841
+ request.target_catalog, schema_name, table_name,
842
+ common_cols, min_max_columns,
843
+ )
844
+ stats_error = None
845
+ except Exception as exc:
846
+ logger.exception(
847
+ "Failed to compute column statistics for '%s.%s'", schema_name, table_name
848
+ )
849
+ source_stats, target_stats = {}, {}
850
+ stats_error = str(exc)
851
+ mismatch = True
852
+
853
+ existing_by_col = {c.column: c for c in result.columns}
854
+ null_statuses, distinct_statuses, minmax_statuses = [], [], []
855
+
856
+ for col in common_cols:
857
+ col_result = existing_by_col.get(col)
858
+ if col_result is None:
859
+ col_result = ColumnValidationResult(column=col, status=ValidationStatus.PASS)
860
+ existing_by_col[col] = col_result
861
+
862
+ if stats_error:
863
+ col_result.null_count_status = ValidationStatus.ERROR
864
+ col_result.distinct_count_status = ValidationStatus.ERROR
865
+ col_result.error = stats_error
866
+ else:
867
+ s_stat = source_stats.get(col, {})
868
+ t_stat = target_stats.get(col, {})
869
+
870
+ col_result.source_null_count = s_stat.get("null_count")
871
+ col_result.target_null_count = t_stat.get("null_count")
872
+ col_result.null_count_status = self.compare_null_counts(
873
+ col_result.source_null_count, col_result.target_null_count
874
+ )
875
+ if col_result.null_count_status == ValidationStatus.FAIL:
876
+ mismatch = True
877
+
878
+ col_result.source_distinct_count = s_stat.get("distinct_count")
879
+ col_result.target_distinct_count = t_stat.get("distinct_count")
880
+ col_result.distinct_count_status = self.compare_distinct_counts(
881
+ col_result.source_distinct_count, col_result.target_distinct_count
882
+ )
883
+ if col_result.distinct_count_status == ValidationStatus.FAIL:
884
+ mismatch = True
885
+
886
+ if col in min_max_columns:
887
+ col_result.source_min = s_stat.get("min")
888
+ col_result.source_max = s_stat.get("max")
889
+ col_result.target_min = t_stat.get("min")
890
+ col_result.target_max = t_stat.get("max")
891
+ col_result.min_max_status = self.compare_min_max(
892
+ col_result.source_min, col_result.source_max,
893
+ col_result.target_min, col_result.target_max,
894
+ )
895
+ if col_result.min_max_status == ValidationStatus.FAIL:
896
+ mismatch = True
897
+ else:
898
+ col_result.min_max_status = ValidationStatus.SKIPPED
899
+
900
+ null_statuses.append(col_result.null_count_status)
901
+ distinct_statuses.append(col_result.distinct_count_status)
902
+ minmax_statuses.append(col_result.min_max_status)
903
+
904
+ col_result.status = self.calculate_overall_status(
905
+ [
906
+ col_result.data_type_status,
907
+ col_result.nullable_status,
908
+ col_result.null_count_status,
909
+ col_result.distinct_count_status,
910
+ col_result.min_max_status,
911
+ ]
912
+ )
913
+
914
+ # null/distinct/min-max are Tier 1's own findings - part of ROW's
915
+ # fail-fast pipeline (this method only runs when ROW is enabled),
916
+ # not gated by COLUMN. result.columns must always reflect what
917
+ # Tier 1 actually computed regardless of COLUMN, since the
918
+ # Suggestions sheet explains a ROW-only FAIL by walking this list -
919
+ # gating it on COLUMN previously left a Tier-1-only failure with no
920
+ # explanation at all ("Unclassified") when COLUMN wasn't selected.
921
+ result.null_counts_status = self.calculate_overall_status(null_statuses)
922
+ result.distinct_counts_status = self.calculate_overall_status(distinct_statuses)
923
+ result.min_max_status = self.calculate_overall_status(minmax_statuses)
924
+ result.columns = [existing_by_col[c] for c in common_cols]
925
+
926
+ return mismatch
927
+
928
+ # ------------------------------------------------------------------
929
+ # Tier 2: whole-table fingerprint. Single aggregate query per side
930
+ # (COUNT * SUM(hash) * XOR(hash)). Match -> tables equal, stop (never
931
+ # reach Tier 4/5). This is the tier that directly prevents the
932
+ # ROW_NUMBER()-fallback timeout: most "actually equal" or "clearly
933
+ # different at the schema/stats level" tables never reach it or stop
934
+ # right here.
935
+ # ------------------------------------------------------------------
936
+ def _tier2_fingerprint(
937
+ self,
938
+ request: CatalogValidationRequest,
939
+ schema_name: str,
940
+ table_name: str,
941
+ common_cols: List[str],
942
+ result: TableValidationResult,
943
+ ) -> bool:
944
+ """Returns True if the fingerprints match (stop the funnel here)."""
945
+ value_columns = sorted(common_cols)
946
+
947
+ try:
948
+ source_fp = self.databricks.get_table_fingerprint(
949
+ request.source_catalog, schema_name, table_name, value_columns,
950
+ )
951
+ target_fp = self.databricks.get_table_fingerprint(
952
+ request.target_catalog, schema_name, table_name, value_columns,
953
+ )
954
+ except Exception as exc:
955
+ logger.exception(
956
+ "Failed to compute table fingerprint for '%s.%s'", schema_name, table_name
957
+ )
958
+ result.data = DataValidationResult(
959
+ mode=request.data_compare_mode,
960
+ status=ValidationStatus.ERROR,
961
+ fingerprint_status=ValidationStatus.ERROR,
962
+ error=f"Fingerprint comparison failed: {exc}",
963
+ )
964
+ return False
965
+
966
+ matches = (
967
+ source_fp.get("row_count") == target_fp.get("row_count")
968
+ and source_fp.get("hash_sum") == target_fp.get("hash_sum")
969
+ and source_fp.get("hash_xor") == target_fp.get("hash_xor")
970
+ )
971
+
972
+ logger.info(
973
+ "[tier2-fingerprint] table=%s.%s | match=%s | source=%s | target=%s",
974
+ schema_name, table_name, matches, source_fp, target_fp,
975
+ )
976
+
977
+ result.data = DataValidationResult(
978
+ mode=request.data_compare_mode,
979
+ status=ValidationStatus.PASS if matches else ValidationStatus.SKIPPED,
980
+ fingerprint_status=ValidationStatus.PASS if matches else ValidationStatus.FAIL,
981
+ source_fingerprint=str(source_fp),
982
+ target_fingerprint=str(target_fp),
983
+ note=(
984
+ "Whole-table fingerprint matched - tables are equal, "
985
+ "row-level comparison skipped." if matches else None
986
+ ),
987
+ )
988
+
989
+ return matches
990
+
991
+ # ------------------------------------------------------------------
992
+ # Tier 3 candidate selection (pure, no I/O): which common columns are
993
+ # reasonable to offer as a partition/bucket column. Kept deliberately
994
+ # simple - no cardinality ranking - just excludes the configured
995
+ # primary key (already uniquely identifies rows, useless as a bucket
996
+ # dimension) and sorts alphabetically.
997
+ # ------------------------------------------------------------------
998
+ @staticmethod
999
+ def _partition_candidates(
1000
+ common_cols: List[str],
1001
+ key_columns: Optional[List[str]],
1002
+ ) -> List[str]:
1003
+ key_lower = {k.lower() for k in (key_columns or [])}
1004
+ return sorted(c for c in common_cols if c.lower() not in key_lower)
1005
+
1006
+ # ------------------------------------------------------------------
1007
+ # Tier 4 dispatch: decides between partitioned and unpartitioned Tier
1008
+ # 4 for a table with a confirmed mismatch (Tier 1 and/or Tier 2).
1009
+ # Partitioning is only offered when a partition_prompt callback is
1010
+ # configured AND the table is large enough (row_count over
1011
+ # request.partition_threshold) - small tables and callers that never
1012
+ # opted into the callback always get today's unpartitioned behavior,
1013
+ # unchanged.
1014
+ # ------------------------------------------------------------------
1015
+ def _dispatch_tier4(
1016
+ self,
1017
+ request: CatalogValidationRequest,
1018
+ schema_name: str,
1019
+ table_name: str,
1020
+ common_cols: List[str],
1021
+ result: TableValidationResult,
1022
+ stats_mismatch: bool = False,
1023
+ ) -> None:
1024
+ row_count = result.row_count_source or 0
1025
+ large_enough = row_count > request.partition_threshold
1026
+
1027
+ if not large_enough:
1028
+ result.partition_skip_reason = None # too small to even offer
1029
+ self._tier4_and_5_row_level(
1030
+ request, schema_name, table_name, common_cols, result,
1031
+ stats_mismatch=stats_mismatch,
1032
+ )
1033
+ return
1034
+
1035
+ if self.partition_prompt is None:
1036
+ result.partition_skip_reason = "no partition_prompt configured"
1037
+ self._tier4_and_5_row_level(
1038
+ request, schema_name, table_name, common_cols, result,
1039
+ stats_mismatch=stats_mismatch,
1040
+ )
1041
+ return
1042
+
1043
+ key_lookup = f"{schema_name}.{table_name}"
1044
+ key_columns = request.primary_keys.get(key_lookup) or request.primary_keys.get(table_name)
1045
+ candidates = self._partition_candidates(common_cols, key_columns)
1046
+
1047
+ context = PartitionPromptContext(
1048
+ schema_name=schema_name,
1049
+ table=table_name,
1050
+ row_count=row_count,
1051
+ candidate_columns=candidates,
1052
+ )
1053
+
1054
+ try:
1055
+ chosen_column = self.partition_prompt(context)
1056
+ except Exception:
1057
+ logger.exception(
1058
+ "partition_prompt callback failed for '%s.%s' - falling back "
1059
+ "to unpartitioned Tier 4",
1060
+ schema_name, table_name,
1061
+ )
1062
+ chosen_column = None
1063
+
1064
+ if not chosen_column:
1065
+ result.partition_skip_reason = "user declined or non-interactive run"
1066
+ self._tier4_and_5_row_level(
1067
+ request, schema_name, table_name, common_cols, result,
1068
+ stats_mismatch=stats_mismatch,
1069
+ )
1070
+ return
1071
+
1072
+ self._tier3_partition_and_tier4(
1073
+ request, schema_name, table_name, common_cols, result,
1074
+ chosen_column, stats_mismatch=stats_mismatch,
1075
+ )
1076
+
1077
+ # ------------------------------------------------------------------
1078
+ # Tier 3: partition/bucket fingerprinting, then Tier 4 (+5) scoped to
1079
+ # only the culprit buckets whose fingerprints disagree. Matching
1080
+ # buckets are never touched by Tier 4 - this is what makes a large,
1081
+ # partly-different table cheaper to diff than a full unpartitioned
1082
+ # row-hash scan.
1083
+ # ------------------------------------------------------------------
1084
+ def _tier3_partition_and_tier4(
1085
+ self,
1086
+ request: CatalogValidationRequest,
1087
+ schema_name: str,
1088
+ table_name: str,
1089
+ common_cols: List[str],
1090
+ result: TableValidationResult,
1091
+ bucket_column: str,
1092
+ stats_mismatch: bool = False,
1093
+ ) -> None:
1094
+ value_columns = sorted(common_cols)
1095
+
1096
+ try:
1097
+ source_buckets = self.databricks.get_table_fingerprint_by_bucket(
1098
+ request.source_catalog, schema_name, table_name, value_columns, bucket_column,
1099
+ )
1100
+ target_buckets = self.databricks.get_table_fingerprint_by_bucket(
1101
+ request.target_catalog, schema_name, table_name, value_columns, bucket_column,
1102
+ )
1103
+ except Exception as exc:
1104
+ logger.exception(
1105
+ "Tier 3 bucket fingerprint failed for '%s.%s' (bucket_column='%s') - "
1106
+ "falling back to unpartitioned Tier 4",
1107
+ schema_name, table_name, bucket_column,
1108
+ )
1109
+ result.partition_skip_reason = f"bucket fingerprint failed: {exc}"
1110
+ self._tier4_and_5_row_level(
1111
+ request, schema_name, table_name, common_cols, result,
1112
+ stats_mismatch=stats_mismatch,
1113
+ )
1114
+ return
1115
+
1116
+ source_by_bucket = {row["bucket_value"]: row for _, row in source_buckets.iterrows()}
1117
+ target_by_bucket = {row["bucket_value"]: row for _, row in target_buckets.iterrows()}
1118
+ all_buckets = sorted(
1119
+ set(source_by_bucket) | set(target_by_bucket), key=str,
1120
+ )
1121
+
1122
+ culprit_buckets = []
1123
+ for bucket_value in all_buckets:
1124
+ src = source_by_bucket.get(bucket_value)
1125
+ tgt = target_by_bucket.get(bucket_value)
1126
+ matches = (
1127
+ src is not None and tgt is not None
1128
+ and src.get("row_count") == tgt.get("row_count")
1129
+ and src.get("hash_sum") == tgt.get("hash_sum")
1130
+ and src.get("hash_xor") == tgt.get("hash_xor")
1131
+ )
1132
+ if not matches:
1133
+ culprit_buckets.append(bucket_value)
1134
+
1135
+ logger.info(
1136
+ "[tier3-partition] table=%s.%s | bucket_column=%s | total_buckets=%d | "
1137
+ "culprit_buckets=%d",
1138
+ schema_name, table_name, bucket_column, len(all_buckets), len(culprit_buckets),
1139
+ )
1140
+
1141
+ result.partitioned = True
1142
+ result.partition_column = bucket_column
1143
+ result.partition_buckets_total = len(all_buckets)
1144
+ result.partition_buckets_culprit = len(culprit_buckets)
1145
+
1146
+ accumulate: Dict[str, Any] = {
1147
+ "mismatches": [],
1148
+ "mismatch_count": 0,
1149
+ "using_row_number_fallback": False,
1150
+ }
1151
+ for bucket_value in culprit_buckets:
1152
+ self._tier4_and_5_row_level(
1153
+ request, schema_name, table_name, common_cols, result,
1154
+ stats_mismatch=stats_mismatch,
1155
+ bucket_predicate=(bucket_column, bucket_value),
1156
+ _accumulate=accumulate,
1157
+ )
1158
+
1159
+ mismatches: List[RowHashMismatch] = accumulate["mismatches"]
1160
+ mismatch_count: int = accumulate["mismatch_count"]
1161
+ using_row_number_fallback: bool = accumulate["using_row_number_fallback"]
1162
+ effective_key_columns = accumulate.get("key_columns", ["row_number"])
1163
+ total_rows = result.row_count_source or 0
1164
+ mismatch_pct = (mismatch_count / total_rows) * 100 if total_rows else 0.0
1165
+
1166
+ if result.data is None:
1167
+ result.data = DataValidationResult(
1168
+ mode=request.data_compare_mode,
1169
+ status=ValidationStatus.SKIPPED,
1170
+ )
1171
+ result.data.row_hash_mismatches = mismatches
1172
+ result.data.row_hash_mismatch_count = mismatch_count
1173
+ result.data.row_hash_mismatch_percentage = mismatch_pct
1174
+ result.data.key_columns = effective_key_columns
1175
+ result.data.note = (
1176
+ (result.data.note + " " if result.data.note else "")
1177
+ + f"Partitioned by '{bucket_column}': {len(culprit_buckets)} of "
1178
+ f"{len(all_buckets)} bucket(s) differed and were row-hash "
1179
+ f"compared; matching buckets were not scanned."
1180
+ )
1181
+
1182
+ if mismatch_count > 0:
1183
+ result.data.status = ValidationStatus.FAIL
1184
+ result.tier_reached = ValidationTier.ROW_HASH
1185
+
1186
+ if not using_row_number_fallback:
1187
+ key_lookup = f"{schema_name}.{table_name}"
1188
+ key_columns = request.primary_keys.get(key_lookup) or request.primary_keys.get(
1189
+ table_name
1190
+ )
1191
+ mismatched_keys = [m.primary_key for m in mismatches if m.status == "MISMATCH"]
1192
+ if key_columns and mismatched_keys:
1193
+ self._tier5_column_diff(
1194
+ request, schema_name, table_name, common_cols,
1195
+ key_columns, mismatched_keys, result,
1196
+ )
1197
+ result.tier_reached = ValidationTier.COLUMN_DIFF
1198
+ elif stats_mismatch:
1199
+ # Tier 1 already confirmed a real difference, but no culprit
1200
+ # bucket's row-hash join found a mismatched key - same
1201
+ # reasoning as the unpartitioned path: don't silently
1202
+ # override a confirmed finding.
1203
+ result.data.status = ValidationStatus.FAIL
1204
+ result.data.note += (
1205
+ " Row-hash comparison of all differing buckets found no "
1206
+ "mismatched keys, but Tier 1 already confirmed a "
1207
+ "statistical difference."
1208
+ )
1209
+ result.tier_reached = ValidationTier.ROW_HASH
1210
+ else:
1211
+ result.data.status = ValidationStatus.PASS
1212
+ result.tier_reached = ValidationTier.ROW_HASH
1213
+
1214
+ # ------------------------------------------------------------------
1215
+ # Tier 4/5 orchestration: row-hash diff, either over the whole table
1216
+ # (bucket_predicate=None) or scoped to a single partition bucket (see
1217
+ # _tier3_partition_and_tier4), then column-level diff for whatever
1218
+ # keys the row-hash diff flagged as mismatched.
1219
+ # ------------------------------------------------------------------
1220
+ def _tier4_and_5_row_level(
1221
+ self,
1222
+ request: CatalogValidationRequest,
1223
+ schema_name: str,
1224
+ table_name: str,
1225
+ common_cols: List[str],
1226
+ result: TableValidationResult,
1227
+ stats_mismatch: bool = False,
1228
+ bucket_predicate: Optional[Tuple[str, Any]] = None,
1229
+ _accumulate: Optional[Dict[str, Any]] = None,
1230
+ ) -> None:
1231
+ """
1232
+ Runs Tier 4 (+ Tier 5 for real mismatched keys) either over the
1233
+ whole table (bucket_predicate=None, the default/unpartitioned
1234
+ path, mutates `result` directly) or scoped to exactly one
1235
+ partition bucket. When `_accumulate` is given (only used by
1236
+ _tier3_partition_and_tier4, one call per culprit bucket), this
1237
+ call's mismatches/counts are appended into it instead of being
1238
+ written straight to `result.data`, so multiple bucket calls
1239
+ aggregate instead of each overwriting the last.
1240
+ """
1241
+ key_lookup = f"{schema_name}.{table_name}"
1242
+ row_hash_key_columns = request.primary_keys.get(key_lookup) or request.primary_keys.get(
1243
+ table_name
1244
+ )
1245
+
1246
+ using_row_number_fallback = not row_hash_key_columns
1247
+ if using_row_number_fallback:
1248
+ logger.info(
1249
+ "[row-hash] no key configured for '%s.%s' - falling back to "
1250
+ "ROW_NUMBER()-based comparison (ORDER BY every common column). "
1251
+ "Best-effort only: reliable solely when both sides have the same "
1252
+ "row set.",
1253
+ schema_name, table_name,
1254
+ )
1255
+
1256
+ try:
1257
+ if using_row_number_fallback:
1258
+ mismatches, mismatch_count, mismatch_pct = self._run_row_hash_stage_by_row_number(
1259
+ request, schema_name, table_name, common_cols,
1260
+ bucket_predicate=bucket_predicate,
1261
+ )
1262
+ effective_key_columns = ["row_number"]
1263
+ else:
1264
+ mismatches, mismatch_count, mismatch_pct = self._run_row_hash_stage(
1265
+ request, schema_name, table_name, common_cols, row_hash_key_columns,
1266
+ bucket_predicate=bucket_predicate,
1267
+ )
1268
+ effective_key_columns = row_hash_key_columns
1269
+
1270
+ if bucket_predicate is not None:
1271
+ bucket_label = str(bucket_predicate[1])
1272
+ for m in mismatches:
1273
+ m.partition_bucket = bucket_label
1274
+
1275
+ logger.info(
1276
+ "[row-hash] table=%s.%s | key_columns=%s | mismatch_count=%s | mismatch_pct=%.2f%%",
1277
+ schema_name, table_name, effective_key_columns, mismatch_count, mismatch_pct,
1278
+ )
1279
+
1280
+ if _accumulate is not None:
1281
+ # Called once per culprit bucket by
1282
+ # _tier3_partition_and_tier4 - append rather than
1283
+ # overwrite, and let the caller decide the final status/
1284
+ # tier_reached/Tier 5 dispatch once all buckets are in.
1285
+ _accumulate["mismatches"].extend(mismatches)
1286
+ _accumulate["mismatch_count"] += mismatch_count
1287
+ _accumulate.setdefault("key_columns", effective_key_columns)
1288
+ _accumulate["using_row_number_fallback"] = using_row_number_fallback
1289
+ return
1290
+
1291
+ if result.data is None:
1292
+ result.data = DataValidationResult(
1293
+ mode=request.data_compare_mode,
1294
+ status=ValidationStatus.SKIPPED,
1295
+ )
1296
+ result.data.row_hash_mismatches = mismatches
1297
+ result.data.row_hash_mismatch_count = mismatch_count
1298
+ result.data.row_hash_mismatch_percentage = mismatch_pct
1299
+ result.data.key_columns = effective_key_columns
1300
+ if using_row_number_fallback:
1301
+ result.data.note = (
1302
+ "No primary key configured - row-level comparison used a "
1303
+ "synthetic ROW_NUMBER() (ORDER BY every common column) "
1304
+ "instead of a real key. Only reliable when both sides "
1305
+ "contain the same set of rows in the same relative order; "
1306
+ "the 'Data Mismatches' sheet may show best-effort "
1307
+ "column-level detail for these rows (marked unverified), "
1308
+ "but it cannot guarantee the same confidence as a real "
1309
+ "primary key."
1310
+ )
1311
+ if mismatch_count > 0 and result.data.status != ValidationStatus.ERROR:
1312
+ result.data.status = ValidationStatus.FAIL
1313
+ result.tier_reached = ValidationTier.ROW_HASH
1314
+
1315
+ # Tier 5: column-level diff. For a real configured key
1316
+ # this pinpoints the exact record; for the row-number
1317
+ # fallback it's best-effort (see
1318
+ # get_row_detail_for_row_numbers) and every resulting
1319
+ # detail row is tagged verified=False.
1320
+ mismatched_keys = [
1321
+ m.primary_key for m in mismatches if m.status == "MISMATCH"
1322
+ ]
1323
+ if mismatched_keys:
1324
+ self._tier5_column_diff(
1325
+ request, schema_name, table_name, common_cols,
1326
+ effective_key_columns, mismatched_keys, result,
1327
+ using_row_number_fallback=using_row_number_fallback,
1328
+ bucket_predicate=bucket_predicate,
1329
+ )
1330
+ result.tier_reached = ValidationTier.COLUMN_DIFF
1331
+ elif result.data.status == ValidationStatus.SKIPPED and mismatch_count == 0:
1332
+ if stats_mismatch:
1333
+ # Tier 1 already confirmed a real difference, but the
1334
+ # row-hash join found no mismatched key - the
1335
+ # difference is likely in a value outside the hashed
1336
+ # columns (e.g. a min/max-only finding) or invisible
1337
+ # to a row-set comparison. Keep this a FAIL rather
1338
+ # than silently reporting PASS on a table Tier 1
1339
+ # already proved differs.
1340
+ result.data.status = ValidationStatus.FAIL
1341
+ result.data.note = (
1342
+ (result.data.note + " " if result.data.note else "")
1343
+ + "Row-hash comparison found no mismatched keys, but "
1344
+ "Tier 1 already confirmed a statistical difference - "
1345
+ "see the null/distinct/min-max columns on this table "
1346
+ "for the specific statistic that disagrees."
1347
+ )
1348
+ else:
1349
+ result.data.status = ValidationStatus.PASS
1350
+ result.tier_reached = ValidationTier.ROW_HASH
1351
+ except Exception as exc:
1352
+ logger.exception(
1353
+ "Failed to run row-hash comparison for '%s.%s'", schema_name, table_name
1354
+ )
1355
+ if result.data is None:
1356
+ result.data = DataValidationResult(
1357
+ mode=request.data_compare_mode,
1358
+ status=ValidationStatus.ERROR,
1359
+ key_columns=row_hash_key_columns or ["row_number"],
1360
+ error=f"Row-hash comparison failed: {exc}",
1361
+ )
1362
+ else:
1363
+ result.data.status = ValidationStatus.ERROR
1364
+ result.data.error = f"Row-hash comparison failed: {exc}"
1365
+
1366
+ # ------------------------------------------------------------------
1367
+ # Tier 5: column-level diff. Only for keys Tier 4 flagged as
1368
+ # ROW_HASH_MISMATCH - a thin re-wire of the existing bounded-sample
1369
+ # fetch (_changed_row_detail), so it's never a full-table pull.
1370
+ # ------------------------------------------------------------------
1371
+ def _tier5_column_diff(
1372
+ self,
1373
+ request: CatalogValidationRequest,
1374
+ schema_name: str,
1375
+ table_name: str,
1376
+ common_cols: List[str],
1377
+ key_columns: List[str],
1378
+ mismatched_keys: List[str],
1379
+ result: TableValidationResult,
1380
+ using_row_number_fallback: bool = False,
1381
+ bucket_predicate: Optional[Tuple[str, Any]] = None,
1382
+ ) -> None:
1383
+ if using_row_number_fallback:
1384
+ # No real key to exclude - every common column is a value
1385
+ # column. This MUST match the column list/order used to
1386
+ # compute the original ROW_NUMBER() in
1387
+ # _run_row_hash_stage_by_row_number, or the re-fetched row
1388
+ # numbers here won't line up with the mismatch's stored
1389
+ # "row_number" values.
1390
+ value_columns = sorted(common_cols)
1391
+ else:
1392
+ value_columns = sorted(
1393
+ c for c in common_cols if c.lower() not in {k.lower() for k in key_columns}
1394
+ )
1395
+ if not value_columns:
1396
+ return
1397
+
1398
+ # mismatched_keys are the "|"-joined display keys from
1399
+ # compare_row_hashes; for a single-column key this is directly
1400
+ # usable as a literal value list. Multi-column keys aren't safely
1401
+ # reconstructible from the "|"-joined display string - skip Tier 5
1402
+ # rather than risk fetching the wrong rows. (Deferred: carry
1403
+ # structured key tuples through Tier 4 instead of display strings.)
1404
+ # Row-number fallback is always a single synthetic key, so this
1405
+ # guard never applies to it.
1406
+ if not using_row_number_fallback and len(key_columns) != 1:
1407
+ return
1408
+
1409
+ try:
1410
+ if using_row_number_fallback:
1411
+ detail = self.databricks.get_row_detail_for_row_numbers(
1412
+ source_catalog=request.source_catalog,
1413
+ target_catalog=request.target_catalog,
1414
+ schema=schema_name,
1415
+ table=table_name,
1416
+ order_by_columns=value_columns,
1417
+ row_numbers=[int(k) for k in mismatched_keys],
1418
+ value_columns=value_columns,
1419
+ limit_samples=request.max_sample_rows,
1420
+ bucket_predicate=bucket_predicate,
1421
+ )
1422
+ else:
1423
+ detail = self.databricks.get_row_detail_for_keys(
1424
+ source_catalog=request.source_catalog,
1425
+ target_catalog=request.target_catalog,
1426
+ schema=schema_name,
1427
+ table=table_name,
1428
+ key_column=key_columns[0],
1429
+ key_values=mismatched_keys,
1430
+ value_columns=value_columns,
1431
+ limit_samples=request.max_sample_rows,
1432
+ )
1433
+ except Exception as exc:
1434
+ logger.exception(
1435
+ "Tier 5 column diff failed for '%s.%s'", schema_name, table_name
1436
+ )
1437
+ if result.data is not None:
1438
+ result.data.error = f"Column-level diff failed: {exc}"
1439
+ return
1440
+
1441
+ sample_changed_detail: List[RowMismatchDetail] = []
1442
+ for row in detail:
1443
+ for col in row["mismatched_columns"]:
1444
+ sample_changed_detail.append(
1445
+ RowMismatchDetail(
1446
+ schema_name=schema_name,
1447
+ table=table_name,
1448
+ primary_key=row["key"],
1449
+ mismatch_column=col,
1450
+ source_value=row["source_values"].get(col),
1451
+ target_value=row["target_values"].get(col),
1452
+ source_row_hash=row["source_row_hash"],
1453
+ target_row_hash=row["target_row_hash"],
1454
+ verified=not using_row_number_fallback,
1455
+ )
1456
+ )
1457
+
1458
+ if result.data is not None:
1459
+ result.data.sample_changed_detail = sample_changed_detail
1460
+
1461
+ # ------------------------------------------------------------------
1462
+ # Stage 15: actual data comparison
1463
+ # ------------------------------------------------------------------
1464
+ def compare_data(
1465
+ self,
1466
+ request: CatalogValidationRequest,
1467
+ schema_name: str,
1468
+ table_name: str,
1469
+ common_columns: List[str],
1470
+ ) -> DataValidationResult:
1471
+
1472
+ mode = request.data_compare_mode
1473
+ key = f"{schema_name}.{table_name}"
1474
+ key_columns = request.primary_keys.get(key) or request.primary_keys.get(
1475
+ table_name
1476
+ )
1477
+
1478
+ logger.info(
1479
+ "[compare_data] table=%s.%s | mode=%s | resolved_key_columns=%s",
1480
+ schema_name, table_name, mode.value, key_columns,
1481
+ )
1482
+
1483
+ if mode == DataCompareMode.COUNT_ONLY:
1484
+ return DataValidationResult(
1485
+ mode=mode,
1486
+ status=ValidationStatus.SKIPPED,
1487
+ note="COUNT_ONLY mode: row-level data comparison skipped by configuration.",
1488
+ )
1489
+
1490
+ if mode == DataCompareMode.STATISTICS:
1491
+ return DataValidationResult(
1492
+ mode=mode,
1493
+ status=ValidationStatus.SKIPPED,
1494
+ note=(
1495
+ "STATISTICS mode (default): row count / null / distinct / "
1496
+ "min-max already validated above; row-level comparison skipped "
1497
+ "for cost. Use HASH or FULL to enable it."
1498
+ ),
1499
+ )
1500
+
1501
+ if not key_columns:
1502
+ return DataValidationResult(
1503
+ mode=mode,
1504
+ status=ValidationStatus.SKIPPED,
1505
+ note=(
1506
+ f"No primary/business key configured for '{key}' - "
1507
+ "row-level data comparison requires a key and was skipped. "
1508
+ "Configure request.primary_keys to enable it."
1509
+ ),
1510
+ )
1511
+
1512
+ missing_keys = [k for k in key_columns if k.lower() not in {c.lower() for c in common_columns}]
1513
+ if missing_keys:
1514
+ return DataValidationResult(
1515
+ mode=mode,
1516
+ status=ValidationStatus.ERROR,
1517
+ key_columns=key_columns,
1518
+ error=f"Configured key column(s) not found as common columns: {missing_keys}",
1519
+ )
1520
+
1521
+ value_columns = [
1522
+ c for c in common_columns if c.lower() not in {k.lower() for k in key_columns}
1523
+ ]
1524
+
1525
+ source_fqtn = f"{request.source_catalog}.{schema_name}.{table_name}"
1526
+ target_fqtn = f"{request.target_catalog}.{schema_name}.{table_name}"
1527
+
1528
+ try:
1529
+ diff = self.databricks.key_based_row_diff(
1530
+ source_fqtn=source_fqtn,
1531
+ target_fqtn=target_fqtn,
1532
+ key_columns=key_columns,
1533
+ # HASH mode: only need counts, not full samples of value diffs;
1534
+ # FULL mode: return samples too. Either way this is pushed down.
1535
+ value_columns=value_columns,
1536
+ limit_samples=request.max_sample_rows if mode == DataCompareMode.FULL else 5,
1537
+ )
1538
+ except Exception as exc:
1539
+ logger.exception(
1540
+ "Failed to run key-based data comparison for '%s'", key
1541
+ )
1542
+ return DataValidationResult(
1543
+ mode=mode,
1544
+ status=ValidationStatus.ERROR,
1545
+ key_columns=key_columns,
1546
+ error=f"Data comparison failed: {exc}",
1547
+ )
1548
+
1549
+ has_diff = (
1550
+ diff["source_only_rows"] > 0
1551
+ or diff["target_only_rows"] > 0
1552
+ or diff["changed_rows"] > 0
1553
+ )
1554
+
1555
+ logger.info(
1556
+ "[data-mismatch] table=%s.%s | mode=%s | source_only=%d | target_only=%d | "
1557
+ "changed_rows=%d | sample_changed_detail_rows=%d",
1558
+ schema_name, table_name, mode.value,
1559
+ diff["source_only_rows"], diff["target_only_rows"], diff["changed_rows"],
1560
+ len(diff.get("sample_changed_detail", [])),
1561
+ )
1562
+
1563
+ sample_changed_detail: List[RowMismatchDetail] = []
1564
+ if mode == DataCompareMode.FULL:
1565
+ for row in diff.get("sample_changed_detail", []):
1566
+ for col in row["mismatched_columns"]:
1567
+ sample_changed_detail.append(
1568
+ RowMismatchDetail(
1569
+ schema_name=schema_name,
1570
+ table=table_name,
1571
+ primary_key=row["key"],
1572
+ mismatch_column=col,
1573
+ source_value=row["source_values"].get(col),
1574
+ target_value=row["target_values"].get(col),
1575
+ source_row_hash=row["source_row_hash"],
1576
+ target_row_hash=row["target_row_hash"],
1577
+ )
1578
+ )
1579
+
1580
+ return DataValidationResult(
1581
+ mode=mode,
1582
+ status=ValidationStatus.FAIL if has_diff else ValidationStatus.PASS,
1583
+ key_columns=key_columns,
1584
+ source_only_rows=diff["source_only_rows"],
1585
+ target_only_rows=diff["target_only_rows"],
1586
+ changed_rows=diff["changed_rows"],
1587
+ sample_source_only=(
1588
+ diff["sample_source_only"] if mode == DataCompareMode.FULL else []
1589
+ ),
1590
+ sample_target_only=(
1591
+ diff["sample_target_only"] if mode == DataCompareMode.FULL else []
1592
+ ),
1593
+ sample_changed=(
1594
+ diff["sample_changed"] if mode == DataCompareMode.FULL else []
1595
+ ),
1596
+ sample_changed_detail=sample_changed_detail,
1597
+ )
1598
+
1599
+ # ------------------------------------------------------------------
1600
+ # Stage 15b: row-hash comparison
1601
+ #
1602
+ # Separate mechanism from key_based_row_diff/_changed_row_detail above
1603
+ # (which only run in HASH/FULL mode): this is a single pushed-down
1604
+ # whole-row hash per side, joined by primary key in Python (never by
1605
+ # row position/order), and is the primary way to detect row-level
1606
+ # mismatches whenever a primary key is configured - independent of
1607
+ # data_compare_mode.
1608
+ # ------------------------------------------------------------------
1609
+ def _run_row_hash_stage(
1610
+ self,
1611
+ request: CatalogValidationRequest,
1612
+ schema_name: str,
1613
+ table_name: str,
1614
+ common_columns: List[str],
1615
+ key_columns: List[str],
1616
+ bucket_predicate: Optional[Tuple[str, Any]] = None,
1617
+ ) -> Tuple[List[RowHashMismatch], int, float]:
1618
+
1619
+ value_columns = sorted(
1620
+ c for c in common_columns if c.lower() not in {k.lower() for k in key_columns}
1621
+ )
1622
+
1623
+ logger.info(
1624
+ "[row-hash] fetching hashes | table=%s.%s | key_columns=%s | value_columns=%s"
1625
+ "%s",
1626
+ schema_name, table_name, key_columns, value_columns,
1627
+ f" | bucket={bucket_predicate}" if bucket_predicate else "",
1628
+ )
1629
+
1630
+ source_hashes = self.databricks.get_row_hashes(
1631
+ request.source_catalog, schema_name, table_name, value_columns, key_columns,
1632
+ bucket_predicate=bucket_predicate,
1633
+ )
1634
+ target_hashes = self.databricks.get_row_hashes(
1635
+ request.target_catalog, schema_name, table_name, value_columns, key_columns,
1636
+ bucket_predicate=bucket_predicate,
1637
+ )
1638
+
1639
+ logger.info(
1640
+ "[row-hash] fetched | table=%s.%s | source_rows=%d | target_rows=%d",
1641
+ schema_name, table_name, len(source_hashes), len(target_hashes),
1642
+ )
1643
+
1644
+ return self.compare_row_hashes(source_hashes, target_hashes, key_columns)
1645
+
1646
+ def _run_row_hash_stage_by_row_number(
1647
+ self,
1648
+ request: CatalogValidationRequest,
1649
+ schema_name: str,
1650
+ table_name: str,
1651
+ common_columns: List[str],
1652
+ bucket_predicate: Optional[Tuple[str, Any]] = None,
1653
+ ) -> Tuple[List[RowHashMismatch], int, float]:
1654
+ """
1655
+ Fallback used when no primary key is configured for the table:
1656
+ both sides get a synthetic ROW_NUMBER() (ORDER BY every common
1657
+ column) instead of a real key. See
1658
+ DatabricksConnector.get_row_hashes_by_row_number for the caveat
1659
+ about what this can and cannot detect.
1660
+ """
1661
+ value_columns = sorted(common_columns)
1662
+
1663
+ logger.info(
1664
+ "[row-hash] fetching row-number hashes | table=%s.%s | value_columns=%s%s",
1665
+ schema_name, table_name, value_columns,
1666
+ f" | bucket={bucket_predicate}" if bucket_predicate else "",
1667
+ )
1668
+
1669
+ source_hashes = self.databricks.get_row_hashes_by_row_number(
1670
+ request.source_catalog, schema_name, table_name, value_columns,
1671
+ bucket_predicate=bucket_predicate,
1672
+ )
1673
+ target_hashes = self.databricks.get_row_hashes_by_row_number(
1674
+ request.target_catalog, schema_name, table_name, value_columns,
1675
+ bucket_predicate=bucket_predicate,
1676
+ )
1677
+
1678
+ logger.info(
1679
+ "[row-hash] fetched row-number hashes | table=%s.%s | source_rows=%d | target_rows=%d",
1680
+ schema_name, table_name, len(source_hashes), len(target_hashes),
1681
+ )
1682
+
1683
+ return self.compare_row_hashes(source_hashes, target_hashes, ["row_number"])
1684
+
1685
+ @staticmethod
1686
+ def compare_row_hashes(
1687
+ source_hashes: pd.DataFrame,
1688
+ target_hashes: pd.DataFrame,
1689
+ primary_key_cols: Sequence[str],
1690
+ ) -> Tuple[List[RowHashMismatch], int, float]:
1691
+ """
1692
+ Join two per-key row-hash sets by primary key (never row position,
1693
+ via a sort-then-merge join over both key sets) and classify every
1694
+ key as matching, MISMATCH (key on both sides, hash differs),
1695
+ MISSING_IN_TARGET, or MISSING_IN_SOURCE. A key appearing more than
1696
+ once on either side (the configured "key" isn't actually unique)
1697
+ is classified separately as DUPLICATE_KEY rather than silently
1698
+ collapsed to its last occurrence.
1699
+
1700
+ Returns (mismatches, mismatch_count, mismatch_percentage) where
1701
+ mismatch_percentage is mismatch_count / total_compared_keys * 100
1702
+ and total_compared_keys is the union of keys seen on either side.
1703
+ """
1704
+
1705
+ def _display_key(row: pd.Series) -> str:
1706
+ return "|".join(str(row[k]) for k in primary_key_cols)
1707
+
1708
+ def _key_tuple(row: pd.Series) -> tuple:
1709
+ return tuple(row[k] for k in primary_key_cols)
1710
+
1711
+ def _group_by_key(df: pd.DataFrame) -> Dict[tuple, List[pd.Series]]:
1712
+ grouped: Dict[tuple, List[pd.Series]] = {}
1713
+ for _, row in df.iterrows():
1714
+ grouped.setdefault(_key_tuple(row), []).append(row)
1715
+ return grouped
1716
+
1717
+ source_by_key = _group_by_key(source_hashes)
1718
+ target_by_key = _group_by_key(target_hashes)
1719
+
1720
+ # Sort-then-merge join: both key sets are sorted once, then walked
1721
+ # with two cursors, so memory stays proportional to the number of
1722
+ # distinct keys rather than requiring a hash-join structure sized
1723
+ # to the larger side.
1724
+ all_keys = sorted(set(source_by_key) | set(target_by_key))
1725
+
1726
+ mismatches: List[RowHashMismatch] = []
1727
+
1728
+ for key_tuple in all_keys:
1729
+ src_rows = source_by_key.get(key_tuple, [])
1730
+ tgt_rows = target_by_key.get(key_tuple, [])
1731
+
1732
+ if len(src_rows) > 1 or len(tgt_rows) > 1:
1733
+ display = _display_key(src_rows[0] if src_rows else tgt_rows[0])
1734
+ mismatches.append(
1735
+ RowHashMismatch(
1736
+ primary_key=display,
1737
+ source_hash=str(src_rows[0]["row_hash"]) if src_rows else "",
1738
+ target_hash=str(tgt_rows[0]["row_hash"]) if tgt_rows else "",
1739
+ status="DUPLICATE_KEY",
1740
+ )
1741
+ )
1742
+ continue
1743
+
1744
+ src_row = src_rows[0] if src_rows else None
1745
+ tgt_row = tgt_rows[0] if tgt_rows else None
1746
+
1747
+ if src_row is not None and tgt_row is None:
1748
+ mismatches.append(
1749
+ RowHashMismatch(
1750
+ primary_key=_display_key(src_row),
1751
+ source_hash=str(src_row["row_hash"]),
1752
+ target_hash="",
1753
+ status="MISSING_IN_TARGET",
1754
+ )
1755
+ )
1756
+ elif src_row is None and tgt_row is not None:
1757
+ mismatches.append(
1758
+ RowHashMismatch(
1759
+ primary_key=_display_key(tgt_row),
1760
+ source_hash="",
1761
+ target_hash=str(tgt_row["row_hash"]),
1762
+ status="MISSING_IN_SOURCE",
1763
+ )
1764
+ )
1765
+ elif src_row is not None and tgt_row is not None:
1766
+ if src_row["row_hash"] != tgt_row["row_hash"]:
1767
+ mismatches.append(
1768
+ RowHashMismatch(
1769
+ primary_key=_display_key(src_row),
1770
+ source_hash=str(src_row["row_hash"]),
1771
+ target_hash=str(tgt_row["row_hash"]),
1772
+ status="MISMATCH",
1773
+ )
1774
+ )
1775
+
1776
+ total_compared_keys = len(all_keys)
1777
+ mismatch_count = len(mismatches)
1778
+ mismatch_percentage = (
1779
+ (mismatch_count / total_compared_keys) * 100 if total_compared_keys else 0.0
1780
+ )
1781
+
1782
+ return mismatches, mismatch_count, mismatch_percentage
1783
+
1784
+ # ------------------------------------------------------------------
1785
+ # Stage 16/17: overall status aggregation (programmatic, never hardcoded)
1786
+ # ------------------------------------------------------------------
1787
+ @staticmethod
1788
+ def calculate_overall_status(
1789
+ statuses: List[Optional[ValidationStatus]],
1790
+ ) -> ValidationStatus:
1791
+
1792
+ clean = [s for s in statuses if s is not None]
1793
+
1794
+ if not clean:
1795
+ return ValidationStatus.SKIPPED
1796
+
1797
+ if any(s == ValidationStatus.ERROR for s in clean):
1798
+ return ValidationStatus.ERROR
1799
+
1800
+ if any(s == ValidationStatus.FAIL for s in clean):
1801
+ return ValidationStatus.FAIL
1802
+
1803
+ if all(s == ValidationStatus.SKIPPED for s in clean):
1804
+ return ValidationStatus.SKIPPED
1805
+
1806
+ return ValidationStatus.PASS
1807
+
1808
+ # ------------------------------------------------------------------
1809
+ # Summary
1810
+ # ------------------------------------------------------------------
1811
+ @staticmethod
1812
+ def _build_summary(
1813
+ schema_results: List[SchemaValidationResult],
1814
+ missing_schemas: List[str],
1815
+ extra_schemas: List[str],
1816
+ ) -> ValidationSummary:
1817
+
1818
+ summary = ValidationSummary()
1819
+
1820
+ summary.total_schemas = len(schema_results) + len(missing_schemas)
1821
+ summary.failed_schemas = sum(
1822
+ 1 for s in schema_results if s.status in (ValidationStatus.FAIL, ValidationStatus.ERROR)
1823
+ ) + len(missing_schemas)
1824
+ summary.passed_schemas = summary.total_schemas - summary.failed_schemas
1825
+
1826
+ for schema_result in schema_results:
1827
+ summary.total_tables += len(schema_result.tables)
1828
+ summary.missing_tables += len(schema_result.missing_tables)
1829
+ summary.extra_tables += len(schema_result.extra_tables)
1830
+
1831
+ for table in schema_result.tables:
1832
+ if table.status == ValidationStatus.PASS:
1833
+ summary.passed_tables += 1
1834
+ elif table.status == ValidationStatus.ERROR:
1835
+ summary.error_tables += 1
1836
+ summary.failed_tables += 1
1837
+ else:
1838
+ summary.failed_tables += 1
1839
+
1840
+ return summary
1841
+
1842
+
1843
+ # ----------------------------------------------------------------------
1844
+ # Module-level convenience function (per spec section 22 / app.py usage)
1845
+ # ----------------------------------------------------------------------
1846
+ def validate_catalogs(
1847
+ databricks_connector: DatabricksConnector,
1848
+ source_catalog: str,
1849
+ target_catalog: str,
1850
+ **kwargs: Any,
1851
+ ) -> CatalogValidationResponse:
1852
+ """
1853
+ Thin convenience wrapper so callers (app.py, CLI, scripts) don't need
1854
+ to construct CatalogValidationRequest / CatalogValidator by hand for
1855
+ the common case.
1856
+ """
1857
+ request = CatalogValidationRequest(
1858
+ source_catalog=source_catalog,
1859
+ target_catalog=target_catalog,
1860
+ **kwargs,
1861
+ )
1862
+ validator = CatalogValidator(databricks_connector)
1863
+ return validator.compare_catalogs(request)