table-validator 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- table_validator/__init__.py +46 -0
- table_validator/auth/__init__.py +1 -0
- table_validator/auth/azure_auth.py +52 -0
- table_validator/auth/databricks_auth.py +31 -0
- table_validator/cli/__init__.py +1 -0
- table_validator/cli/main.py +722 -0
- table_validator/cli/partition_prompt.py +78 -0
- table_validator/cli/summary_table.py +146 -0
- table_validator/cli/wizard.py +429 -0
- table_validator/config/__init__.py +1 -0
- table_validator/config/manager.py +84 -0
- table_validator/config/schema.py +179 -0
- table_validator/connectors/__init__.py +1 -0
- table_validator/connectors/azure_connector.py +809 -0
- table_validator/connectors/databricks_connector.py +1230 -0
- table_validator/engine/__init__.py +1 -0
- table_validator/engine/comparison_engine.py +645 -0
- table_validator/models.py +952 -0
- table_validator/reports/__init__.py +1 -0
- table_validator/reports/excel_report.py +953 -0
- table_validator/validators/__init__.py +1 -0
- table_validator/validators/blob_discovery.py +467 -0
- table_validator/validators/catalog_validator.py +1863 -0
- table_validator/validators/row_validator.py +1727 -0
- table_validator-0.1.0.dist-info/METADATA +190 -0
- table_validator-0.1.0.dist-info/RECORD +30 -0
- table_validator-0.1.0.dist-info/WHEEL +5 -0
- table_validator-0.1.0.dist-info/entry_points.txt +2 -0
- table_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
- table_validator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|