sql-diff-explainer 0.1.3__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 +31 -1
- sql_diff_explainer/analysis.py +804 -0
- sql_diff_explainer/cli.py +159 -29
- sql_diff_explainer/core.py +235 -3
- sql_diff_explainer/reports.py +215 -1
- {sql_diff_explainer-0.1.3.dist-info → sql_diff_explainer-0.1.5.dist-info}/METADATA +130 -7
- sql_diff_explainer-0.1.5.dist-info/RECORD +10 -0
- sql_diff_explainer-0.1.3.dist-info/RECORD +0 -9
- {sql_diff_explainer-0.1.3.dist-info → sql_diff_explainer-0.1.5.dist-info}/WHEEL +0 -0
- {sql_diff_explainer-0.1.3.dist-info → sql_diff_explainer-0.1.5.dist-info}/entry_points.txt +0 -0
- {sql_diff_explainer-0.1.3.dist-info → sql_diff_explainer-0.1.5.dist-info}/licenses/LICENSE +0 -0
sql_diff_explainer/cli.py
CHANGED
|
@@ -5,11 +5,13 @@ 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,
|
|
11
12
|
SQLDirectoryDiffResult,
|
|
12
13
|
compare_sql_directories,
|
|
14
|
+
compare_sql_git,
|
|
13
15
|
explain_sql_change,
|
|
14
16
|
)
|
|
15
17
|
from .reports import render_html_report, to_sarif
|
|
@@ -31,6 +33,17 @@ def main() -> int:
|
|
|
31
33
|
metavar=("BEFORE_DIR", "AFTER_DIR"),
|
|
32
34
|
help="Recursively compare matching .sql files in two directories",
|
|
33
35
|
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--git-base",
|
|
38
|
+
metavar="REF",
|
|
39
|
+
help="Compare Git-reported .sql changes from REF to the current worktree",
|
|
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
|
+
)
|
|
34
47
|
parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
|
|
35
48
|
parser.add_argument(
|
|
36
49
|
"--format",
|
|
@@ -47,14 +60,38 @@ def main() -> int:
|
|
|
47
60
|
args = parser.parse_args()
|
|
48
61
|
|
|
49
62
|
directory_mode = args.compare_dirs is not None
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
git_mode = args.git_base is not None
|
|
64
|
+
migration_mode = args.analyze_migration is not None
|
|
65
|
+
selected_modes = sum((directory_mode, git_mode, migration_mode))
|
|
66
|
+
if selected_modes and (args.before is not None or args.after is not None):
|
|
67
|
+
parser.error(
|
|
68
|
+
"Use only one of --git-base, --compare-dirs, --analyze-migration, "
|
|
69
|
+
"or the two SQL file arguments"
|
|
70
|
+
)
|
|
71
|
+
if selected_modes > 1:
|
|
72
|
+
parser.error("Choose only one of --git-base, --compare-dirs, or --analyze-migration")
|
|
73
|
+
if not selected_modes and (args.before is None or args.after is None):
|
|
74
|
+
parser.error(
|
|
75
|
+
"Provide two SQL files, use --compare-dirs BEFORE_DIR AFTER_DIR, "
|
|
76
|
+
"--git-base REF, or --analyze-migration FILE"
|
|
77
|
+
)
|
|
78
|
+
if migration_mode and args.output_format in {"sarif", "html"}:
|
|
79
|
+
parser.error("--analyze-migration supports text and JSON output")
|
|
54
80
|
|
|
55
81
|
try:
|
|
56
|
-
if
|
|
57
|
-
result: SQLDiffResult | SQLDirectoryDiffResult =
|
|
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:
|
|
91
|
+
result = compare_sql_git(args.git_base, dialect=args.dialect)
|
|
92
|
+
report_path = ""
|
|
93
|
+
elif directory_mode:
|
|
94
|
+
result = compare_sql_directories(
|
|
58
95
|
args.compare_dirs[0],
|
|
59
96
|
args.compare_dirs[1],
|
|
60
97
|
dialect=args.dialect,
|
|
@@ -74,28 +111,118 @@ def main() -> int:
|
|
|
74
111
|
if args.output_format == "json":
|
|
75
112
|
report = json.dumps(result.to_dict(), indent=2)
|
|
76
113
|
elif args.output_format == "sarif":
|
|
114
|
+
if isinstance(result, MigrationAnalysisResult):
|
|
115
|
+
raise AssertionError("Migration SARIF output should be rejected before analysis")
|
|
77
116
|
report = json.dumps(to_sarif(result, path=report_path or "after.sql"), indent=2)
|
|
78
117
|
elif args.output_format == "html":
|
|
118
|
+
if isinstance(result, MigrationAnalysisResult):
|
|
119
|
+
raise AssertionError("Migration HTML output should be rejected before analysis")
|
|
79
120
|
report = render_html_report(result, path=report_path or "after.sql")
|
|
80
121
|
else:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
lines.append(
|
|
85
|
-
|
|
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}")
|
|
133
|
+
lines.extend(
|
|
134
|
+
f"- [UNSUPPORTED] {item.reason}: {item.statement}"
|
|
135
|
+
for item in result.unsupported
|
|
136
|
+
)
|
|
86
137
|
else:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
lines.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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']}")
|
|
99
226
|
report = "\n".join(lines)
|
|
100
227
|
|
|
101
228
|
try:
|
|
@@ -108,12 +235,15 @@ def main() -> int:
|
|
|
108
235
|
print(f"error: Unable to write report: {exc}", file=sys.stderr)
|
|
109
236
|
return 2
|
|
110
237
|
|
|
111
|
-
|
|
112
|
-
bool(result.changes)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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)
|
|
117
247
|
if (
|
|
118
248
|
args.fail_on_risk is not None
|
|
119
249
|
and has_changes
|
sql_diff_explainer/core.py
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
5
7
|
from abc import ABC, abstractmethod
|
|
6
8
|
from collections import Counter
|
|
7
9
|
from dataclasses import dataclass, field
|
|
8
10
|
from difflib import unified_diff
|
|
9
|
-
from pathlib import Path
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
10
12
|
from typing import Any
|
|
11
13
|
|
|
12
14
|
|
|
@@ -81,6 +83,9 @@ class SQLDiffResult:
|
|
|
81
83
|
risk: str = "low"
|
|
82
84
|
risk_reasons: list[str] = field(default_factory=list)
|
|
83
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)
|
|
84
89
|
|
|
85
90
|
@property
|
|
86
91
|
def risk_level(self) -> str:
|
|
@@ -101,6 +106,9 @@ class SQLDiffResult:
|
|
|
101
106
|
"risk_reasons": list(self.risk_reasons),
|
|
102
107
|
"dialect": self.dialect,
|
|
103
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),
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
|
|
@@ -122,6 +130,7 @@ class SQLDirectoryDiffResult:
|
|
|
122
130
|
files: list[SQLFileDiff] = field(default_factory=list)
|
|
123
131
|
unmatched_before: list[str] = field(default_factory=list)
|
|
124
132
|
unmatched_after: list[str] = field(default_factory=list)
|
|
133
|
+
renamed_files: list[tuple[str, str]] = field(default_factory=list)
|
|
125
134
|
|
|
126
135
|
@property
|
|
127
136
|
def risk(self) -> str:
|
|
@@ -143,8 +152,13 @@ class SQLDirectoryDiffResult:
|
|
|
143
152
|
def summary(self) -> str:
|
|
144
153
|
changed = sum(bool(item.result.changes) for item in self.files)
|
|
145
154
|
unmatched = len(self.unmatched_before) + len(self.unmatched_after)
|
|
146
|
-
if not changed and not unmatched:
|
|
155
|
+
if not changed and not unmatched and not self.renamed_files:
|
|
147
156
|
return f"No structural SQL changes detected across {len(self.files)} file(s)."
|
|
157
|
+
if self.renamed_files:
|
|
158
|
+
return (
|
|
159
|
+
f"{changed} changed SQL file(s), {unmatched} unmatched SQL file(s), "
|
|
160
|
+
f"{len(self.renamed_files)} renamed SQL file(s); overall risk is {self.risk}."
|
|
161
|
+
)
|
|
148
162
|
return (
|
|
149
163
|
f"{changed} changed SQL file(s), {unmatched} unmatched SQL file(s); "
|
|
150
164
|
f"overall risk is {self.risk}."
|
|
@@ -158,6 +172,9 @@ class SQLDirectoryDiffResult:
|
|
|
158
172
|
"files": [item.to_dict() for item in self.files],
|
|
159
173
|
"unmatched_before": list(self.unmatched_before),
|
|
160
174
|
"unmatched_after": list(self.unmatched_after),
|
|
175
|
+
"renamed_files": [
|
|
176
|
+
{"before": before, "after": after} for before, after in self.renamed_files
|
|
177
|
+
],
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
|
|
@@ -402,7 +419,91 @@ def explain_sql_change(
|
|
|
402
419
|
)[1]
|
|
403
420
|
if reason and reason not in reasons:
|
|
404
421
|
reasons.append(reason)
|
|
405
|
-
|
|
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
|
+
)
|
|
406
507
|
|
|
407
508
|
|
|
408
509
|
def compare_sql_directories(
|
|
@@ -461,6 +562,137 @@ def compare_sql_directories(
|
|
|
461
562
|
)
|
|
462
563
|
|
|
463
564
|
|
|
565
|
+
def compare_sql_git(
|
|
566
|
+
base_ref: str,
|
|
567
|
+
*,
|
|
568
|
+
cwd: str | Path = ".",
|
|
569
|
+
dialect: str | None = None,
|
|
570
|
+
parser: SQLParser | None = None,
|
|
571
|
+
) -> SQLDirectoryDiffResult:
|
|
572
|
+
"""Compare Git-reported SQL changes from a base commit to the current worktree."""
|
|
573
|
+
working_dir = Path(cwd)
|
|
574
|
+
|
|
575
|
+
def run_git(*arguments: str) -> bytes:
|
|
576
|
+
try:
|
|
577
|
+
completed = subprocess.run(
|
|
578
|
+
["git", *arguments],
|
|
579
|
+
cwd=working_dir,
|
|
580
|
+
capture_output=True,
|
|
581
|
+
check=False,
|
|
582
|
+
)
|
|
583
|
+
except OSError as exc:
|
|
584
|
+
raise SQLDiffError(f"Unable to run git: {exc}") from exc
|
|
585
|
+
if completed.returncode:
|
|
586
|
+
message = os.fsdecode(completed.stderr).strip()
|
|
587
|
+
raise SQLDiffError(f"Git command failed: {message or 'unknown git error'}")
|
|
588
|
+
return completed.stdout
|
|
589
|
+
|
|
590
|
+
root_output = run_git("rev-parse", "--show-toplevel").removesuffix(b"\n").removesuffix(b"\r")
|
|
591
|
+
root = Path(os.fsdecode(root_output))
|
|
592
|
+
commit = os.fsdecode(
|
|
593
|
+
run_git("rev-parse", "--verify", "--end-of-options", f"{base_ref}^{{commit}}")
|
|
594
|
+
).strip()
|
|
595
|
+
if not commit:
|
|
596
|
+
raise SQLDiffError(f"Git base ref did not resolve to a commit: {base_ref}")
|
|
597
|
+
|
|
598
|
+
status_fields = run_git(
|
|
599
|
+
"diff", "--name-status", "-z", "--find-renames", commit, "--"
|
|
600
|
+
).split(b"\0")
|
|
601
|
+
if status_fields and status_fields[-1] == b"":
|
|
602
|
+
status_fields.pop()
|
|
603
|
+
|
|
604
|
+
changed: list[tuple[str, str, str | None]] = []
|
|
605
|
+
index = 0
|
|
606
|
+
while index < len(status_fields):
|
|
607
|
+
status = os.fsdecode(status_fields[index])
|
|
608
|
+
index += 1
|
|
609
|
+
path_count = 2 if status.startswith(("R", "C")) else 1
|
|
610
|
+
if index + path_count > len(status_fields):
|
|
611
|
+
raise SQLDiffError("Git returned an incomplete file change record")
|
|
612
|
+
paths = [os.fsdecode(item) for item in status_fields[index : index + path_count]]
|
|
613
|
+
index += path_count
|
|
614
|
+
if path_count == 2:
|
|
615
|
+
changed.append((status[0], paths[0], paths[1]))
|
|
616
|
+
else:
|
|
617
|
+
changed.append(
|
|
618
|
+
(status[0], paths[0], None if status.startswith("D") else paths[0])
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
def validate_repo_path(path: str) -> None:
|
|
622
|
+
candidate = PurePosixPath(path)
|
|
623
|
+
if candidate.is_absolute() or "\\" in path or ".." in candidate.parts:
|
|
624
|
+
raise SQLDiffError(f"Unsafe path returned by git: {path!r}")
|
|
625
|
+
|
|
626
|
+
def read_base(path: str) -> str:
|
|
627
|
+
validate_repo_path(path)
|
|
628
|
+
try:
|
|
629
|
+
return run_git("cat-file", "blob", f"{commit}:{path}").decode("utf-8")
|
|
630
|
+
except UnicodeDecodeError as exc:
|
|
631
|
+
raise SQLDiffError(
|
|
632
|
+
f"Unable to read SQL file as UTF-8 from Git base ({path}): {exc}"
|
|
633
|
+
) from exc
|
|
634
|
+
except SQLDiffError as exc:
|
|
635
|
+
raise SQLDiffError(f"Unable to read SQL file from Git base ({path}): {exc}") from exc
|
|
636
|
+
|
|
637
|
+
def read_worktree(path: str) -> str:
|
|
638
|
+
validate_repo_path(path)
|
|
639
|
+
file_path = root.joinpath(*PurePosixPath(path).parts)
|
|
640
|
+
try:
|
|
641
|
+
resolved = file_path.resolve(strict=True)
|
|
642
|
+
if not resolved.is_relative_to(root.resolve()):
|
|
643
|
+
raise SQLDiffError(f"SQL file path resolves outside the Git repository: {path}")
|
|
644
|
+
if not resolved.is_file():
|
|
645
|
+
raise SQLDiffError(f"SQL path is not a file in the worktree: {path}")
|
|
646
|
+
return resolved.read_text(encoding="utf-8")
|
|
647
|
+
except UnicodeDecodeError as exc:
|
|
648
|
+
raise SQLDiffError(f"Unable to read SQL file as UTF-8 ({path}): {exc}") from exc
|
|
649
|
+
except OSError as exc:
|
|
650
|
+
raise SQLDiffError(f"Unable to read SQL file from worktree ({path}): {exc}") from exc
|
|
651
|
+
|
|
652
|
+
def is_sql(path: str) -> bool:
|
|
653
|
+
return PurePosixPath(path).suffix.lower() == ".sql"
|
|
654
|
+
|
|
655
|
+
files: list[SQLFileDiff] = []
|
|
656
|
+
removed: list[str] = []
|
|
657
|
+
added: list[str] = []
|
|
658
|
+
renamed: list[tuple[str, str]] = []
|
|
659
|
+
for status, before_path, after_path in changed:
|
|
660
|
+
if after_path is None:
|
|
661
|
+
if is_sql(before_path):
|
|
662
|
+
removed.append(before_path)
|
|
663
|
+
continue
|
|
664
|
+
|
|
665
|
+
before_is_sql = is_sql(before_path)
|
|
666
|
+
after_is_sql = is_sql(after_path)
|
|
667
|
+
if status == "A":
|
|
668
|
+
if after_is_sql:
|
|
669
|
+
added.append(after_path)
|
|
670
|
+
continue
|
|
671
|
+
if before_is_sql and after_is_sql:
|
|
672
|
+
if before_path != after_path:
|
|
673
|
+
renamed.append((before_path, after_path))
|
|
674
|
+
files.append(
|
|
675
|
+
SQLFileDiff(
|
|
676
|
+
after_path,
|
|
677
|
+
explain_sql_change(
|
|
678
|
+
read_base(before_path), read_worktree(after_path),
|
|
679
|
+
dialect=dialect, parser=parser,
|
|
680
|
+
),
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
elif before_is_sql:
|
|
684
|
+
removed.append(before_path)
|
|
685
|
+
elif after_is_sql:
|
|
686
|
+
added.append(after_path)
|
|
687
|
+
|
|
688
|
+
return SQLDirectoryDiffResult(
|
|
689
|
+
files=sorted(files, key=lambda item: item.path),
|
|
690
|
+
unmatched_before=sorted(removed),
|
|
691
|
+
unmatched_after=sorted(added),
|
|
692
|
+
renamed_files=sorted(renamed),
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
464
696
|
def explain_diff(before: str, after: str) -> str:
|
|
465
697
|
"""Return a readable unified diff for two SQL documents."""
|
|
466
698
|
lines = unified_diff(
|