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.
Files changed (21) hide show
  1. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/PKG-INFO +56 -2
  2. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/README.md +55 -1
  3. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/pyproject.toml +1 -1
  4. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/src/sql_diff_explainer/__init__.py +3 -1
  5. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/src/sql_diff_explainer/cli.py +25 -6
  6. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/src/sql_diff_explainer/core.py +144 -2
  7. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/src/sql_diff_explainer/reports.py +21 -0
  8. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/tests/test_core.py +144 -0
  9. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/.github/workflows/ci.yml +0 -0
  10. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/.gitignore +0 -0
  11. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/LICENSE +0 -0
  12. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/after.sql +0 -0
  13. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/aggregation_after.sql +0 -0
  14. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/aggregation_before.sql +0 -0
  15. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/before.sql +0 -0
  16. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/distinct_after.sql +0 -0
  17. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/distinct_before.sql +0 -0
  18. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/filter_after.sql +0 -0
  19. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/filter_before.sql +0 -0
  20. {sql_diff_explainer-0.1.3 → sql_diff_explainer-0.1.4}/examples/join_after.sql +0 -0
  21. {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
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
- [![PyPI version](https://img.shields.io/pypi/v/sql-diff-explainer)](https://pypi.org/project/sql-diff-explainer/)
23
+ [![PyPI version](https://img.shields.io/pypi/v/sql-diff-explainer?cacheSeconds=60)](https://pypi.org/project/sql-diff-explainer/)
24
24
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/sql-diff-explainer/)
25
25
  [![License: MIT](https://img.shields.io/pypi/l/sql-diff-explainer)](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
- [![PyPI version](https://img.shields.io/pypi/v/sql-diff-explainer)](https://pypi.org/project/sql-diff-explainer/)
9
+ [![PyPI version](https://img.shields.io/pypi/v/sql-diff-explainer?cacheSeconds=60)](https://pypi.org/project/sql-diff-explainer/)
10
10
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/sql-diff-explainer/)
11
11
  [![License: MIT](https://img.shields.io/pypi/l/sql-diff-explainer)](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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sql-diff-explainer"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Explain SQL schema and migration differences"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.3"
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
- 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")
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 directory_mode:
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: