sql-diff-explainer 0.1.2__py3-none-any.whl → 0.1.3__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 +10 -1
- sql_diff_explainer/cli.py +89 -22
- sql_diff_explainer/core.py +114 -0
- sql_diff_explainer/reports.py +198 -0
- sql_diff_explainer-0.1.3.dist-info/METADATA +230 -0
- sql_diff_explainer-0.1.3.dist-info/RECORD +9 -0
- sql_diff_explainer-0.1.2.dist-info/METADATA +0 -147
- sql_diff_explainer-0.1.2.dist-info/RECORD +0 -8
- {sql_diff_explainer-0.1.2.dist-info → sql_diff_explainer-0.1.3.dist-info}/WHEEL +0 -0
- {sql_diff_explainer-0.1.2.dist-info → sql_diff_explainer-0.1.3.dist-info}/entry_points.txt +0 -0
- {sql_diff_explainer-0.1.2.dist-info → sql_diff_explainer-0.1.3.dist-info}/licenses/LICENSE +0 -0
sql_diff_explainer/__init__.py
CHANGED
|
@@ -4,23 +4,32 @@ from .core import (
|
|
|
4
4
|
SQLChange,
|
|
5
5
|
SQLDiffError,
|
|
6
6
|
SQLDiffResult,
|
|
7
|
+
SQLDirectoryDiffResult,
|
|
8
|
+
SQLFileDiff,
|
|
7
9
|
SqlglotParser,
|
|
8
10
|
SQLParseError,
|
|
9
11
|
SQLParser,
|
|
10
12
|
UnsupportedSQL,
|
|
13
|
+
compare_sql_directories,
|
|
11
14
|
explain_diff,
|
|
12
15
|
explain_sql_change,
|
|
13
16
|
)
|
|
17
|
+
from .reports import render_html_report, to_sarif
|
|
14
18
|
|
|
15
19
|
__all__ = [
|
|
16
20
|
"SQLChange",
|
|
21
|
+
"SQLDirectoryDiffResult",
|
|
17
22
|
"SQLDiffError",
|
|
18
23
|
"SQLDiffResult",
|
|
24
|
+
"SQLFileDiff",
|
|
19
25
|
"SQLParseError",
|
|
20
26
|
"SQLParser",
|
|
21
27
|
"SqlglotParser",
|
|
22
28
|
"UnsupportedSQL",
|
|
29
|
+
"compare_sql_directories",
|
|
23
30
|
"explain_diff",
|
|
24
31
|
"explain_sql_change",
|
|
32
|
+
"render_html_report",
|
|
33
|
+
"to_sarif",
|
|
25
34
|
]
|
|
26
|
-
__version__ = "0.1.
|
|
35
|
+
__version__ = "0.1.3"
|
sql_diff_explainer/cli.py
CHANGED
|
@@ -5,7 +5,14 @@ import json
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
-
from .core import
|
|
8
|
+
from .core import (
|
|
9
|
+
SQLDiffError,
|
|
10
|
+
SQLDiffResult,
|
|
11
|
+
SQLDirectoryDiffResult,
|
|
12
|
+
compare_sql_directories,
|
|
13
|
+
explain_sql_change,
|
|
14
|
+
)
|
|
15
|
+
from .reports import render_html_report, to_sarif
|
|
9
16
|
|
|
10
17
|
_RISK_SEVERITY = {"low": 0, "medium": 1, "high": 2}
|
|
11
18
|
|
|
@@ -13,12 +20,25 @@ _RISK_SEVERITY = {"low": 0, "medium": 1, "high": 2}
|
|
|
13
20
|
def main() -> int:
|
|
14
21
|
"""Run the command-line interface."""
|
|
15
22
|
parser = argparse.ArgumentParser(
|
|
16
|
-
description="Explain structural differences between
|
|
23
|
+
description="Explain structural differences between SQL files or directories."
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("before", type=Path, nargs="?", help="Original SQL file")
|
|
26
|
+
parser.add_argument("after", type=Path, nargs="?", help="Updated SQL file")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--compare-dirs",
|
|
29
|
+
nargs=2,
|
|
30
|
+
type=Path,
|
|
31
|
+
metavar=("BEFORE_DIR", "AFTER_DIR"),
|
|
32
|
+
help="Recursively compare matching .sql files in two directories",
|
|
17
33
|
)
|
|
18
|
-
parser.add_argument("before", type=Path, help="Original SQL file")
|
|
19
|
-
parser.add_argument("after", type=Path, help="Updated SQL file")
|
|
20
34
|
parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
|
|
21
|
-
parser.add_argument(
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--format",
|
|
37
|
+
choices=("text", "json", "sarif", "html"),
|
|
38
|
+
default="text",
|
|
39
|
+
dest="output_format",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("--output", type=Path, help="Write the report to this file")
|
|
22
42
|
parser.add_argument(
|
|
23
43
|
"--fail-on-risk",
|
|
24
44
|
choices=tuple(_RISK_SEVERITY),
|
|
@@ -26,30 +46,77 @@ def main() -> int:
|
|
|
26
46
|
)
|
|
27
47
|
args = parser.parse_args()
|
|
28
48
|
|
|
49
|
+
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")
|
|
54
|
+
|
|
29
55
|
try:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
if directory_mode:
|
|
57
|
+
result: SQLDiffResult | SQLDirectoryDiffResult = compare_sql_directories(
|
|
58
|
+
args.compare_dirs[0],
|
|
59
|
+
args.compare_dirs[1],
|
|
60
|
+
dialect=args.dialect,
|
|
61
|
+
)
|
|
62
|
+
report_path = ""
|
|
63
|
+
else:
|
|
64
|
+
result = explain_sql_change(
|
|
65
|
+
args.before.read_text(encoding="utf-8"),
|
|
66
|
+
args.after.read_text(encoding="utf-8"),
|
|
67
|
+
dialect=args.dialect,
|
|
68
|
+
)
|
|
69
|
+
report_path = args.after.as_posix()
|
|
70
|
+
except (OSError, UnicodeDecodeError, SQLDiffError) as exc:
|
|
36
71
|
print(f"error: {exc}", file=sys.stderr)
|
|
37
72
|
return 2
|
|
73
|
+
|
|
38
74
|
if args.output_format == "json":
|
|
39
|
-
|
|
75
|
+
report = json.dumps(result.to_dict(), indent=2)
|
|
76
|
+
elif args.output_format == "sarif":
|
|
77
|
+
report = json.dumps(to_sarif(result, path=report_path or "after.sql"), indent=2)
|
|
78
|
+
elif args.output_format == "html":
|
|
79
|
+
report = render_html_report(result, path=report_path or "after.sql")
|
|
40
80
|
else:
|
|
41
|
-
|
|
42
|
-
if result
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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)]
|
|
86
|
+
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}")
|
|
99
|
+
report = "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
if args.output is None:
|
|
103
|
+
print(report)
|
|
104
|
+
else:
|
|
105
|
+
output_text = report if report.endswith("\n") else report + "\n"
|
|
106
|
+
args.output.write_text(output_text, encoding="utf-8")
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
print(f"error: Unable to write report: {exc}", file=sys.stderr)
|
|
109
|
+
return 2
|
|
110
|
+
|
|
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
|
+
)
|
|
50
117
|
if (
|
|
51
118
|
args.fail_on_risk is not None
|
|
52
|
-
and
|
|
119
|
+
and has_changes
|
|
53
120
|
and _RISK_SEVERITY[result.risk] >= _RISK_SEVERITY[args.fail_on_risk]
|
|
54
121
|
):
|
|
55
122
|
return 1
|
sql_diff_explainer/core.py
CHANGED
|
@@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
|
|
|
6
6
|
from collections import Counter
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
8
|
from difflib import unified_diff
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
|
|
@@ -103,6 +104,63 @@ class SQLDiffResult:
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class SQLFileDiff:
|
|
109
|
+
"""The semantic comparison for one matched relative SQL file path."""
|
|
110
|
+
|
|
111
|
+
path: str
|
|
112
|
+
result: SQLDiffResult
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict[str, object]:
|
|
115
|
+
return {"path": self.path, **self.result.to_dict()}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class SQLDirectoryDiffResult:
|
|
120
|
+
"""Comparisons and unmatched SQL files from two directory trees."""
|
|
121
|
+
|
|
122
|
+
files: list[SQLFileDiff] = field(default_factory=list)
|
|
123
|
+
unmatched_before: list[str] = field(default_factory=list)
|
|
124
|
+
unmatched_after: list[str] = field(default_factory=list)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def risk(self) -> str:
|
|
128
|
+
"""Return the highest file risk; whole-file additions/removals are high risk."""
|
|
129
|
+
if self.unmatched_before or self.unmatched_after:
|
|
130
|
+
return "high"
|
|
131
|
+
severity = {"low": 0, "medium": 1, "high": 2}
|
|
132
|
+
return max(
|
|
133
|
+
(item.result.risk for item in self.files),
|
|
134
|
+
key=severity.__getitem__,
|
|
135
|
+
default="low",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def risk_level(self) -> str:
|
|
140
|
+
return self.risk
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def summary(self) -> str:
|
|
144
|
+
changed = sum(bool(item.result.changes) for item in self.files)
|
|
145
|
+
unmatched = len(self.unmatched_before) + len(self.unmatched_after)
|
|
146
|
+
if not changed and not unmatched:
|
|
147
|
+
return f"No structural SQL changes detected across {len(self.files)} file(s)."
|
|
148
|
+
return (
|
|
149
|
+
f"{changed} changed SQL file(s), {unmatched} unmatched SQL file(s); "
|
|
150
|
+
f"overall risk is {self.risk}."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict[str, object]:
|
|
154
|
+
return {
|
|
155
|
+
"summary": self.summary,
|
|
156
|
+
"risk": self.risk,
|
|
157
|
+
"risk_level": self.risk_level,
|
|
158
|
+
"files": [item.to_dict() for item in self.files],
|
|
159
|
+
"unmatched_before": list(self.unmatched_before),
|
|
160
|
+
"unmatched_after": list(self.unmatched_after),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
106
164
|
def _sql(node: Any, dialect: str | None) -> str | None:
|
|
107
165
|
if node is None:
|
|
108
166
|
return None
|
|
@@ -347,6 +405,62 @@ def explain_sql_change(
|
|
|
347
405
|
return SQLDiffResult(changes=changes, risk=overall, risk_reasons=reasons, dialect=dialect)
|
|
348
406
|
|
|
349
407
|
|
|
408
|
+
def compare_sql_directories(
|
|
409
|
+
before_dir: str | Path,
|
|
410
|
+
after_dir: str | Path,
|
|
411
|
+
*,
|
|
412
|
+
dialect: str | None = None,
|
|
413
|
+
parser: SQLParser | None = None,
|
|
414
|
+
) -> SQLDirectoryDiffResult:
|
|
415
|
+
"""Compare recursively discovered SQL files paired by exact relative path.
|
|
416
|
+
|
|
417
|
+
Files present on only one side are returned as unmatched instead of being
|
|
418
|
+
parsed as empty SQL. An unmatched file makes the aggregate risk high.
|
|
419
|
+
"""
|
|
420
|
+
before_root = Path(before_dir)
|
|
421
|
+
after_root = Path(after_dir)
|
|
422
|
+
for label, root in (("before", before_root), ("after", after_root)):
|
|
423
|
+
if not root.exists():
|
|
424
|
+
raise SQLDiffError(f"{label} directory does not exist: {root}")
|
|
425
|
+
if not root.is_dir():
|
|
426
|
+
raise SQLDiffError(f"{label} path is not a directory: {root}")
|
|
427
|
+
|
|
428
|
+
def discover(root: Path) -> dict[str, Path]:
|
|
429
|
+
return {
|
|
430
|
+
path.relative_to(root).as_posix(): path
|
|
431
|
+
for path in sorted(root.rglob("*"))
|
|
432
|
+
if path.is_file() and path.suffix.lower() == ".sql"
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
before_files = discover(before_root)
|
|
436
|
+
after_files = discover(after_root)
|
|
437
|
+
before_paths = set(before_files)
|
|
438
|
+
after_paths = set(after_files)
|
|
439
|
+
shared_paths = sorted(before_paths & after_paths)
|
|
440
|
+
try:
|
|
441
|
+
comparisons = [
|
|
442
|
+
SQLFileDiff(
|
|
443
|
+
path=relative_path,
|
|
444
|
+
result=explain_sql_change(
|
|
445
|
+
before_files[relative_path].read_text(encoding="utf-8"),
|
|
446
|
+
after_files[relative_path].read_text(encoding="utf-8"),
|
|
447
|
+
dialect=dialect,
|
|
448
|
+
parser=parser,
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
for relative_path in shared_paths
|
|
452
|
+
]
|
|
453
|
+
except UnicodeDecodeError as exc:
|
|
454
|
+
raise SQLDiffError(f"Unable to read SQL file as UTF-8: {exc}") from exc
|
|
455
|
+
except OSError as exc:
|
|
456
|
+
raise SQLDiffError(f"Unable to read SQL file: {exc}") from exc
|
|
457
|
+
return SQLDirectoryDiffResult(
|
|
458
|
+
files=comparisons,
|
|
459
|
+
unmatched_before=sorted(before_paths - after_paths),
|
|
460
|
+
unmatched_after=sorted(after_paths - before_paths),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
350
464
|
def explain_diff(before: str, after: str) -> str:
|
|
351
465
|
"""Return a readable unified diff for two SQL documents."""
|
|
352
466
|
lines = unified_diff(
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Machine-readable and standalone HTML reports for SQL comparisons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .core import SQLDiffResult, SQLDirectoryDiffResult
|
|
10
|
+
|
|
11
|
+
_SARIF_LEVEL = {"low": "note", "medium": "warning", "high": "error"}
|
|
12
|
+
_SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _artifact_uri(path: str) -> str:
|
|
16
|
+
candidate = Path(path)
|
|
17
|
+
if candidate.is_absolute():
|
|
18
|
+
return candidate.as_uri()
|
|
19
|
+
return quote(candidate.as_posix(), safe="/-._~")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sarif_result(
|
|
23
|
+
*,
|
|
24
|
+
rule_id: str,
|
|
25
|
+
risk: str,
|
|
26
|
+
message: str,
|
|
27
|
+
path: str,
|
|
28
|
+
properties: dict[str, object] | None = None,
|
|
29
|
+
) -> dict[str, object]:
|
|
30
|
+
result: dict[str, object] = {
|
|
31
|
+
"ruleId": rule_id,
|
|
32
|
+
"level": _SARIF_LEVEL[risk],
|
|
33
|
+
"message": {"text": message},
|
|
34
|
+
"locations": [
|
|
35
|
+
{
|
|
36
|
+
"physicalLocation": {
|
|
37
|
+
"artifactLocation": {"uri": _artifact_uri(path)},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"properties": {"risk": risk, **(properties or {})},
|
|
42
|
+
}
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_sarif(
|
|
47
|
+
result: SQLDiffResult | SQLDirectoryDiffResult,
|
|
48
|
+
*,
|
|
49
|
+
path: str = "after.sql",
|
|
50
|
+
) -> dict[str, object]:
|
|
51
|
+
"""Convert a single-file or directory comparison to a SARIF 2.1.0 document."""
|
|
52
|
+
sarif_results: list[dict[str, object]] = []
|
|
53
|
+
rules: dict[str, dict[str, object]] = {}
|
|
54
|
+
|
|
55
|
+
def add_rule(rule_id: str, name: str, description: str) -> None:
|
|
56
|
+
rules.setdefault(
|
|
57
|
+
rule_id,
|
|
58
|
+
{
|
|
59
|
+
"id": rule_id,
|
|
60
|
+
"name": name,
|
|
61
|
+
"shortDescription": {"text": description},
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def add_changes(sql_result: SQLDiffResult, file_path: str) -> None:
|
|
66
|
+
for change in sql_result.changes:
|
|
67
|
+
rule_id = f"sql-diff-{change.category.replace('_', '-')}"
|
|
68
|
+
add_rule(rule_id, change.category, f"SQL {change.category.replace('_', ' ')} change")
|
|
69
|
+
message = change.description
|
|
70
|
+
if change.before is not None:
|
|
71
|
+
message += f" Before: {change.before}"
|
|
72
|
+
if change.after is not None:
|
|
73
|
+
message += f" After: {change.after}"
|
|
74
|
+
sarif_results.append(
|
|
75
|
+
_sarif_result(
|
|
76
|
+
rule_id=rule_id,
|
|
77
|
+
risk=change.risk,
|
|
78
|
+
message=message,
|
|
79
|
+
path=file_path,
|
|
80
|
+
properties={
|
|
81
|
+
"category": change.category,
|
|
82
|
+
"before": change.before,
|
|
83
|
+
"after": change.after,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if isinstance(result, SQLDiffResult):
|
|
89
|
+
add_changes(result, path)
|
|
90
|
+
else:
|
|
91
|
+
for file_diff in result.files:
|
|
92
|
+
add_changes(file_diff.result, file_diff.path)
|
|
93
|
+
for unmatched, rule_id, action in (
|
|
94
|
+
(result.unmatched_before, "sql-file-removed", "removed"),
|
|
95
|
+
(result.unmatched_after, "sql-file-added", "added"),
|
|
96
|
+
):
|
|
97
|
+
if unmatched:
|
|
98
|
+
add_rule(rule_id, f"SQL file {action}", f"SQL file {action} between directories")
|
|
99
|
+
for file_path in unmatched:
|
|
100
|
+
sarif_results.append(
|
|
101
|
+
_sarif_result(
|
|
102
|
+
rule_id=rule_id,
|
|
103
|
+
risk="high",
|
|
104
|
+
message=f"SQL file {action}: {file_path}",
|
|
105
|
+
path=file_path,
|
|
106
|
+
properties={"change": action},
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"$schema": _SARIF_SCHEMA,
|
|
112
|
+
"version": "2.1.0",
|
|
113
|
+
"runs": [
|
|
114
|
+
{
|
|
115
|
+
"tool": {
|
|
116
|
+
"driver": {
|
|
117
|
+
"name": "SQL Diff Explainer",
|
|
118
|
+
"rules": list(rules.values()),
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"results": sarif_results,
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_html_report(
|
|
128
|
+
result: SQLDiffResult | SQLDirectoryDiffResult,
|
|
129
|
+
*,
|
|
130
|
+
path: str = "after.sql",
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Render a self-contained HTML report, escaping all SQL and path content."""
|
|
133
|
+
rows: list[str] = []
|
|
134
|
+
|
|
135
|
+
def add_changes(sql_result: SQLDiffResult, file_path: str) -> None:
|
|
136
|
+
for change in sql_result.changes:
|
|
137
|
+
rows.append(
|
|
138
|
+
"<tr>"
|
|
139
|
+
f"<td>{escape(file_path, quote=True)}</td>"
|
|
140
|
+
f'<td class="{escape(change.risk, quote=True)}">{escape(change.risk.upper())}</td>'
|
|
141
|
+
f"<td>{escape(change.description, quote=True)}</td>"
|
|
142
|
+
f"<td><pre>{escape(change.before or '', quote=True)}</pre></td>"
|
|
143
|
+
f"<td><pre>{escape(change.after or '', quote=True)}</pre></td>"
|
|
144
|
+
"</tr>"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if isinstance(result, SQLDiffResult):
|
|
148
|
+
risk = result.risk
|
|
149
|
+
summary = result.summary
|
|
150
|
+
add_changes(result, path)
|
|
151
|
+
else:
|
|
152
|
+
risk = result.risk
|
|
153
|
+
summary = result.summary
|
|
154
|
+
for file_diff in result.files:
|
|
155
|
+
add_changes(file_diff.result, file_diff.path)
|
|
156
|
+
for file_path in result.unmatched_before:
|
|
157
|
+
rows.append(
|
|
158
|
+
"<tr>"
|
|
159
|
+
f"<td>{escape(file_path, quote=True)}</td>"
|
|
160
|
+
'<td class="high">HIGH</td>'
|
|
161
|
+
"<td>SQL file removed.</td><td><pre>Present before</pre></td>"
|
|
162
|
+
"<td><pre></pre></td></tr>"
|
|
163
|
+
)
|
|
164
|
+
for file_path in result.unmatched_after:
|
|
165
|
+
rows.append(
|
|
166
|
+
"<tr>"
|
|
167
|
+
f"<td>{escape(file_path, quote=True)}</td>"
|
|
168
|
+
'<td class="high">HIGH</td>'
|
|
169
|
+
"<td>SQL file added.</td><td><pre></pre></td>"
|
|
170
|
+
"<td><pre>Present after</pre></td></tr>"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if not rows:
|
|
174
|
+
rows.append('<tr><td colspan="5">No structural SQL changes detected.</td></tr>')
|
|
175
|
+
return "\n".join(
|
|
176
|
+
[
|
|
177
|
+
"<!doctype html>",
|
|
178
|
+
'<html lang="en"><head><meta charset="utf-8">',
|
|
179
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
180
|
+
"<title>SQL Diff Explainer report</title>",
|
|
181
|
+
"<style>",
|
|
182
|
+
"body{font:15px system-ui,sans-serif;margin:2rem;color:#24292f}"
|
|
183
|
+
"table{border-collapse:collapse;width:100%}th,td{border:1px solid #d0d7de;"
|
|
184
|
+
"padding:.6rem;text-align:left;vertical-align:top}pre{white-space:pre-wrap;"
|
|
185
|
+
"overflow-wrap:anywhere;margin:0}.high{color:#cf222e}.medium{color:#9a6700}"
|
|
186
|
+
".low{color:#1a7f37}",
|
|
187
|
+
"</style></head><body>",
|
|
188
|
+
"<h1>SQL Diff Explainer</h1>",
|
|
189
|
+
f'<p class="{escape(risk, quote=True)}">Overall risk: '
|
|
190
|
+
f"{escape(risk.upper(), quote=True)}</p>",
|
|
191
|
+
f"<p>{escape(summary, quote=True)}</p>",
|
|
192
|
+
"<table><thead><tr><th>File</th><th>Risk</th><th>Change</th>"
|
|
193
|
+
"<th>Before</th><th>After</th></tr></thead><tbody>",
|
|
194
|
+
*rows,
|
|
195
|
+
"</tbody></table></body></html>",
|
|
196
|
+
"",
|
|
197
|
+
]
|
|
198
|
+
)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sql-diff-explainer
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Explain SQL schema and migration differences
|
|
5
|
+
Author: zmodz1010
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: sqlglot>=25.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# SQL Diff Explainer
|
|
16
|
+
|
|
17
|
+
**See what changed in a SQL query—and why it might matter.**
|
|
18
|
+
|
|
19
|
+
SQL Diff Explainer compares the structure of two `SELECT` queries and returns
|
|
20
|
+
the changes with a static risk signal, so reviews focus on query behavior rather
|
|
21
|
+
than formatting.
|
|
22
|
+
|
|
23
|
+
[](https://pypi.org/project/sql-diff-explainer/)
|
|
24
|
+
[](https://pypi.org/project/sql-diff-explainer/)
|
|
25
|
+
[](LICENSE)
|
|
26
|
+
|
|
27
|
+
## Contents
|
|
28
|
+
|
|
29
|
+
- [Quick start](#quick-start)
|
|
30
|
+
- [Command-line usage](#command-line-usage)
|
|
31
|
+
- [Directory comparison](#directory-comparison)
|
|
32
|
+
- [SARIF and HTML reports](#sarif-and-html-reports)
|
|
33
|
+
- [What it compares](#what-it-compares)
|
|
34
|
+
- [Use cases](#use-cases)
|
|
35
|
+
- [Scope and limitations](#scope-and-limitations)
|
|
36
|
+
- [Development](#development)
|
|
37
|
+
- [Contributing](#contributing)
|
|
38
|
+
- [License](#license)
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
Install the latest release from [PyPI](https://pypi.org/project/sql-diff-explainer/):
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
python -m pip install sql-diff-explainer
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Compare two queries from Python:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from sql_diff_explainer import explain_sql_change
|
|
52
|
+
|
|
53
|
+
before = """
|
|
54
|
+
SELECT c.customer_id, c.segment, o.order_id
|
|
55
|
+
FROM customers AS c
|
|
56
|
+
LEFT JOIN orders AS o ON c.customer_id = o.customer_id
|
|
57
|
+
"""
|
|
58
|
+
after = """
|
|
59
|
+
SELECT c.customer_id, c.segment, o.order_id
|
|
60
|
+
FROM customers AS c
|
|
61
|
+
INNER JOIN orders AS o ON c.customer_id = o.customer_id
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
result = explain_sql_change(before, after)
|
|
65
|
+
print(result.summary)
|
|
66
|
+
for change in result.changes:
|
|
67
|
+
print(
|
|
68
|
+
f"{change.category}: {change.before} -> {change.after} "
|
|
69
|
+
f"({change.risk} risk)"
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Output:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
1 SQL change(s) detected; overall risk is high.
|
|
77
|
+
join: LEFT JOIN orders AS o ON c.customer_id = o.customer_id -> INNER JOIN orders AS o ON c.customer_id = o.customer_id (high risk)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The change is classified as high risk because an `INNER JOIN` excludes
|
|
81
|
+
customers without a matching order, whereas a `LEFT JOIN` retains them. The
|
|
82
|
+
result is structured: inspect `result.risk_level` and `result.changes`, or call
|
|
83
|
+
`result.to_dict()` for JSON-ready data. Pass `dialect="postgres"` (or another
|
|
84
|
+
[sqlglot dialect](https://sqlglot.com/sqlglot/dialects.html)) for
|
|
85
|
+
dialect-specific SQL. Invalid SQL raises `SQLParseError`; non-`SELECT` input
|
|
86
|
+
raises `UnsupportedSQL`.
|
|
87
|
+
|
|
88
|
+
## Command-line usage
|
|
89
|
+
|
|
90
|
+
The CLI compares two SQL files and prints a human-readable report by default:
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
sql-diff-explainer examples/join_before.sql examples/join_after.sql
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Choose JSON output, set a parsing dialect, or make a risk threshold fail a CI
|
|
97
|
+
step:
|
|
98
|
+
|
|
99
|
+
```console
|
|
100
|
+
sql-diff-explainer before.sql after.sql --format json
|
|
101
|
+
sql-diff-explainer before.sql after.sql --dialect postgres
|
|
102
|
+
sql-diff-explainer before.sql after.sql --fail-on-risk medium
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--fail-on-risk` accepts `low`, `medium`, or `high`. It exits with status `1`
|
|
106
|
+
when detected changes meet or exceed the threshold; the text or JSON report is
|
|
107
|
+
still printed. No detected changes exit successfully. Input-file errors and
|
|
108
|
+
SQL parse/support errors exit with status `2`.
|
|
109
|
+
|
|
110
|
+
Try the runnable before/after pairs in [`examples/`](examples/):
|
|
111
|
+
|
|
112
|
+
```console
|
|
113
|
+
sql-diff-explainer examples/filter_before.sql examples/filter_after.sql --format json
|
|
114
|
+
sql-diff-explainer examples/aggregation_before.sql examples/aggregation_after.sql
|
|
115
|
+
sql-diff-explainer examples/distinct_before.sql examples/distinct_after.sql
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Directory comparison
|
|
119
|
+
|
|
120
|
+
Compare two directory trees recursively. SQL files are paired by their exact
|
|
121
|
+
relative path (with `/` separators); the `.sql` extension is matched
|
|
122
|
+
case-insensitively. Files present on only one side are reported as additions
|
|
123
|
+
or removals rather than parsed as empty SQL, and raise the aggregate risk to
|
|
124
|
+
high:
|
|
125
|
+
|
|
126
|
+
```console
|
|
127
|
+
sql-diff-explainer --compare-dirs models/main models/review --format json
|
|
128
|
+
sql-diff-explainer --compare-dirs models/main models/review --fail-on-risk high
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The JSON report includes each matched file's ordinary SQL diff, plus
|
|
132
|
+
`unmatched_before` and `unmatched_after` path lists. A missing or non-directory
|
|
133
|
+
input, unreadable file, or invalid SQL exits with status `2`.
|
|
134
|
+
|
|
135
|
+
The same operation is available in Python:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from sql_diff_explainer import compare_sql_directories
|
|
139
|
+
|
|
140
|
+
result = compare_sql_directories("models/main", "models/review")
|
|
141
|
+
print(result.to_dict())
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### SARIF and HTML reports
|
|
145
|
+
|
|
146
|
+
Emit SARIF 2.1.0 results for GitHub code scanning integrations, or generate a
|
|
147
|
+
self-contained HTML report that can be attached to a review:
|
|
148
|
+
|
|
149
|
+
```console
|
|
150
|
+
sql-diff-explainer before.sql after.sql --format sarif --output sql-diff.sarif
|
|
151
|
+
sql-diff-explainer --compare-dirs models/main models/review --format html --output review.html
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Both formats work with the existing `--fail-on-risk` threshold. SARIF maps
|
|
155
|
+
high, medium, and low risk to `error`, `warning`, and `note`; report output is
|
|
156
|
+
still written when the threshold causes exit status `1`. HTML escapes SQL text
|
|
157
|
+
and file paths. Python callers can use `to_sarif(result)` or
|
|
158
|
+
`render_html_report(result)` with either `SQLDiffResult` or
|
|
159
|
+
`SQLDirectoryDiffResult`.
|
|
160
|
+
|
|
161
|
+
## What it compares
|
|
162
|
+
|
|
163
|
+
The analyzer compares supported `SELECT` query features and reports additions,
|
|
164
|
+
removals, or changes with a low, medium, or high risk classification:
|
|
165
|
+
|
|
166
|
+
| Feature | Examples of changes |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| Projections | Selected columns and expressions |
|
|
169
|
+
| Joins | Join target, predicate, or type |
|
|
170
|
+
| Filters | `WHERE` predicates |
|
|
171
|
+
| Aggregation | Aggregate functions and `GROUP BY` expressions |
|
|
172
|
+
| Ordering and pagination | `ORDER BY`, `LIMIT`, and `OFFSET` |
|
|
173
|
+
| Duplicate handling | `DISTINCT` added or removed |
|
|
174
|
+
|
|
175
|
+
Join or filter changes can alter which rows qualify; grouping or aggregation
|
|
176
|
+
changes can change a metric's meaning or result grain; and pagination changes
|
|
177
|
+
can affect which rows downstream consumers receive.
|
|
178
|
+
|
|
179
|
+
## Use cases
|
|
180
|
+
|
|
181
|
+
- Review dbt models or warehouse SQL in a pull request.
|
|
182
|
+
- Spot join and filter changes that could alter row counts.
|
|
183
|
+
- Check grouping and aggregation changes that could shift metric grain.
|
|
184
|
+
- Add a lightweight risk threshold to a CI check.
|
|
185
|
+
- Explain generated SQL changes without treating formatting-only differences
|
|
186
|
+
as query changes.
|
|
187
|
+
|
|
188
|
+
## Scope and limitations
|
|
189
|
+
|
|
190
|
+
This is static analysis, not a SQL execution engine or correctness proof. It
|
|
191
|
+
does not connect to a database, validate schemas or permissions, estimate query
|
|
192
|
+
plans or cost, inspect table statistics or null rates, or decide whether a
|
|
193
|
+
business metric is correct. It compares supported query structure; it does not
|
|
194
|
+
analyze a multi-statement migration file or guarantee complete semantic
|
|
195
|
+
equivalence across SQL dialects. Risk levels are review signals, not
|
|
196
|
+
predictions. Pair the report with warehouse tests, representative row-count
|
|
197
|
+
checks, query-plan review, and domain-owner input.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
The package requires Python 3.10 or newer and uses a `src/` layout.
|
|
202
|
+
|
|
203
|
+
```console
|
|
204
|
+
python -m venv .venv
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Activate the environment, then install the development dependencies and run
|
|
208
|
+
the checks:
|
|
209
|
+
|
|
210
|
+
```console
|
|
211
|
+
# macOS / Linux
|
|
212
|
+
source .venv/bin/activate
|
|
213
|
+
|
|
214
|
+
# Windows PowerShell
|
|
215
|
+
.venv\Scripts\Activate.ps1
|
|
216
|
+
|
|
217
|
+
python -m pip install -e ".[dev]"
|
|
218
|
+
python -m pytest
|
|
219
|
+
ruff check .
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Contributing
|
|
223
|
+
|
|
224
|
+
Bug reports, focused improvements, and pull requests are welcome. Please
|
|
225
|
+
include or update tests for behavior changes and keep documentation aligned
|
|
226
|
+
with the supported API.
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
Distributed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
sql_diff_explainer/__init__.py,sha256=_Thcgi2aYlJnnAf1beLKPaVHpbEK9doHILFDliYjUsE,743
|
|
2
|
+
sql_diff_explainer/cli.py,sha256=W654_Apfe4o9nWAGlPDAvJs6nmSQZuu34RIlV0W4spc,4866
|
|
3
|
+
sql_diff_explainer/core.py,sha256=3Qx7vkPZUcKfN_u55ID0Ebr30zHDheE75xggtiYa904,17389
|
|
4
|
+
sql_diff_explainer/reports.py,sha256=jIY9lEa3xnj6Q1xojzB3N0S9t-AUAWzNlvRc4rFDQBo,7228
|
|
5
|
+
sql_diff_explainer-0.1.3.dist-info/METADATA,sha256=GwXOieoenCVZOQ1bkHaBVPmjF_VkR-bs4rZZJWTU06M,7795
|
|
6
|
+
sql_diff_explainer-0.1.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
sql_diff_explainer-0.1.3.dist-info/entry_points.txt,sha256=Pu2gG46es25JExzGO58plyLEsFedeCUM-TQnUeLpOlI,67
|
|
8
|
+
sql_diff_explainer-0.1.3.dist-info/licenses/LICENSE,sha256=OJ2eEuGmpMxVb7jSmuVgeDcVHCEOMBkZgLgTlnK5mQQ,1087
|
|
9
|
+
sql_diff_explainer-0.1.3.dist-info/RECORD,,
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: sql-diff-explainer
|
|
3
|
-
Version: 0.1.2
|
|
4
|
-
Summary: Explain SQL schema and migration differences
|
|
5
|
-
Author: zmodz1010
|
|
6
|
-
License: MIT
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.10
|
|
9
|
-
Requires-Dist: sqlglot>=25.0
|
|
10
|
-
Provides-Extra: dev
|
|
11
|
-
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
12
|
-
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
|
-
# SQL Diff Explainer
|
|
16
|
-
|
|
17
|
-
Explain the meaningful structural differences between two SQL `SELECT` queries.
|
|
18
|
-
|
|
19
|
-
The package uses [sqlglot](https://github.com/tobymao/sqlglot) behind an abstract
|
|
20
|
-
parser interface, so callers receive a stable result even if the parser
|
|
21
|
-
implementation changes. It reports changes to projections, joins, filters,
|
|
22
|
-
grouping, aggregation, ordering, pagination, and `DISTINCT`, with a static
|
|
23
|
-
low/medium/high risk classification.
|
|
24
|
-
|
|
25
|
-
The next patch release is **0.1.2**. It adds the CI-friendly CLI
|
|
26
|
-
`--fail-on-risk` threshold.
|
|
27
|
-
|
|
28
|
-
## Why use SQL Diff Explainer?
|
|
29
|
-
|
|
30
|
-
Use it in a pull request, migration review, or CI check when a textual SQL diff
|
|
31
|
-
does not explain the behavioral impact of a query change. It is especially
|
|
32
|
-
useful for data-engineering teams reviewing:
|
|
33
|
-
|
|
34
|
-
- dbt model changes where a join or filter can change row counts
|
|
35
|
-
- warehouse reports where grouping or aggregation changes the grain
|
|
36
|
-
- incremental pipeline queries where `DISTINCT`, `ORDER BY`, `LIMIT`, or
|
|
37
|
-
`OFFSET` changes the records consumed downstream
|
|
38
|
-
- generated SQL changes where normalized syntax is easier to compare than
|
|
39
|
-
formatting or alias changes
|
|
40
|
-
|
|
41
|
-
The result is a structured object, so a CI integration can fail or annotate a
|
|
42
|
-
review based on `result.risk_level`, inspect `result.changes`, or serialize
|
|
43
|
-
`result.to_dict()` as JSON.
|
|
44
|
-
|
|
45
|
-
## Real-life data-engineering examples
|
|
46
|
-
|
|
47
|
-
The `examples/` directory contains runnable before/after pairs. For example:
|
|
48
|
-
|
|
49
|
-
```powershell
|
|
50
|
-
sql-diff-explainer examples\join_before.sql examples\join_after.sql
|
|
51
|
-
sql-diff-explainer examples\filter_before.sql examples\filter_after.sql --format json
|
|
52
|
-
sql-diff-explainer examples\aggregation_before.sql examples\aggregation_after.sql
|
|
53
|
-
sql-diff-explainer examples\distinct_before.sql examples\distinct_after.sql
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
These scenarios demonstrate the changes reviewers most often need to discuss:
|
|
57
|
-
|
|
58
|
-
1. **Outer join to inner join:** changing `LEFT JOIN` to `INNER JOIN` can
|
|
59
|
-
exclude dimension or fact rows without a match, so the tool reports one
|
|
60
|
-
semantic join change at high risk.
|
|
61
|
-
2. **Added `WHERE` filter:** adding `WHERE order_status = 'completed'` narrows
|
|
62
|
-
the population and is reported as a filter change.
|
|
63
|
-
3. **Aggregation or grouping change:** changing `COUNT(*)` to `SUM(revenue)`,
|
|
64
|
-
or changing the `GROUP BY` grain from customer to customer and day, can
|
|
65
|
-
change metric meaning and row cardinality.
|
|
66
|
-
4. **`DISTINCT`, ordering, and pagination:** adding `DISTINCT` changes duplicate
|
|
67
|
-
handling; changing `ORDER BY` changes presentation or deterministic paging;
|
|
68
|
-
changing `LIMIT`/`OFFSET` changes which rows a downstream consumer receives.
|
|
69
|
-
|
|
70
|
-
Try the same comparisons from Python:
|
|
71
|
-
|
|
72
|
-
```python
|
|
73
|
-
from sql_diff_explainer import explain_sql_change
|
|
74
|
-
|
|
75
|
-
result = explain_sql_change(
|
|
76
|
-
"SELECT c.id FROM customers c LEFT JOIN orders o ON c.id = o.customer_id",
|
|
77
|
-
"SELECT c.id FROM customers c INNER JOIN orders o ON c.id = o.customer_id",
|
|
78
|
-
)
|
|
79
|
-
assert result.risk_level == "high"
|
|
80
|
-
for change in result.changes:
|
|
81
|
-
print(change.category, change.risk, change.description)
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
## What it catches—and what it cannot know
|
|
85
|
-
|
|
86
|
-
The analyzer parses both inputs with sqlglot and compares the supported
|
|
87
|
-
`SELECT` structure: projections, joins and join types, `WHERE`, `GROUP BY`,
|
|
88
|
-
aggregate functions, `ORDER BY`, `LIMIT`, `OFFSET`, and `DISTINCT`. It provides
|
|
89
|
-
static risk guidance; it does not execute SQL or connect to a warehouse.
|
|
90
|
-
|
|
91
|
-
Risk is therefore a useful review signal, not a correctness proof. The tool
|
|
92
|
-
cannot infer table statistics, actual null rates, constraints, query plans,
|
|
93
|
-
cost, freshness, permissions, dialect-specific UDF behavior, or whether a
|
|
94
|
-
business metric is correct. It also does not validate schema availability or
|
|
95
|
-
compare multiple statements in a migration file. Pair its output with
|
|
96
|
-
warehouse tests, representative row-count checks, query-plan review, and
|
|
97
|
-
domain-owner approval.
|
|
98
|
-
|
|
99
|
-
## Development
|
|
100
|
-
|
|
101
|
-
This project uses a `src/` layout and requires Python 3.10 or newer.
|
|
102
|
-
|
|
103
|
-
```powershell
|
|
104
|
-
python -m venv .venv
|
|
105
|
-
.venv\Scripts\Activate.ps1
|
|
106
|
-
python -m pip install -e ".[dev]"
|
|
107
|
-
pytest
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
## Library usage
|
|
111
|
-
|
|
112
|
-
```python
|
|
113
|
-
from sql_diff_explainer import explain_sql_change
|
|
114
|
-
|
|
115
|
-
result = explain_sql_change(
|
|
116
|
-
"SELECT department, COUNT(*) FROM employees GROUP BY department",
|
|
117
|
-
"SELECT department, COUNT(*) FROM employees WHERE active = TRUE GROUP BY department",
|
|
118
|
-
)
|
|
119
|
-
print(result.to_dict())
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
`explain_sql_change` raises `SQLParseError` for invalid SQL and `UnsupportedSQL`
|
|
123
|
-
for non-`SELECT` statements. Pass `dialect="postgres"` (or another sqlglot
|
|
124
|
-
dialect) when parsing dialect-specific syntax.
|
|
125
|
-
|
|
126
|
-
## CLI
|
|
127
|
-
|
|
128
|
-
```powershell
|
|
129
|
-
sql-diff-explainer before.sql after.sql
|
|
130
|
-
sql-diff-explainer before.sql after.sql --dialect postgres --format json
|
|
131
|
-
sql-diff-explainer before.sql after.sql --fail-on-risk medium
|
|
132
|
-
sql-diff-explainer before.sql after.sql --format json --fail-on-risk high
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
`--fail-on-risk` accepts `low`, `medium`, or `high` and exits with status 1 when
|
|
136
|
-
detected overall risk meets or exceeds the chosen threshold. It does not suppress
|
|
137
|
-
the regular text or JSON report. Comparisons with no detected changes exit
|
|
138
|
-
successfully even though their baseline report risk is `low`. JSON output is
|
|
139
|
-
intended for CI and integrations; text output is optimized for human review.
|
|
140
|
-
|
|
141
|
-
Exit codes:
|
|
142
|
-
|
|
143
|
-
- `0`: the comparison succeeded and no detected change met the configured
|
|
144
|
-
threshold (or `--fail-on-risk` was not set).
|
|
145
|
-
- `1`: detected overall risk met or exceeded `--fail-on-risk`.
|
|
146
|
-
- `2`: an input file could not be read or an expected SQL parse/support error
|
|
147
|
-
occurred.
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
sql_diff_explainer/__init__.py,sha256=P-jgWkLNkeuM1kcLChW7rSYWn6EpqOY8K0qMvALxqyA,488
|
|
2
|
-
sql_diff_explainer/cli.py,sha256=XNYIVozEXbjJa_1PXX6ZYmgqxmqHkLHAYNuKaovMCJI,2091
|
|
3
|
-
sql_diff_explainer/core.py,sha256=P9NrPjHGivx-sGlyIyVi7V41x4R2lH3ETC4y8opz_yQ,13265
|
|
4
|
-
sql_diff_explainer-0.1.2.dist-info/METADATA,sha256=0UOgvJOzI3j8gbI64wGVOq1YkGKkNNajipAEGyYJjL4,5863
|
|
5
|
-
sql_diff_explainer-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
-
sql_diff_explainer-0.1.2.dist-info/entry_points.txt,sha256=Pu2gG46es25JExzGO58plyLEsFedeCUM-TQnUeLpOlI,67
|
|
7
|
-
sql_diff_explainer-0.1.2.dist-info/licenses/LICENSE,sha256=OJ2eEuGmpMxVb7jSmuVgeDcVHCEOMBkZgLgTlnK5mQQ,1087
|
|
8
|
-
sql_diff_explainer-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|