sql-diff-explainer 0.1.4__py3-none-any.whl → 0.1.5__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.
- sql_diff_explainer/__init__.py +29 -1
- sql_diff_explainer/analysis.py +804 -0
- sql_diff_explainer/cli.py +143 -32
- sql_diff_explainer/core.py +91 -1
- sql_diff_explainer/reports.py +194 -1
- {sql_diff_explainer-0.1.4.dist-info → sql_diff_explainer-0.1.5.dist-info}/METADATA +75 -6
- sql_diff_explainer-0.1.5.dist-info/RECORD +10 -0
- sql_diff_explainer-0.1.4.dist-info/RECORD +0 -9
- {sql_diff_explainer-0.1.4.dist-info → sql_diff_explainer-0.1.5.dist-info}/WHEEL +0 -0
- {sql_diff_explainer-0.1.4.dist-info → sql_diff_explainer-0.1.5.dist-info}/entry_points.txt +0 -0
- {sql_diff_explainer-0.1.4.dist-info → sql_diff_explainer-0.1.5.dist-info}/licenses/LICENSE +0 -0
sql_diff_explainer/cli.py
CHANGED
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
from .analysis import MigrationAnalysisResult, analyze_migration
|
|
8
9
|
from .core import (
|
|
9
10
|
SQLDiffError,
|
|
10
11
|
SQLDiffResult,
|
|
@@ -37,6 +38,12 @@ def main() -> int:
|
|
|
37
38
|
metavar="REF",
|
|
38
39
|
help="Compare Git-reported .sql changes from REF to the current worktree",
|
|
39
40
|
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--analyze-migration",
|
|
43
|
+
type=Path,
|
|
44
|
+
metavar="FILE",
|
|
45
|
+
help="Analyze common ALTER TABLE operations in a migration script",
|
|
46
|
+
)
|
|
40
47
|
parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
|
|
41
48
|
parser.add_argument(
|
|
42
49
|
"--format",
|
|
@@ -54,22 +61,37 @@ def main() -> int:
|
|
|
54
61
|
|
|
55
62
|
directory_mode = args.compare_dirs is not None
|
|
56
63
|
git_mode = args.git_base is not None
|
|
57
|
-
|
|
64
|
+
migration_mode = args.analyze_migration is not None
|
|
65
|
+
selected_modes = sum((directory_mode, git_mode, migration_mode))
|
|
58
66
|
if selected_modes and (args.before is not None or args.after is not None):
|
|
59
|
-
parser.error(
|
|
67
|
+
parser.error(
|
|
68
|
+
"Use only one of --git-base, --compare-dirs, --analyze-migration, "
|
|
69
|
+
"or the two SQL file arguments"
|
|
70
|
+
)
|
|
60
71
|
if selected_modes > 1:
|
|
61
|
-
parser.error("
|
|
72
|
+
parser.error("Choose only one of --git-base, --compare-dirs, or --analyze-migration")
|
|
62
73
|
if not selected_modes and (args.before is None or args.after is None):
|
|
63
74
|
parser.error(
|
|
64
|
-
"Provide two SQL files, use --compare-dirs BEFORE_DIR AFTER_DIR,
|
|
75
|
+
"Provide two SQL files, use --compare-dirs BEFORE_DIR AFTER_DIR, "
|
|
76
|
+
"--git-base REF, or --analyze-migration FILE"
|
|
65
77
|
)
|
|
78
|
+
if migration_mode and args.output_format in {"sarif", "html"}:
|
|
79
|
+
parser.error("--analyze-migration supports text and JSON output")
|
|
66
80
|
|
|
67
81
|
try:
|
|
68
|
-
if
|
|
82
|
+
if migration_mode:
|
|
83
|
+
result: SQLDiffResult | SQLDirectoryDiffResult | MigrationAnalysisResult = (
|
|
84
|
+
analyze_migration(
|
|
85
|
+
args.analyze_migration.read_text(encoding="utf-8"),
|
|
86
|
+
dialect=args.dialect,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
report_path = ""
|
|
90
|
+
elif git_mode:
|
|
69
91
|
result = compare_sql_git(args.git_base, dialect=args.dialect)
|
|
70
92
|
report_path = ""
|
|
71
93
|
elif directory_mode:
|
|
72
|
-
result
|
|
94
|
+
result = compare_sql_directories(
|
|
73
95
|
args.compare_dirs[0],
|
|
74
96
|
args.compare_dirs[1],
|
|
75
97
|
dialect=args.dialect,
|
|
@@ -89,32 +111,118 @@ def main() -> int:
|
|
|
89
111
|
if args.output_format == "json":
|
|
90
112
|
report = json.dumps(result.to_dict(), indent=2)
|
|
91
113
|
elif args.output_format == "sarif":
|
|
114
|
+
if isinstance(result, MigrationAnalysisResult):
|
|
115
|
+
raise AssertionError("Migration SARIF output should be rejected before analysis")
|
|
92
116
|
report = json.dumps(to_sarif(result, path=report_path or "after.sql"), indent=2)
|
|
93
117
|
elif args.output_format == "html":
|
|
118
|
+
if isinstance(result, MigrationAnalysisResult):
|
|
119
|
+
raise AssertionError("Migration HTML output should be rejected before analysis")
|
|
94
120
|
report = render_html_report(result, path=report_path or "after.sql")
|
|
95
121
|
else:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
lines.append(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
122
|
+
if isinstance(result, MigrationAnalysisResult):
|
|
123
|
+
lines = [result.summary]
|
|
124
|
+
for operation in result.operations:
|
|
125
|
+
lines.append(
|
|
126
|
+
f"- [{operation.risk.upper()}] {operation.kind}: "
|
|
127
|
+
f"{operation.table}.{operation.column}"
|
|
128
|
+
)
|
|
129
|
+
if operation.before is not None:
|
|
130
|
+
lines.append(f" before: {operation.before}")
|
|
131
|
+
if operation.after is not None:
|
|
132
|
+
lines.append(f" after: {operation.after}")
|
|
105
133
|
lines.extend(
|
|
106
|
-
f"
|
|
107
|
-
for
|
|
134
|
+
f"- [UNSUPPORTED] {item.reason}: {item.statement}"
|
|
135
|
+
for item in result.unsupported
|
|
108
136
|
)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
137
|
+
else:
|
|
138
|
+
lines = [result.summary]
|
|
139
|
+
if isinstance(result, SQLDiffResult):
|
|
140
|
+
if result.risk_reasons:
|
|
141
|
+
lines.append(f"Risk: {result.risk.upper()} - {' '.join(result.risk_reasons)}")
|
|
142
|
+
file_results = [("", result)]
|
|
143
|
+
else:
|
|
144
|
+
file_results = [(item.path, item.result) for item in result.files]
|
|
145
|
+
lines.extend(f"Removed SQL file: {path}" for path in result.unmatched_before)
|
|
146
|
+
lines.extend(f"Added SQL file: {path}" for path in result.unmatched_after)
|
|
147
|
+
lines.extend(
|
|
148
|
+
f"Renamed SQL file: {before} -> {after}"
|
|
149
|
+
for before, after in result.renamed_files
|
|
150
|
+
)
|
|
151
|
+
for file_path, file_result in file_results:
|
|
152
|
+
if file_path and (
|
|
153
|
+
file_result.changes
|
|
154
|
+
or file_result.validation_checks
|
|
155
|
+
or (
|
|
156
|
+
file_result.lineage
|
|
157
|
+
and (
|
|
158
|
+
file_result.lineage.get("changes")
|
|
159
|
+
or file_result.lineage.get("supported") is False
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
or (
|
|
163
|
+
file_result.schema_contract
|
|
164
|
+
and (
|
|
165
|
+
file_result.schema_contract.get("changes")
|
|
166
|
+
or file_result.schema_contract.get("supported") is False
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
):
|
|
170
|
+
lines.append(f"{file_path}:")
|
|
171
|
+
for change in file_result.changes:
|
|
172
|
+
lines.append(f"- [{change.risk.upper()}] {change.description}")
|
|
173
|
+
if change.before is not None:
|
|
174
|
+
lines.append(f" before: {change.before}")
|
|
175
|
+
if change.after is not None:
|
|
176
|
+
lines.append(f" after: {change.after}")
|
|
177
|
+
if file_result.schema_contract is not None:
|
|
178
|
+
contract = file_result.schema_contract
|
|
179
|
+
if contract.get("supported") is False:
|
|
180
|
+
lines.append(
|
|
181
|
+
" Output-schema contract unsupported: "
|
|
182
|
+
f"{contract.get('unsupported_reason')}"
|
|
183
|
+
)
|
|
184
|
+
for item in contract.get("changes", []):
|
|
185
|
+
old = item["before"]
|
|
186
|
+
new = item["after"]
|
|
187
|
+
old_label = (
|
|
188
|
+
f"{old['name']} ({old['type'] or 'type unknown'})" if old else "absent"
|
|
189
|
+
)
|
|
190
|
+
new_label = (
|
|
191
|
+
f"{new['name']} ({new['type'] or 'type unknown'})" if new else "absent"
|
|
192
|
+
)
|
|
193
|
+
lines.append(
|
|
194
|
+
f" Output schema {item['kind']}: {old_label} -> {new_label}"
|
|
195
|
+
)
|
|
196
|
+
if file_result.lineage is not None:
|
|
197
|
+
lineage = file_result.lineage
|
|
198
|
+
if lineage.get("supported") is False:
|
|
199
|
+
lines.append(
|
|
200
|
+
f" CTE lineage unsupported: {lineage.get('unsupported_reason')}"
|
|
201
|
+
)
|
|
202
|
+
for item in lineage.get("changes", []):
|
|
203
|
+
old = item.get("before")
|
|
204
|
+
new = item.get("after")
|
|
205
|
+
old_sources = ", ".join(old["sources"]) if old else "none"
|
|
206
|
+
new_sources = ", ".join(new["sources"]) if new else "none"
|
|
207
|
+
output_name = (new or old)["name"]
|
|
208
|
+
old_expression = old["expression"] if old else "absent"
|
|
209
|
+
new_expression = new["expression"] if new else "absent"
|
|
210
|
+
lines.append(
|
|
211
|
+
f" CTE lineage {item['kind']} for {output_name}: "
|
|
212
|
+
f"{old_expression} [{old_sources}] -> "
|
|
213
|
+
f"{new_expression} [{new_sources}]"
|
|
214
|
+
)
|
|
215
|
+
for check in file_result.validation_checks:
|
|
216
|
+
lines.append(
|
|
217
|
+
f" Validation suggestion (not executed, {check['kind']}): "
|
|
218
|
+
f"{check['description']}"
|
|
219
|
+
)
|
|
220
|
+
if check["sql"]:
|
|
221
|
+
lines.append(f" SQL:\n{check['sql']}")
|
|
222
|
+
for item in check["checklist"]:
|
|
223
|
+
lines.append(f" Checklist: {item}")
|
|
224
|
+
if check["unsupported_reason"]:
|
|
225
|
+
lines.append(f" SQL not generated: {check['unsupported_reason']}")
|
|
118
226
|
report = "\n".join(lines)
|
|
119
227
|
|
|
120
228
|
try:
|
|
@@ -127,12 +235,15 @@ def main() -> int:
|
|
|
127
235
|
print(f"error: Unable to write report: {exc}", file=sys.stderr)
|
|
128
236
|
return 2
|
|
129
237
|
|
|
130
|
-
|
|
131
|
-
bool(result.changes)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
238
|
+
if isinstance(result, SQLDiffResult):
|
|
239
|
+
has_changes = bool(result.changes)
|
|
240
|
+
elif isinstance(result, SQLDirectoryDiffResult):
|
|
241
|
+
has_changes = (
|
|
242
|
+
any(item.result.changes for item in result.files)
|
|
243
|
+
or bool(result.unmatched_before or result.unmatched_after or result.renamed_files)
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
has_changes = bool(result.operations or result.unsupported)
|
|
136
247
|
if (
|
|
137
248
|
args.fail_on_risk is not None
|
|
138
249
|
and has_changes
|
sql_diff_explainer/core.py
CHANGED
|
@@ -83,6 +83,9 @@ class SQLDiffResult:
|
|
|
83
83
|
risk: str = "low"
|
|
84
84
|
risk_reasons: list[str] = field(default_factory=list)
|
|
85
85
|
dialect: str | None = None
|
|
86
|
+
schema_contract: dict[str, object] | None = None
|
|
87
|
+
lineage: dict[str, object] | None = None
|
|
88
|
+
validation_checks: list[dict[str, object]] = field(default_factory=list)
|
|
86
89
|
|
|
87
90
|
@property
|
|
88
91
|
def risk_level(self) -> str:
|
|
@@ -103,6 +106,9 @@ class SQLDiffResult:
|
|
|
103
106
|
"risk_reasons": list(self.risk_reasons),
|
|
104
107
|
"dialect": self.dialect,
|
|
105
108
|
"changes": [change.to_dict() for change in self.changes],
|
|
109
|
+
"schema_contract": self.schema_contract,
|
|
110
|
+
"lineage": self.lineage,
|
|
111
|
+
"validation_checks": list(self.validation_checks),
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
|
|
@@ -413,7 +419,91 @@ def explain_sql_change(
|
|
|
413
419
|
)[1]
|
|
414
420
|
if reason and reason not in reasons:
|
|
415
421
|
reasons.append(reason)
|
|
416
|
-
|
|
422
|
+
from .analysis import (
|
|
423
|
+
CTELineageDiff,
|
|
424
|
+
SchemaContractDiff,
|
|
425
|
+
compare_output_schema,
|
|
426
|
+
generate_validation_suggestions,
|
|
427
|
+
trace_cte_lineage,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
schema_contract: SchemaContractDiff
|
|
431
|
+
lineage: CTELineageDiff | None = None
|
|
432
|
+
if isinstance(parser, SqlglotParser):
|
|
433
|
+
schema_contract = compare_output_schema(before, after, dialect=dialect)
|
|
434
|
+
if before_tree.args.get("with_") is not None or after_tree.args.get("with_") is not None:
|
|
435
|
+
lineage = trace_cte_lineage(before, after, dialect=dialect)
|
|
436
|
+
else:
|
|
437
|
+
schema_contract = SchemaContractDiff(
|
|
438
|
+
supported=False,
|
|
439
|
+
unsupported_reason="Output-schema analysis requires the SQLGlot parser.",
|
|
440
|
+
)
|
|
441
|
+
if schema_contract.supported:
|
|
442
|
+
for item in schema_contract.changes:
|
|
443
|
+
if item.kind != "reordered" or item.before is None or item.after is None:
|
|
444
|
+
continue
|
|
445
|
+
before_column = item.before
|
|
446
|
+
after_column = item.after
|
|
447
|
+
changes.append(
|
|
448
|
+
SQLChange(
|
|
449
|
+
category="schema_contract",
|
|
450
|
+
description=f"Output column {after_column.name} changed position.",
|
|
451
|
+
before=f"{before_column.name} at position {before_column.position + 1}",
|
|
452
|
+
after=f"{after_column.name} at position {after_column.position + 1}",
|
|
453
|
+
risk="medium",
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
if severity["medium"] > severity[overall]:
|
|
457
|
+
overall = "medium"
|
|
458
|
+
reason = "Reordering output columns changes the result contract."
|
|
459
|
+
if reason not in reasons:
|
|
460
|
+
reasons.append(reason)
|
|
461
|
+
if lineage is not None and lineage.supported:
|
|
462
|
+
for item in lineage.changes:
|
|
463
|
+
before_lineage = item.before
|
|
464
|
+
after_lineage = item.after
|
|
465
|
+
before_text = (
|
|
466
|
+
f"{before_lineage.expression} <- {', '.join(before_lineage.sources)}"
|
|
467
|
+
if before_lineage
|
|
468
|
+
else None
|
|
469
|
+
)
|
|
470
|
+
after_text = (
|
|
471
|
+
f"{after_lineage.expression} <- {', '.join(after_lineage.sources)}"
|
|
472
|
+
if after_lineage
|
|
473
|
+
else None
|
|
474
|
+
)
|
|
475
|
+
risk = "high" if item.kind == "removed" else "medium"
|
|
476
|
+
changes.append(
|
|
477
|
+
SQLChange(
|
|
478
|
+
category="cte_lineage",
|
|
479
|
+
description=f"CTE lineage {item.kind} for output "
|
|
480
|
+
f"{(after_lineage or before_lineage).name}.",
|
|
481
|
+
before=before_text,
|
|
482
|
+
after=after_text,
|
|
483
|
+
risk=risk,
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
if severity[risk] > severity[overall]:
|
|
487
|
+
overall = risk
|
|
488
|
+
reason = "A CTE source or expression change can alter a final output."
|
|
489
|
+
if reason not in reasons:
|
|
490
|
+
reasons.append(reason)
|
|
491
|
+
validation_checks = generate_validation_suggestions(
|
|
492
|
+
before,
|
|
493
|
+
after,
|
|
494
|
+
changes,
|
|
495
|
+
schema_contract,
|
|
496
|
+
dialect=dialect,
|
|
497
|
+
)
|
|
498
|
+
return SQLDiffResult(
|
|
499
|
+
changes=changes,
|
|
500
|
+
risk=overall,
|
|
501
|
+
risk_reasons=reasons,
|
|
502
|
+
dialect=dialect,
|
|
503
|
+
schema_contract=schema_contract.to_dict(),
|
|
504
|
+
lineage=lineage.to_dict() if lineage else None,
|
|
505
|
+
validation_checks=[item.to_dict() for item in validation_checks],
|
|
506
|
+
)
|
|
417
507
|
|
|
418
508
|
|
|
419
509
|
def compare_sql_directories(
|
sql_diff_explainer/reports.py
CHANGED
|
@@ -84,6 +84,117 @@ def to_sarif(
|
|
|
84
84
|
},
|
|
85
85
|
)
|
|
86
86
|
)
|
|
87
|
+
contract = sql_result.schema_contract
|
|
88
|
+
if contract and contract.get("supported"):
|
|
89
|
+
risks = {
|
|
90
|
+
"added": "medium",
|
|
91
|
+
"removed": "high",
|
|
92
|
+
"renamed": "medium",
|
|
93
|
+
"reordered": "medium",
|
|
94
|
+
"changed": "medium",
|
|
95
|
+
"type_changed": "high",
|
|
96
|
+
}
|
|
97
|
+
for item in contract.get("changes", []):
|
|
98
|
+
kind = item["kind"]
|
|
99
|
+
before = item["before"]
|
|
100
|
+
after = item["after"]
|
|
101
|
+
before_label = (
|
|
102
|
+
f"{before['name']} ({before['type'] or 'type unknown'})"
|
|
103
|
+
if before
|
|
104
|
+
else "absent"
|
|
105
|
+
)
|
|
106
|
+
after_label = (
|
|
107
|
+
f"{after['name']} ({after['type'] or 'type unknown'})"
|
|
108
|
+
if after
|
|
109
|
+
else "absent"
|
|
110
|
+
)
|
|
111
|
+
rule_id = f"sql-schema-{kind.replace('_', '-')}"
|
|
112
|
+
add_rule(
|
|
113
|
+
rule_id,
|
|
114
|
+
f"Output column {kind.replace('_', ' ')}",
|
|
115
|
+
f"SQL output column {kind.replace('_', ' ')}",
|
|
116
|
+
)
|
|
117
|
+
sarif_results.append(
|
|
118
|
+
_sarif_result(
|
|
119
|
+
rule_id=rule_id,
|
|
120
|
+
risk=risks[kind],
|
|
121
|
+
message=(
|
|
122
|
+
f"Output column {kind.replace('_', ' ')}: "
|
|
123
|
+
f"{before_label} -> {after_label}."
|
|
124
|
+
),
|
|
125
|
+
path=file_path,
|
|
126
|
+
properties={"schema_change": item},
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
elif contract:
|
|
130
|
+
rule_id = "sql-output-schema-unsupported"
|
|
131
|
+
add_rule(
|
|
132
|
+
rule_id,
|
|
133
|
+
"Output schema analysis unsupported",
|
|
134
|
+
"The output-schema contract could not be determined statically",
|
|
135
|
+
)
|
|
136
|
+
sarif_results.append(
|
|
137
|
+
_sarif_result(
|
|
138
|
+
rule_id=rule_id,
|
|
139
|
+
risk="low",
|
|
140
|
+
message=(
|
|
141
|
+
"Output-schema contract unsupported: "
|
|
142
|
+
f"{contract.get('unsupported_reason')}"
|
|
143
|
+
),
|
|
144
|
+
path=file_path,
|
|
145
|
+
properties={
|
|
146
|
+
"analysis": "output_schema",
|
|
147
|
+
"supported": False,
|
|
148
|
+
"reason": contract.get("unsupported_reason"),
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
lineage = sql_result.lineage
|
|
153
|
+
if lineage and lineage.get("supported"):
|
|
154
|
+
for item in lineage.get("changes", []):
|
|
155
|
+
output = item["after"] or item["before"]
|
|
156
|
+
before = item["before"]
|
|
157
|
+
after = item["after"]
|
|
158
|
+
before_expression = before["expression"] if before else "absent"
|
|
159
|
+
after_expression = after["expression"] if after else "absent"
|
|
160
|
+
rule_id = "sql-cte-lineage-change"
|
|
161
|
+
add_rule(
|
|
162
|
+
rule_id,
|
|
163
|
+
"CTE lineage changed",
|
|
164
|
+
"Source columns or expressions contributing to an output changed",
|
|
165
|
+
)
|
|
166
|
+
sarif_results.append(
|
|
167
|
+
_sarif_result(
|
|
168
|
+
rule_id=rule_id,
|
|
169
|
+
risk="medium",
|
|
170
|
+
message=(
|
|
171
|
+
f"CTE lineage {item['kind']} for output {output['name']}: "
|
|
172
|
+
f"{before_expression} -> {after_expression}."
|
|
173
|
+
),
|
|
174
|
+
path=file_path,
|
|
175
|
+
properties={"lineage_change": item},
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
elif lineage:
|
|
179
|
+
rule_id = "sql-cte-lineage-unsupported"
|
|
180
|
+
add_rule(
|
|
181
|
+
rule_id,
|
|
182
|
+
"CTE lineage analysis unsupported",
|
|
183
|
+
"Source-to-output lineage could not be determined statically",
|
|
184
|
+
)
|
|
185
|
+
sarif_results.append(
|
|
186
|
+
_sarif_result(
|
|
187
|
+
rule_id=rule_id,
|
|
188
|
+
risk="low",
|
|
189
|
+
message=f"CTE lineage unsupported: {lineage.get('unsupported_reason')}",
|
|
190
|
+
path=file_path,
|
|
191
|
+
properties={
|
|
192
|
+
"analysis": "cte_lineage",
|
|
193
|
+
"supported": False,
|
|
194
|
+
"reason": lineage.get("unsupported_reason"),
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
)
|
|
87
198
|
|
|
88
199
|
if isinstance(result, SQLDiffResult):
|
|
89
200
|
add_changes(result, path)
|
|
@@ -143,6 +254,7 @@ def render_html_report(
|
|
|
143
254
|
) -> str:
|
|
144
255
|
"""Render a self-contained HTML report, escaping all SQL and path content."""
|
|
145
256
|
rows: list[str] = []
|
|
257
|
+
details: list[str] = []
|
|
146
258
|
|
|
147
259
|
def add_changes(sql_result: SQLDiffResult, file_path: str) -> None:
|
|
148
260
|
for change in sql_result.changes:
|
|
@@ -155,6 +267,80 @@ def render_html_report(
|
|
|
155
267
|
f"<td><pre>{escape(change.after or '', quote=True)}</pre></td>"
|
|
156
268
|
"</tr>"
|
|
157
269
|
)
|
|
270
|
+
prefix = f"{escape(file_path, quote=True)}: " if file_path else ""
|
|
271
|
+
contract = sql_result.schema_contract
|
|
272
|
+
if contract:
|
|
273
|
+
if contract.get("supported") is False:
|
|
274
|
+
details.append(
|
|
275
|
+
"<li>"
|
|
276
|
+
f"{prefix}output-schema contract unsupported: "
|
|
277
|
+
f"{escape(str(contract.get('unsupported_reason')), quote=True)}"
|
|
278
|
+
"</li>"
|
|
279
|
+
)
|
|
280
|
+
for item in contract.get("changes", []):
|
|
281
|
+
old = item["before"]
|
|
282
|
+
new = item["after"]
|
|
283
|
+
old_label = (
|
|
284
|
+
f"{old['name']} ({old['type'] or 'type unknown'})" if old else "absent"
|
|
285
|
+
)
|
|
286
|
+
new_label = (
|
|
287
|
+
f"{new['name']} ({new['type'] or 'type unknown'})" if new else "absent"
|
|
288
|
+
)
|
|
289
|
+
details.append(
|
|
290
|
+
"<li>"
|
|
291
|
+
f"{prefix}output column "
|
|
292
|
+
f"{escape(item['kind'].replace('_', ' '), quote=True)}: "
|
|
293
|
+
f"{escape(old_label, quote=True)} → "
|
|
294
|
+
f"{escape(new_label, quote=True)}"
|
|
295
|
+
"</li>"
|
|
296
|
+
)
|
|
297
|
+
lineage = sql_result.lineage
|
|
298
|
+
if lineage:
|
|
299
|
+
if lineage.get("supported") is False:
|
|
300
|
+
details.append(
|
|
301
|
+
"<li>"
|
|
302
|
+
f"{prefix}CTE lineage unsupported: "
|
|
303
|
+
f"{escape(str(lineage.get('unsupported_reason')), quote=True)}"
|
|
304
|
+
"</li>"
|
|
305
|
+
)
|
|
306
|
+
for item in lineage.get("changes", []):
|
|
307
|
+
old = item.get("before")
|
|
308
|
+
new = item.get("after")
|
|
309
|
+
name = (new or old)["name"]
|
|
310
|
+
old_sources = ", ".join(old["sources"]) if old else "none"
|
|
311
|
+
new_sources = ", ".join(new["sources"]) if new else "none"
|
|
312
|
+
old_expression = old["expression"] if old else "absent"
|
|
313
|
+
new_expression = new["expression"] if new else "absent"
|
|
314
|
+
details.append(
|
|
315
|
+
"<li>"
|
|
316
|
+
f"{prefix}CTE lineage {escape(item['kind'], quote=True)} for "
|
|
317
|
+
f"{escape(name, quote=True)}: "
|
|
318
|
+
f"{escape(old_expression, quote=True)} [{escape(old_sources, quote=True)}] "
|
|
319
|
+
f"→ {escape(new_expression, quote=True)} "
|
|
320
|
+
f"[{escape(new_sources, quote=True)}]"
|
|
321
|
+
"</li>"
|
|
322
|
+
)
|
|
323
|
+
for check in sql_result.validation_checks:
|
|
324
|
+
details.append(
|
|
325
|
+
"<li>"
|
|
326
|
+
f"{prefix}validation ({escape(check['kind'], quote=True)}): "
|
|
327
|
+
f"{escape(check['description'], quote=True)}"
|
|
328
|
+
+ "".join(
|
|
329
|
+
f"<ul><li>{escape(item, quote=True)}</li></ul>"
|
|
330
|
+
for item in check["checklist"]
|
|
331
|
+
)
|
|
332
|
+
+ "</li>"
|
|
333
|
+
)
|
|
334
|
+
if check["sql"]:
|
|
335
|
+
details.append(
|
|
336
|
+
"<li><strong>Suggested SQL (not executed)</strong>"
|
|
337
|
+
f"<pre>{escape(check['sql'], quote=True)}</pre></li>"
|
|
338
|
+
)
|
|
339
|
+
if check["unsupported_reason"]:
|
|
340
|
+
details.append(
|
|
341
|
+
"<li>SQL not generated: "
|
|
342
|
+
f"{escape(check['unsupported_reason'], quote=True)}</li>"
|
|
343
|
+
)
|
|
158
344
|
|
|
159
345
|
if isinstance(result, SQLDiffResult):
|
|
160
346
|
risk = result.risk
|
|
@@ -193,6 +379,11 @@ def render_html_report(
|
|
|
193
379
|
|
|
194
380
|
if not rows:
|
|
195
381
|
rows.append('<tr><td colspan="5">No structural SQL changes detected.</td></tr>')
|
|
382
|
+
details_section = (
|
|
383
|
+
["<section><h2>Analysis details</h2><ul>", *details, "</ul></section>"]
|
|
384
|
+
if details
|
|
385
|
+
else []
|
|
386
|
+
)
|
|
196
387
|
return "\n".join(
|
|
197
388
|
[
|
|
198
389
|
"<!doctype html>",
|
|
@@ -213,7 +404,9 @@ def render_html_report(
|
|
|
213
404
|
"<table><thead><tr><th>File</th><th>Risk</th><th>Change</th>"
|
|
214
405
|
"<th>Before</th><th>After</th></tr></thead><tbody>",
|
|
215
406
|
*rows,
|
|
216
|
-
"</tbody></table
|
|
407
|
+
"</tbody></table>",
|
|
408
|
+
*details_section,
|
|
409
|
+
"</body></html>",
|
|
217
410
|
"",
|
|
218
411
|
]
|
|
219
412
|
)
|