sql-diff-explainer 0.1.3__tar.gz → 0.1.4__tar.gz
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-0.1.3 → sql_diff_explainer-0.1.4}/PKG-INFO +56 -2
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/README.md +55 -1
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/pyproject.toml +1 -1
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/src/sql_diff_explainer/__init__.py +3 -1
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/src/sql_diff_explainer/cli.py +25 -6
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/src/sql_diff_explainer/core.py +144 -2
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/src/sql_diff_explainer/reports.py +21 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/tests/test_core.py +144 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/.github/workflows/ci.yml +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/.gitignore +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/LICENSE +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/after.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/aggregation_after.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/aggregation_before.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/before.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/distinct_after.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/distinct_before.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/filter_after.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/filter_before.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/join_after.sql +0 -0
- {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/join_before.sql +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sql-diff-explainer
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Explain SQL schema and migration differences
|
|
5
5
|
Author: zmodz1010
|
|
6
6
|
License: MIT
|
|
@@ -20,7 +20,7 @@ SQL Diff Explainer compares the structure of two `SELECT` queries and returns
|
|
|
20
20
|
the changes with a static risk signal, so reviews focus on query behavior rather
|
|
21
21
|
than formatting.
|
|
22
22
|
|
|
23
|
-
[](https://pypi.org/project/sql-diff-explainer/)
|
|
23
|
+
[](https://pypi.org/project/sql-diff-explainer/)
|
|
24
24
|
[](https://pypi.org/project/sql-diff-explainer/)
|
|
25
25
|
[](LICENSE)
|
|
26
26
|
|
|
@@ -29,6 +29,7 @@ than formatting.
|
|
|
29
29
|
- [Quick start](#quick-start)
|
|
30
30
|
- [Command-line usage](#command-line-usage)
|
|
31
31
|
- [Directory comparison](#directory-comparison)
|
|
32
|
+
- [Git-aware pull-request comparison](#git-aware-pull-request-comparison)
|
|
32
33
|
- [SARIF and HTML reports](#sarif-and-html-reports)
|
|
33
34
|
- [What it compares](#what-it-compares)
|
|
34
35
|
- [Use cases](#use-cases)
|
|
@@ -141,6 +142,59 @@ result = compare_sql_directories("models/main", "models/review")
|
|
|
141
142
|
print(result.to_dict())
|
|
142
143
|
```
|
|
143
144
|
|
|
145
|
+
### Git-aware pull-request comparison
|
|
146
|
+
|
|
147
|
+
Compare SQL paths Git reports as changed between a base ref and the current
|
|
148
|
+
worktree. Modified files and SQL-to-SQL renames are compared semantically;
|
|
149
|
+
added or removed SQL files are reported explicitly as high risk. Untracked
|
|
150
|
+
files are not included. The base can be a branch, tag, or commit:
|
|
151
|
+
|
|
152
|
+
```console
|
|
153
|
+
sql-diff-explainer --git-base origin/main --format json
|
|
154
|
+
sql-diff-explainer --git-base "$GITHUB_BASE_REF" --format sarif --output sql-diff.sarif
|
|
155
|
+
sql-diff-explainer --git-base origin/main --fail-on-risk high
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
For GitHub Actions, fetch the base history and use the pull request's base
|
|
159
|
+
commit. This example checks out the pull-request worktree, installs this
|
|
160
|
+
repository's source (so the CLI includes the feature), emits SARIF, and fails
|
|
161
|
+
the job on high-risk changes:
|
|
162
|
+
|
|
163
|
+
```yaml
|
|
164
|
+
name: SQL review
|
|
165
|
+
on:
|
|
166
|
+
pull_request:
|
|
167
|
+
|
|
168
|
+
jobs:
|
|
169
|
+
sql-diff:
|
|
170
|
+
runs-on: ubuntu-latest
|
|
171
|
+
steps:
|
|
172
|
+
- uses: actions/checkout@v4
|
|
173
|
+
with:
|
|
174
|
+
fetch-depth: 0
|
|
175
|
+
- uses: actions/setup-python@v5
|
|
176
|
+
with:
|
|
177
|
+
python-version: "3.12"
|
|
178
|
+
- run: python -m pip install .
|
|
179
|
+
- name: Analyze changed SQL
|
|
180
|
+
run: >-
|
|
181
|
+
sql-diff-explainer
|
|
182
|
+
--git-base "${{ github.event.pull_request.base.sha }}"
|
|
183
|
+
--format sarif
|
|
184
|
+
--output sql-diff.sarif
|
|
185
|
+
--fail-on-risk high
|
|
186
|
+
- name: Upload SQL findings
|
|
187
|
+
if: always() && hashFiles('sql-diff.sarif') != ''
|
|
188
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
189
|
+
with:
|
|
190
|
+
sarif_file: sql-diff.sarif
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
An unresolved base ref, Git failure, unreadable SQL, or invalid SQL exits with
|
|
194
|
+
status `2` and a diagnostic. Renames appear in every report; a rename does not
|
|
195
|
+
raise risk by itself, while semantic changes in its content retain their
|
|
196
|
+
normal risk level.
|
|
197
|
+
|
|
144
198
|
### SARIF and HTML reports
|
|
145
199
|
|
|
146
200
|
Emit SARIF 2.1.0 results for GitHub code scanning integrations, or generate a
|
|
@@ -6,7 +6,7 @@ SQL Diff Explainer compares the structure of two `SELECT` queries and returns
|
|
|
6
6
|
the changes with a static risk signal, so reviews focus on query behavior rather
|
|
7
7
|
than formatting.
|
|
8
8
|
|
|
9
|
-
[](https://pypi.org/project/sql-diff-explainer/)
|
|
9
|
+
[](https://pypi.org/project/sql-diff-explainer/)
|
|
10
10
|
[](https://pypi.org/project/sql-diff-explainer/)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|
|
|
@@ -15,6 +15,7 @@ than formatting.
|
|
|
15
15
|
- [Quick start](#quick-start)
|
|
16
16
|
- [Command-line usage](#command-line-usage)
|
|
17
17
|
- [Directory comparison](#directory-comparison)
|
|
18
|
+
- [Git-aware pull-request comparison](#git-aware-pull-request-comparison)
|
|
18
19
|
- [SARIF and HTML reports](#sarif-and-html-reports)
|
|
19
20
|
- [What it compares](#what-it-compares)
|
|
20
21
|
- [Use cases](#use-cases)
|
|
@@ -127,6 +128,59 @@ result = compare_sql_directories("models/main", "models/review")
|
|
|
127
128
|
print(result.to_dict())
|
|
128
129
|
```
|
|
129
130
|
|
|
131
|
+
### Git-aware pull-request comparison
|
|
132
|
+
|
|
133
|
+
Compare SQL paths Git reports as changed between a base ref and the current
|
|
134
|
+
worktree. Modified files and SQL-to-SQL renames are compared semantically;
|
|
135
|
+
added or removed SQL files are reported explicitly as high risk. Untracked
|
|
136
|
+
files are not included. The base can be a branch, tag, or commit:
|
|
137
|
+
|
|
138
|
+
```console
|
|
139
|
+
sql-diff-explainer --git-base origin/main --format json
|
|
140
|
+
sql-diff-explainer --git-base "$GITHUB_BASE_REF" --format sarif --output sql-diff.sarif
|
|
141
|
+
sql-diff-explainer --git-base origin/main --fail-on-risk high
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
For GitHub Actions, fetch the base history and use the pull request's base
|
|
145
|
+
commit. This example checks out the pull-request worktree, installs this
|
|
146
|
+
repository's source (so the CLI includes the feature), emits SARIF, and fails
|
|
147
|
+
the job on high-risk changes:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
name: SQL review
|
|
151
|
+
on:
|
|
152
|
+
pull_request:
|
|
153
|
+
|
|
154
|
+
jobs:
|
|
155
|
+
sql-diff:
|
|
156
|
+
runs-on: ubuntu-latest
|
|
157
|
+
steps:
|
|
158
|
+
- uses: actions/checkout@v4
|
|
159
|
+
with:
|
|
160
|
+
fetch-depth: 0
|
|
161
|
+
- uses: actions/setup-python@v5
|
|
162
|
+
with:
|
|
163
|
+
python-version: "3.12"
|
|
164
|
+
- run: python -m pip install .
|
|
165
|
+
- name: Analyze changed SQL
|
|
166
|
+
run: >-
|
|
167
|
+
sql-diff-explainer
|
|
168
|
+
--git-base "${{ github.event.pull_request.base.sha }}"
|
|
169
|
+
--format sarif
|
|
170
|
+
--output sql-diff.sarif
|
|
171
|
+
--fail-on-risk high
|
|
172
|
+
- name: Upload SQL findings
|
|
173
|
+
if: always() && hashFiles('sql-diff.sarif') != ''
|
|
174
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
175
|
+
with:
|
|
176
|
+
sarif_file: sql-diff.sarif
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
An unresolved base ref, Git failure, unreadable SQL, or invalid SQL exits with
|
|
180
|
+
status `2` and a diagnostic. Renames appear in every report; a rename does not
|
|
181
|
+
raise risk by itself, while semantic changes in its content retain their
|
|
182
|
+
normal risk level.
|
|
183
|
+
|
|
130
184
|
### SARIF and HTML reports
|
|
131
185
|
|
|
132
186
|
Emit SARIF 2.1.0 results for GitHub code scanning integrations, or generate a
|
|
@@ -11,6 +11,7 @@ from .core import (
|
|
|
11
11
|
SQLParser,
|
|
12
12
|
UnsupportedSQL,
|
|
13
13
|
compare_sql_directories,
|
|
14
|
+
compare_sql_git,
|
|
14
15
|
explain_diff,
|
|
15
16
|
explain_sql_change,
|
|
16
17
|
)
|
|
@@ -27,9 +28,10 @@ __all__ = [
|
|
|
27
28
|
"SqlglotParser",
|
|
28
29
|
"UnsupportedSQL",
|
|
29
30
|
"compare_sql_directories",
|
|
31
|
+
"compare_sql_git",
|
|
30
32
|
"explain_diff",
|
|
31
33
|
"explain_sql_change",
|
|
32
34
|
"render_html_report",
|
|
33
35
|
"to_sarif",
|
|
34
36
|
]
|
|
35
|
-
__version__ = "0.1.
|
|
37
|
+
__version__ = "0.1.4"
|
|
@@ -10,6 +10,7 @@ from .core import (
|
|
|
10
10
|
SQLDiffResult,
|
|
11
11
|
SQLDirectoryDiffResult,
|
|
12
12
|
compare_sql_directories,
|
|
13
|
+
compare_sql_git,
|
|
13
14
|
explain_sql_change,
|
|
14
15
|
)
|
|
15
16
|
from .reports import render_html_report, to_sarif
|
|
@@ -31,6 +32,11 @@ def main() -> int:
|
|
|
31
32
|
metavar=("BEFORE_DIR", "AFTER_DIR"),
|
|
32
33
|
help="Recursively compare matching .sql files in two directories",
|
|
33
34
|
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--git-base",
|
|
37
|
+
metavar="REF",
|
|
38
|
+
help="Compare Git-reported .sql changes from REF to the current worktree",
|
|
39
|
+
)
|
|
34
40
|
parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
|
|
35
41
|
parser.add_argument(
|
|
36
42
|
"--format",
|
|
@@ -47,13 +53,22 @@ def main() -> int:
|
|
|
47
53
|
args = parser.parse_args()
|
|
48
54
|
|
|
49
55
|
directory_mode = args.compare_dirs is not None
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if
|
|
53
|
-
parser.error("
|
|
56
|
+
git_mode = args.git_base is not None
|
|
57
|
+
selected_modes = sum((directory_mode, git_mode))
|
|
58
|
+
if selected_modes and (args.before is not None or args.after is not None):
|
|
59
|
+
parser.error("Use only one of --git-base, --compare-dirs, or the two SQL file arguments")
|
|
60
|
+
if selected_modes > 1:
|
|
61
|
+
parser.error("Use either --git-base or --compare-dirs, not both")
|
|
62
|
+
if not selected_modes and (args.before is None or args.after is None):
|
|
63
|
+
parser.error(
|
|
64
|
+
"Provide two SQL files, use --compare-dirs BEFORE_DIR AFTER_DIR, or use --git-base REF"
|
|
65
|
+
)
|
|
54
66
|
|
|
55
67
|
try:
|
|
56
|
-
if
|
|
68
|
+
if git_mode:
|
|
69
|
+
result = compare_sql_git(args.git_base, dialect=args.dialect)
|
|
70
|
+
report_path = ""
|
|
71
|
+
elif directory_mode:
|
|
57
72
|
result: SQLDiffResult | SQLDirectoryDiffResult = compare_sql_directories(
|
|
58
73
|
args.compare_dirs[0],
|
|
59
74
|
args.compare_dirs[1],
|
|
@@ -87,6 +102,10 @@ def main() -> int:
|
|
|
87
102
|
file_results = [(item.path, item.result) for item in result.files]
|
|
88
103
|
lines.extend(f"Removed SQL file: {path}" for path in result.unmatched_before)
|
|
89
104
|
lines.extend(f"Added SQL file: {path}" for path in result.unmatched_after)
|
|
105
|
+
lines.extend(
|
|
106
|
+
f"Renamed SQL file: {before} -> {after}"
|
|
107
|
+
for before, after in result.renamed_files
|
|
108
|
+
)
|
|
90
109
|
for file_path, file_result in file_results:
|
|
91
110
|
if file_path and file_result.changes:
|
|
92
111
|
lines.append(f"{file_path}:")
|
|
@@ -112,7 +131,7 @@ def main() -> int:
|
|
|
112
131
|
bool(result.changes)
|
|
113
132
|
if isinstance(result, SQLDiffResult)
|
|
114
133
|
else any(item.result.changes for item in result.files)
|
|
115
|
-
or bool(result.unmatched_before or result.unmatched_after)
|
|
134
|
+
or bool(result.unmatched_before or result.unmatched_after or result.renamed_files)
|
|
116
135
|
)
|
|
117
136
|
if (
|
|
118
137
|
args.fail_on_risk is not None
|
|
@@ -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
|
|
|
@@ -122,6 +124,7 @@ class SQLDirectoryDiffResult:
|
|
|
122
124
|
files: list[SQLFileDiff] = field(default_factory=list)
|
|
123
125
|
unmatched_before: list[str] = field(default_factory=list)
|
|
124
126
|
unmatched_after: list[str] = field(default_factory=list)
|
|
127
|
+
renamed_files: list[tuple[str, str]] = field(default_factory=list)
|
|
125
128
|
|
|
126
129
|
@property
|
|
127
130
|
def risk(self) -> str:
|
|
@@ -143,8 +146,13 @@ class SQLDirectoryDiffResult:
|
|
|
143
146
|
def summary(self) -> str:
|
|
144
147
|
changed = sum(bool(item.result.changes) for item in self.files)
|
|
145
148
|
unmatched = len(self.unmatched_before) + len(self.unmatched_after)
|
|
146
|
-
if not changed and not unmatched:
|
|
149
|
+
if not changed and not unmatched and not self.renamed_files:
|
|
147
150
|
return f"No structural SQL changes detected across {len(self.files)} file(s)."
|
|
151
|
+
if self.renamed_files:
|
|
152
|
+
return (
|
|
153
|
+
f"{changed} changed SQL file(s), {unmatched} unmatched SQL file(s), "
|
|
154
|
+
f"{len(self.renamed_files)} renamed SQL file(s); overall risk is {self.risk}."
|
|
155
|
+
)
|
|
148
156
|
return (
|
|
149
157
|
f"{changed} changed SQL file(s), {unmatched} unmatched SQL file(s); "
|
|
150
158
|
f"overall risk is {self.risk}."
|
|
@@ -158,6 +166,9 @@ class SQLDirectoryDiffResult:
|
|
|
158
166
|
"files": [item.to_dict() for item in self.files],
|
|
159
167
|
"unmatched_before": list(self.unmatched_before),
|
|
160
168
|
"unmatched_after": list(self.unmatched_after),
|
|
169
|
+
"renamed_files": [
|
|
170
|
+
{"before": before, "after": after} for before, after in self.renamed_files
|
|
171
|
+
],
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
|
|
@@ -461,6 +472,137 @@ def compare_sql_directories(
|
|
|
461
472
|
)
|
|
462
473
|
|
|
463
474
|
|
|
475
|
+
def compare_sql_git(
|
|
476
|
+
base_ref: str,
|
|
477
|
+
*,
|
|
478
|
+
cwd: str | Path = ".",
|
|
479
|
+
dialect: str | None = None,
|
|
480
|
+
parser: SQLParser | None = None,
|
|
481
|
+
) -> SQLDirectoryDiffResult:
|
|
482
|
+
"""Compare Git-reported SQL changes from a base commit to the current worktree."""
|
|
483
|
+
working_dir = Path(cwd)
|
|
484
|
+
|
|
485
|
+
def run_git(*arguments: str) -> bytes:
|
|
486
|
+
try:
|
|
487
|
+
completed = subprocess.run(
|
|
488
|
+
["git", *arguments],
|
|
489
|
+
cwd=working_dir,
|
|
490
|
+
capture_output=True,
|
|
491
|
+
check=False,
|
|
492
|
+
)
|
|
493
|
+
except OSError as exc:
|
|
494
|
+
raise SQLDiffError(f"Unable to run git: {exc}") from exc
|
|
495
|
+
if completed.returncode:
|
|
496
|
+
message = os.fsdecode(completed.stderr).strip()
|
|
497
|
+
raise SQLDiffError(f"Git command failed: {message or 'unknown git error'}")
|
|
498
|
+
return completed.stdout
|
|
499
|
+
|
|
500
|
+
root_output = run_git("rev-parse", "--show-toplevel").removesuffix(b"\n").removesuffix(b"\r")
|
|
501
|
+
root = Path(os.fsdecode(root_output))
|
|
502
|
+
commit = os.fsdecode(
|
|
503
|
+
run_git("rev-parse", "--verify", "--end-of-options", f"{base_ref}^{{commit}}")
|
|
504
|
+
).strip()
|
|
505
|
+
if not commit:
|
|
506
|
+
raise SQLDiffError(f"Git base ref did not resolve to a commit: {base_ref}")
|
|
507
|
+
|
|
508
|
+
status_fields = run_git(
|
|
509
|
+
"diff", "--name-status", "-z", "--find-renames", commit, "--"
|
|
510
|
+
).split(b"\0")
|
|
511
|
+
if status_fields and status_fields[-1] == b"":
|
|
512
|
+
status_fields.pop()
|
|
513
|
+
|
|
514
|
+
changed: list[tuple[str, str, str | None]] = []
|
|
515
|
+
index = 0
|
|
516
|
+
while index < len(status_fields):
|
|
517
|
+
status = os.fsdecode(status_fields[index])
|
|
518
|
+
index += 1
|
|
519
|
+
path_count = 2 if status.startswith(("R", "C")) else 1
|
|
520
|
+
if index + path_count > len(status_fields):
|
|
521
|
+
raise SQLDiffError("Git returned an incomplete file change record")
|
|
522
|
+
paths = [os.fsdecode(item) for item in status_fields[index : index + path_count]]
|
|
523
|
+
index += path_count
|
|
524
|
+
if path_count == 2:
|
|
525
|
+
changed.append((status[0], paths[0], paths[1]))
|
|
526
|
+
else:
|
|
527
|
+
changed.append(
|
|
528
|
+
(status[0], paths[0], None if status.startswith("D") else paths[0])
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
def validate_repo_path(path: str) -> None:
|
|
532
|
+
candidate = PurePosixPath(path)
|
|
533
|
+
if candidate.is_absolute() or "\\" in path or ".." in candidate.parts:
|
|
534
|
+
raise SQLDiffError(f"Unsafe path returned by git: {path!r}")
|
|
535
|
+
|
|
536
|
+
def read_base(path: str) -> str:
|
|
537
|
+
validate_repo_path(path)
|
|
538
|
+
try:
|
|
539
|
+
return run_git("cat-file", "blob", f"{commit}:{path}").decode("utf-8")
|
|
540
|
+
except UnicodeDecodeError as exc:
|
|
541
|
+
raise SQLDiffError(
|
|
542
|
+
f"Unable to read SQL file as UTF-8 from Git base ({path}): {exc}"
|
|
543
|
+
) from exc
|
|
544
|
+
except SQLDiffError as exc:
|
|
545
|
+
raise SQLDiffError(f"Unable to read SQL file from Git base ({path}): {exc}") from exc
|
|
546
|
+
|
|
547
|
+
def read_worktree(path: str) -> str:
|
|
548
|
+
validate_repo_path(path)
|
|
549
|
+
file_path = root.joinpath(*PurePosixPath(path).parts)
|
|
550
|
+
try:
|
|
551
|
+
resolved = file_path.resolve(strict=True)
|
|
552
|
+
if not resolved.is_relative_to(root.resolve()):
|
|
553
|
+
raise SQLDiffError(f"SQL file path resolves outside the Git repository: {path}")
|
|
554
|
+
if not resolved.is_file():
|
|
555
|
+
raise SQLDiffError(f"SQL path is not a file in the worktree: {path}")
|
|
556
|
+
return resolved.read_text(encoding="utf-8")
|
|
557
|
+
except UnicodeDecodeError as exc:
|
|
558
|
+
raise SQLDiffError(f"Unable to read SQL file as UTF-8 ({path}): {exc}") from exc
|
|
559
|
+
except OSError as exc:
|
|
560
|
+
raise SQLDiffError(f"Unable to read SQL file from worktree ({path}): {exc}") from exc
|
|
561
|
+
|
|
562
|
+
def is_sql(path: str) -> bool:
|
|
563
|
+
return PurePosixPath(path).suffix.lower() == ".sql"
|
|
564
|
+
|
|
565
|
+
files: list[SQLFileDiff] = []
|
|
566
|
+
removed: list[str] = []
|
|
567
|
+
added: list[str] = []
|
|
568
|
+
renamed: list[tuple[str, str]] = []
|
|
569
|
+
for status, before_path, after_path in changed:
|
|
570
|
+
if after_path is None:
|
|
571
|
+
if is_sql(before_path):
|
|
572
|
+
removed.append(before_path)
|
|
573
|
+
continue
|
|
574
|
+
|
|
575
|
+
before_is_sql = is_sql(before_path)
|
|
576
|
+
after_is_sql = is_sql(after_path)
|
|
577
|
+
if status == "A":
|
|
578
|
+
if after_is_sql:
|
|
579
|
+
added.append(after_path)
|
|
580
|
+
continue
|
|
581
|
+
if before_is_sql and after_is_sql:
|
|
582
|
+
if before_path != after_path:
|
|
583
|
+
renamed.append((before_path, after_path))
|
|
584
|
+
files.append(
|
|
585
|
+
SQLFileDiff(
|
|
586
|
+
after_path,
|
|
587
|
+
explain_sql_change(
|
|
588
|
+
read_base(before_path), read_worktree(after_path),
|
|
589
|
+
dialect=dialect, parser=parser,
|
|
590
|
+
),
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
elif before_is_sql:
|
|
594
|
+
removed.append(before_path)
|
|
595
|
+
elif after_is_sql:
|
|
596
|
+
added.append(after_path)
|
|
597
|
+
|
|
598
|
+
return SQLDirectoryDiffResult(
|
|
599
|
+
files=sorted(files, key=lambda item: item.path),
|
|
600
|
+
unmatched_before=sorted(removed),
|
|
601
|
+
unmatched_after=sorted(added),
|
|
602
|
+
renamed_files=sorted(renamed),
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
464
606
|
def explain_diff(before: str, after: str) -> str:
|
|
465
607
|
"""Return a readable unified diff for two SQL documents."""
|
|
466
608
|
lines = unified_diff(
|
|
@@ -106,6 +106,18 @@ def to_sarif(
|
|
|
106
106
|
properties={"change": action},
|
|
107
107
|
)
|
|
108
108
|
)
|
|
109
|
+
if result.renamed_files:
|
|
110
|
+
add_rule("sql-file-renamed", "SQL file renamed", "SQL file renamed in the worktree")
|
|
111
|
+
for before_path, after_path in result.renamed_files:
|
|
112
|
+
sarif_results.append(
|
|
113
|
+
_sarif_result(
|
|
114
|
+
rule_id="sql-file-renamed",
|
|
115
|
+
risk="low",
|
|
116
|
+
message=f"SQL file renamed: {before_path} -> {after_path}",
|
|
117
|
+
path=after_path,
|
|
118
|
+
properties={"change": "renamed", "before": before_path, "after": after_path},
|
|
119
|
+
)
|
|
120
|
+
)
|
|
109
121
|
|
|
110
122
|
return {
|
|
111
123
|
"$schema": _SARIF_SCHEMA,
|
|
@@ -169,6 +181,15 @@ def render_html_report(
|
|
|
169
181
|
"<td>SQL file added.</td><td><pre></pre></td>"
|
|
170
182
|
"<td><pre>Present after</pre></td></tr>"
|
|
171
183
|
)
|
|
184
|
+
for before_path, after_path in result.renamed_files:
|
|
185
|
+
rows.append(
|
|
186
|
+
"<tr>"
|
|
187
|
+
f"<td>{escape(after_path, quote=True)}</td>"
|
|
188
|
+
'<td class="low">LOW</td>'
|
|
189
|
+
f"<td>SQL file renamed from {escape(before_path, quote=True)}.</td>"
|
|
190
|
+
"<td><pre>Present before</pre></td>"
|
|
191
|
+
"<td><pre>Present after</pre></td></tr>"
|
|
192
|
+
)
|
|
172
193
|
|
|
173
194
|
if not rows:
|
|
174
195
|
rows.append('<tr><td colspan="5">No structural SQL changes detected.</td></tr>')
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
2
4
|
import sys
|
|
3
5
|
|
|
4
6
|
import pytest
|
|
@@ -8,6 +10,7 @@ from sql_diff_explainer import (
|
|
|
8
10
|
SQLParseError,
|
|
9
11
|
UnsupportedSQL,
|
|
10
12
|
compare_sql_directories,
|
|
13
|
+
compare_sql_git,
|
|
11
14
|
explain_diff,
|
|
12
15
|
explain_sql_change,
|
|
13
16
|
render_html_report,
|
|
@@ -16,6 +19,29 @@ from sql_diff_explainer import (
|
|
|
16
19
|
from sql_diff_explainer.cli import main as cli_main
|
|
17
20
|
|
|
18
21
|
|
|
22
|
+
def _init_git_repo(path):
|
|
23
|
+
if shutil.which("git") is None:
|
|
24
|
+
pytest.skip("git executable is not available")
|
|
25
|
+
path.mkdir()
|
|
26
|
+
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
|
|
27
|
+
subprocess.run(
|
|
28
|
+
["git", "config", "user.name", "SQL Diff Tests"],
|
|
29
|
+
cwd=path,
|
|
30
|
+
check=True,
|
|
31
|
+
)
|
|
32
|
+
subprocess.run(
|
|
33
|
+
["git", "config", "user.email", "sql-diff-tests@example.invalid"],
|
|
34
|
+
cwd=path,
|
|
35
|
+
check=True,
|
|
36
|
+
)
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _git_commit(path, message):
|
|
41
|
+
subprocess.run(["git", "add", "-A"], cwd=path, check=True)
|
|
42
|
+
subprocess.run(["git", "commit", "-qm", message], cwd=path, check=True)
|
|
43
|
+
|
|
44
|
+
|
|
19
45
|
def test_explain_diff_returns_unified_diff() -> None:
|
|
20
46
|
result = explain_diff("CREATE TABLE users;", "CREATE TABLE accounts;")
|
|
21
47
|
|
|
@@ -282,6 +308,124 @@ def test_compare_sql_directories_rejects_invalid_utf8(tmp_path) -> None:
|
|
|
282
308
|
compare_sql_directories(before_dir, after_dir)
|
|
283
309
|
|
|
284
310
|
|
|
311
|
+
def test_compare_sql_git_handles_modifications_additions_deletions_and_renames(tmp_path) -> None:
|
|
312
|
+
repo = _init_git_repo(tmp_path / "repo")
|
|
313
|
+
models = repo / "models"
|
|
314
|
+
models.mkdir()
|
|
315
|
+
(models / "changed; &.sql").write_text("SELECT id FROM users", encoding="utf-8")
|
|
316
|
+
(repo / "removed.sql").write_text("SELECT id FROM users", encoding="utf-8")
|
|
317
|
+
(repo / "old name.sql").write_text(
|
|
318
|
+
"SELECT\n id,\n name,\n email,\n created_at\nFROM users\n"
|
|
319
|
+
"WHERE active = TRUE\nORDER BY id",
|
|
320
|
+
encoding="utf-8",
|
|
321
|
+
)
|
|
322
|
+
_git_commit(repo, "base")
|
|
323
|
+
base = subprocess.run(
|
|
324
|
+
["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True
|
|
325
|
+
).stdout.strip()
|
|
326
|
+
|
|
327
|
+
(models / "changed; &.sql").write_text("SELECT name FROM users", encoding="utf-8")
|
|
328
|
+
(repo / "removed.sql").unlink()
|
|
329
|
+
(repo / "old name.sql").rename(repo / "new name.sql")
|
|
330
|
+
(repo / "new name.sql").write_text(
|
|
331
|
+
"SELECT\n id,\n full_name,\n email,\n created_at\nFROM users\n"
|
|
332
|
+
"WHERE active = TRUE\nORDER BY id",
|
|
333
|
+
encoding="utf-8",
|
|
334
|
+
)
|
|
335
|
+
(repo / "added; &.SQL").write_text("SELECT id FROM orders", encoding="utf-8")
|
|
336
|
+
(repo / "untracked.sql").write_text("SELECT 1", encoding="utf-8")
|
|
337
|
+
subprocess.run(
|
|
338
|
+
[
|
|
339
|
+
"git",
|
|
340
|
+
"add",
|
|
341
|
+
"-A",
|
|
342
|
+
"--",
|
|
343
|
+
"models/changed; &.sql",
|
|
344
|
+
"removed.sql",
|
|
345
|
+
"old name.sql",
|
|
346
|
+
"new name.sql",
|
|
347
|
+
"added; &.SQL",
|
|
348
|
+
],
|
|
349
|
+
cwd=repo,
|
|
350
|
+
check=True,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
result = compare_sql_git(base, cwd=repo)
|
|
354
|
+
|
|
355
|
+
assert [item.path for item in result.files] == ["models/changed; &.sql", "new name.sql"]
|
|
356
|
+
assert all(item.result.changes for item in result.files)
|
|
357
|
+
assert result.unmatched_before == ["removed.sql"]
|
|
358
|
+
assert result.unmatched_after == ["added; &.SQL"]
|
|
359
|
+
assert result.renamed_files == [("old name.sql", "new name.sql")]
|
|
360
|
+
assert result.risk == "high"
|
|
361
|
+
report = result.to_dict()
|
|
362
|
+
assert "untracked.sql" not in str(report)
|
|
363
|
+
assert report["renamed_files"] == [{"before": "old name.sql", "after": "new name.sql"}]
|
|
364
|
+
assert "new name.sql" in render_html_report(result)
|
|
365
|
+
sarif = to_sarif(result)
|
|
366
|
+
assert any(
|
|
367
|
+
item["ruleId"] == "sql-file-renamed"
|
|
368
|
+
for item in sarif["runs"][0]["results"]
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@pytest.mark.parametrize("output_format", ["text", "json", "sarif", "html"])
|
|
373
|
+
def test_cli_git_mode_supports_reports_and_risk_gate(
|
|
374
|
+
tmp_path, monkeypatch, capsys, output_format
|
|
375
|
+
) -> None:
|
|
376
|
+
repo = _init_git_repo(tmp_path / f"repo-{output_format}")
|
|
377
|
+
(repo / "query.sql").write_text("SELECT id FROM users", encoding="utf-8")
|
|
378
|
+
_git_commit(repo, "base")
|
|
379
|
+
base = subprocess.run(
|
|
380
|
+
["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True
|
|
381
|
+
).stdout.strip()
|
|
382
|
+
(repo / "query.sql").write_text("SELECT name FROM users", encoding="utf-8")
|
|
383
|
+
monkeypatch.chdir(repo)
|
|
384
|
+
monkeypatch.setattr(
|
|
385
|
+
sys,
|
|
386
|
+
"argv",
|
|
387
|
+
[
|
|
388
|
+
"sql-diff-explainer",
|
|
389
|
+
"--git-base",
|
|
390
|
+
base,
|
|
391
|
+
"--format",
|
|
392
|
+
output_format,
|
|
393
|
+
"--fail-on-risk",
|
|
394
|
+
"medium",
|
|
395
|
+
],
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
assert cli_main() == 1
|
|
399
|
+
output = capsys.readouterr().out
|
|
400
|
+
if output_format == "json":
|
|
401
|
+
assert json.loads(output)["files"][0]["path"] == "query.sql"
|
|
402
|
+
elif output_format == "sarif":
|
|
403
|
+
assert json.loads(output)["runs"][0]["results"]
|
|
404
|
+
elif output_format == "html":
|
|
405
|
+
assert "<html" in output and "query.sql" in output
|
|
406
|
+
else:
|
|
407
|
+
assert "query.sql:" in output
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_compare_sql_git_reports_invalid_ref_and_non_repository(tmp_path) -> None:
|
|
411
|
+
repo = _init_git_repo(tmp_path / "repo")
|
|
412
|
+
with pytest.raises(SQLDiffError, match="Git command failed"):
|
|
413
|
+
compare_sql_git("not-a-real-ref", cwd=repo)
|
|
414
|
+
with pytest.raises(SQLDiffError, match="Git command failed"):
|
|
415
|
+
compare_sql_git("HEAD", cwd=tmp_path)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def test_cli_git_mode_rejects_conflicting_arguments(tmp_path, monkeypatch) -> None:
|
|
419
|
+
monkeypatch.setattr(
|
|
420
|
+
sys,
|
|
421
|
+
"argv",
|
|
422
|
+
["sql-diff-explainer", "before.sql", "after.sql", "--git-base", "HEAD"],
|
|
423
|
+
)
|
|
424
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
425
|
+
cli_main()
|
|
426
|
+
assert exc_info.value.code == 2
|
|
427
|
+
|
|
428
|
+
|
|
285
429
|
def test_cli_compare_directories_emits_json_and_applies_risk_gate(
|
|
286
430
|
tmp_path, monkeypatch, capsys
|
|
287
431
|
) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|