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