sql-diff-explainer 0.1.1__tar.gz → 0.1.3__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 (25) hide show
  1. sql_diff_explainer-0.1.3/PKG-INFO +230 -0
  2. sql_diff_explainer-0.1.3/README.md +216 -0
  3. sql_diff_explainer-0.1.3/examples/aggregation_after.sql +3 -0
  4. sql_diff_explainer-0.1.3/examples/aggregation_before.sql +3 -0
  5. sql_diff_explainer-0.1.3/examples/distinct_after.sql +4 -0
  6. sql_diff_explainer-0.1.3/examples/distinct_before.sql +4 -0
  7. sql_diff_explainer-0.1.3/examples/filter_after.sql +4 -0
  8. sql_diff_explainer-0.1.3/examples/filter_before.sql +3 -0
  9. sql_diff_explainer-0.1.3/examples/join_after.sql +3 -0
  10. sql_diff_explainer-0.1.3/examples/join_before.sql +3 -0
  11. {sql_diff_explainer-0.1.1 → sql_diff_explainer-0.1.3}/pyproject.toml +1 -1
  12. {sql_diff_explainer-0.1.1 → sql_diff_explainer-0.1.3}/src/sql_diff_explainer/__init__.py +10 -1
  13. sql_diff_explainer-0.1.3/src/sql_diff_explainer/cli.py +127 -0
  14. {sql_diff_explainer-0.1.1 → sql_diff_explainer-0.1.3}/src/sql_diff_explainer/core.py +114 -0
  15. sql_diff_explainer-0.1.3/src/sql_diff_explainer/reports.py +198 -0
  16. sql_diff_explainer-0.1.3/tests/test_core.py +390 -0
  17. sql_diff_explainer-0.1.1/PKG-INFO +0 -65
  18. sql_diff_explainer-0.1.1/README.md +0 -51
  19. sql_diff_explainer-0.1.1/src/sql_diff_explainer/cli.py +0 -47
  20. sql_diff_explainer-0.1.1/tests/test_core.py +0 -79
  21. {sql_diff_explainer-0.1.1 → sql_diff_explainer-0.1.3}/.github/workflows/ci.yml +0 -0
  22. {sql_diff_explainer-0.1.1 → sql_diff_explainer-0.1.3}/.gitignore +0 -0
  23. {sql_diff_explainer-0.1.1 → sql_diff_explainer-0.1.3}/LICENSE +0 -0
  24. {sql_diff_explainer-0.1.1 → sql_diff_explainer-0.1.3}/examples/after.sql +0 -0
  25. {sql_diff_explainer-0.1.1 → sql_diff_explainer-0.1.3}/examples/before.sql +0 -0
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/sql-diff-explainer)](https://pypi.org/project/sql-diff-explainer/)
24
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/sql-diff-explainer/)
25
+ [![License: MIT](https://img.shields.io/pypi/l/sql-diff-explainer)](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,216 @@
1
+ # SQL Diff Explainer
2
+
3
+ **See what changed in a SQL query—and why it might matter.**
4
+
5
+ SQL Diff Explainer compares the structure of two `SELECT` queries and returns
6
+ the changes with a static risk signal, so reviews focus on query behavior rather
7
+ than formatting.
8
+
9
+ [![PyPI version](https://img.shields.io/pypi/v/sql-diff-explainer)](https://pypi.org/project/sql-diff-explainer/)
10
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/sql-diff-explainer/)
11
+ [![License: MIT](https://img.shields.io/pypi/l/sql-diff-explainer)](LICENSE)
12
+
13
+ ## Contents
14
+
15
+ - [Quick start](#quick-start)
16
+ - [Command-line usage](#command-line-usage)
17
+ - [Directory comparison](#directory-comparison)
18
+ - [SARIF and HTML reports](#sarif-and-html-reports)
19
+ - [What it compares](#what-it-compares)
20
+ - [Use cases](#use-cases)
21
+ - [Scope and limitations](#scope-and-limitations)
22
+ - [Development](#development)
23
+ - [Contributing](#contributing)
24
+ - [License](#license)
25
+
26
+ ## Quick start
27
+
28
+ Install the latest release from [PyPI](https://pypi.org/project/sql-diff-explainer/):
29
+
30
+ ```console
31
+ python -m pip install sql-diff-explainer
32
+ ```
33
+
34
+ Compare two queries from Python:
35
+
36
+ ```python
37
+ from sql_diff_explainer import explain_sql_change
38
+
39
+ before = """
40
+ SELECT c.customer_id, c.segment, o.order_id
41
+ FROM customers AS c
42
+ LEFT JOIN orders AS o ON c.customer_id = o.customer_id
43
+ """
44
+ after = """
45
+ SELECT c.customer_id, c.segment, o.order_id
46
+ FROM customers AS c
47
+ INNER JOIN orders AS o ON c.customer_id = o.customer_id
48
+ """
49
+
50
+ result = explain_sql_change(before, after)
51
+ print(result.summary)
52
+ for change in result.changes:
53
+ print(
54
+ f"{change.category}: {change.before} -> {change.after} "
55
+ f"({change.risk} risk)"
56
+ )
57
+ ```
58
+
59
+ Output:
60
+
61
+ ```text
62
+ 1 SQL change(s) detected; overall risk is high.
63
+ 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)
64
+ ```
65
+
66
+ The change is classified as high risk because an `INNER JOIN` excludes
67
+ customers without a matching order, whereas a `LEFT JOIN` retains them. The
68
+ result is structured: inspect `result.risk_level` and `result.changes`, or call
69
+ `result.to_dict()` for JSON-ready data. Pass `dialect="postgres"` (or another
70
+ [sqlglot dialect](https://sqlglot.com/sqlglot/dialects.html)) for
71
+ dialect-specific SQL. Invalid SQL raises `SQLParseError`; non-`SELECT` input
72
+ raises `UnsupportedSQL`.
73
+
74
+ ## Command-line usage
75
+
76
+ The CLI compares two SQL files and prints a human-readable report by default:
77
+
78
+ ```console
79
+ sql-diff-explainer examples/join_before.sql examples/join_after.sql
80
+ ```
81
+
82
+ Choose JSON output, set a parsing dialect, or make a risk threshold fail a CI
83
+ step:
84
+
85
+ ```console
86
+ sql-diff-explainer before.sql after.sql --format json
87
+ sql-diff-explainer before.sql after.sql --dialect postgres
88
+ sql-diff-explainer before.sql after.sql --fail-on-risk medium
89
+ ```
90
+
91
+ `--fail-on-risk` accepts `low`, `medium`, or `high`. It exits with status `1`
92
+ when detected changes meet or exceed the threshold; the text or JSON report is
93
+ still printed. No detected changes exit successfully. Input-file errors and
94
+ SQL parse/support errors exit with status `2`.
95
+
96
+ Try the runnable before/after pairs in [`examples/`](examples/):
97
+
98
+ ```console
99
+ sql-diff-explainer examples/filter_before.sql examples/filter_after.sql --format json
100
+ sql-diff-explainer examples/aggregation_before.sql examples/aggregation_after.sql
101
+ sql-diff-explainer examples/distinct_before.sql examples/distinct_after.sql
102
+ ```
103
+
104
+ ### Directory comparison
105
+
106
+ Compare two directory trees recursively. SQL files are paired by their exact
107
+ relative path (with `/` separators); the `.sql` extension is matched
108
+ case-insensitively. Files present on only one side are reported as additions
109
+ or removals rather than parsed as empty SQL, and raise the aggregate risk to
110
+ high:
111
+
112
+ ```console
113
+ sql-diff-explainer --compare-dirs models/main models/review --format json
114
+ sql-diff-explainer --compare-dirs models/main models/review --fail-on-risk high
115
+ ```
116
+
117
+ The JSON report includes each matched file's ordinary SQL diff, plus
118
+ `unmatched_before` and `unmatched_after` path lists. A missing or non-directory
119
+ input, unreadable file, or invalid SQL exits with status `2`.
120
+
121
+ The same operation is available in Python:
122
+
123
+ ```python
124
+ from sql_diff_explainer import compare_sql_directories
125
+
126
+ result = compare_sql_directories("models/main", "models/review")
127
+ print(result.to_dict())
128
+ ```
129
+
130
+ ### SARIF and HTML reports
131
+
132
+ Emit SARIF 2.1.0 results for GitHub code scanning integrations, or generate a
133
+ self-contained HTML report that can be attached to a review:
134
+
135
+ ```console
136
+ sql-diff-explainer before.sql after.sql --format sarif --output sql-diff.sarif
137
+ sql-diff-explainer --compare-dirs models/main models/review --format html --output review.html
138
+ ```
139
+
140
+ Both formats work with the existing `--fail-on-risk` threshold. SARIF maps
141
+ high, medium, and low risk to `error`, `warning`, and `note`; report output is
142
+ still written when the threshold causes exit status `1`. HTML escapes SQL text
143
+ and file paths. Python callers can use `to_sarif(result)` or
144
+ `render_html_report(result)` with either `SQLDiffResult` or
145
+ `SQLDirectoryDiffResult`.
146
+
147
+ ## What it compares
148
+
149
+ The analyzer compares supported `SELECT` query features and reports additions,
150
+ removals, or changes with a low, medium, or high risk classification:
151
+
152
+ | Feature | Examples of changes |
153
+ | --- | --- |
154
+ | Projections | Selected columns and expressions |
155
+ | Joins | Join target, predicate, or type |
156
+ | Filters | `WHERE` predicates |
157
+ | Aggregation | Aggregate functions and `GROUP BY` expressions |
158
+ | Ordering and pagination | `ORDER BY`, `LIMIT`, and `OFFSET` |
159
+ | Duplicate handling | `DISTINCT` added or removed |
160
+
161
+ Join or filter changes can alter which rows qualify; grouping or aggregation
162
+ changes can change a metric's meaning or result grain; and pagination changes
163
+ can affect which rows downstream consumers receive.
164
+
165
+ ## Use cases
166
+
167
+ - Review dbt models or warehouse SQL in a pull request.
168
+ - Spot join and filter changes that could alter row counts.
169
+ - Check grouping and aggregation changes that could shift metric grain.
170
+ - Add a lightweight risk threshold to a CI check.
171
+ - Explain generated SQL changes without treating formatting-only differences
172
+ as query changes.
173
+
174
+ ## Scope and limitations
175
+
176
+ This is static analysis, not a SQL execution engine or correctness proof. It
177
+ does not connect to a database, validate schemas or permissions, estimate query
178
+ plans or cost, inspect table statistics or null rates, or decide whether a
179
+ business metric is correct. It compares supported query structure; it does not
180
+ analyze a multi-statement migration file or guarantee complete semantic
181
+ equivalence across SQL dialects. Risk levels are review signals, not
182
+ predictions. Pair the report with warehouse tests, representative row-count
183
+ checks, query-plan review, and domain-owner input.
184
+
185
+ ## Development
186
+
187
+ The package requires Python 3.10 or newer and uses a `src/` layout.
188
+
189
+ ```console
190
+ python -m venv .venv
191
+ ```
192
+
193
+ Activate the environment, then install the development dependencies and run
194
+ the checks:
195
+
196
+ ```console
197
+ # macOS / Linux
198
+ source .venv/bin/activate
199
+
200
+ # Windows PowerShell
201
+ .venv\Scripts\Activate.ps1
202
+
203
+ python -m pip install -e ".[dev]"
204
+ python -m pytest
205
+ ruff check .
206
+ ```
207
+
208
+ ## Contributing
209
+
210
+ Bug reports, focused improvements, and pull requests are welcome. Please
211
+ include or update tests for behavior changes and keep documentation aligned
212
+ with the supported API.
213
+
214
+ ## License
215
+
216
+ Distributed under the [MIT License](LICENSE).
@@ -0,0 +1,3 @@
1
+ SELECT customer_id, order_date, SUM(order_total) AS revenue
2
+ FROM orders
3
+ GROUP BY customer_id, order_date;
@@ -0,0 +1,3 @@
1
+ SELECT customer_id, COUNT(*) AS order_count
2
+ FROM orders
3
+ GROUP BY customer_id;
@@ -0,0 +1,4 @@
1
+ SELECT DISTINCT customer_id, event_type
2
+ FROM customer_events
3
+ ORDER BY customer_id, event_type
4
+ LIMIT 50 OFFSET 100;
@@ -0,0 +1,4 @@
1
+ SELECT customer_id, event_type
2
+ FROM customer_events
3
+ ORDER BY customer_id, event_type
4
+ LIMIT 100;
@@ -0,0 +1,4 @@
1
+ SELECT order_id, customer_id, order_total
2
+ FROM orders
3
+ WHERE order_date >= DATE '2026-01-01'
4
+ AND order_status = 'completed';
@@ -0,0 +1,3 @@
1
+ SELECT order_id, customer_id, order_total
2
+ FROM orders
3
+ WHERE order_date >= DATE '2026-01-01';
@@ -0,0 +1,3 @@
1
+ SELECT c.customer_id, c.segment, o.order_id
2
+ FROM customers AS c
3
+ INNER JOIN orders AS o ON c.customer_id = o.customer_id;
@@ -0,0 +1,3 @@
1
+ SELECT c.customer_id, c.segment, o.order_id
2
+ FROM customers AS c
3
+ LEFT JOIN orders AS o ON c.customer_id = o.customer_id;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sql-diff-explainer"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Explain SQL schema and migration differences"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.1"
35
+ __version__ = "0.1.3"
@@ -0,0 +1,127 @@
1
+ """Command-line interface for SQL Diff Explainer."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
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
16
+
17
+ _RISK_SEVERITY = {"low": 0, "medium": 1, "high": 2}
18
+
19
+
20
+ def main() -> int:
21
+ """Run the command-line interface."""
22
+ parser = argparse.ArgumentParser(
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",
33
+ )
34
+ parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
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")
42
+ parser.add_argument(
43
+ "--fail-on-risk",
44
+ choices=tuple(_RISK_SEVERITY),
45
+ help="Exit with status 1 when detected risk meets or exceeds this level",
46
+ )
47
+ args = parser.parse_args()
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
+
55
+ try:
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:
71
+ print(f"error: {exc}", file=sys.stderr)
72
+ return 2
73
+
74
+ if args.output_format == "json":
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")
80
+ else:
81
+ lines = [result.summary]
82
+ if isinstance(result, SQLDiffResult):
83
+ if result.risk_reasons:
84
+ lines.append(f"Risk: {result.risk.upper()} - {' '.join(result.risk_reasons)}")
85
+ file_results = [("", result)]
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
+ )
117
+ if (
118
+ args.fail_on_risk is not None
119
+ and has_changes
120
+ and _RISK_SEVERITY[result.risk] >= _RISK_SEVERITY[args.fail_on_risk]
121
+ ):
122
+ return 1
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())