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/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
- if directory_mode and (args.before is not None or args.after is not None):
51
- parser.error("Use either --compare-dirs or the two SQL file arguments, not both")
52
- if not directory_mode and (args.before is None or args.after is None):
53
- parser.error("Provide two SQL files or use --compare-dirs BEFORE_DIR AFTER_DIR")
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 directory_mode:
57
- result: SQLDiffResult | SQLDirectoryDiffResult = compare_sql_directories(
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
- lines = [result.summary]
82
- if isinstance(result, SQLDiffResult):
83
- if result.risk_reasons:
84
- lines.append(f"Risk: {result.risk.upper()} - {' '.join(result.risk_reasons)}")
85
- file_results = [("", result)]
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
- file_results = [(item.path, item.result) for item in result.files]
88
- lines.extend(f"Removed SQL file: {path}" for path in result.unmatched_before)
89
- lines.extend(f"Added SQL file: {path}" for path in result.unmatched_after)
90
- for file_path, file_result in file_results:
91
- if file_path and file_result.changes:
92
- lines.append(f"{file_path}:")
93
- for change in file_result.changes:
94
- lines.append(f"- [{change.risk.upper()}] {change.description}")
95
- if change.before is not None:
96
- lines.append(f" before: {change.before}")
97
- if change.after is not None:
98
- lines.append(f" after: {change.after}")
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
- has_changes = (
112
- bool(result.changes)
113
- if isinstance(result, SQLDiffResult)
114
- else any(item.result.changes for item in result.files)
115
- or bool(result.unmatched_before or result.unmatched_after)
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
@@ -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
- return SQLDiffResult(changes=changes, risk=overall, risk_reasons=reasons, dialect=dialect)
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(