sql-diff-explainer 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.10", "3.11", "3.12"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
18
+ - run: python -m pip install --upgrade pip
19
+ - run: python -m pip install -e ".[dev]"
20
+ - run: python -m pytest
21
+ - run: python -m ruff check .
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zmodz1010
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.5
2
+ Name: sql-diff-explainer
3
+ Version: 0.1.0
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
+ ## Development
26
+
27
+ This project uses a `src/` layout and requires Python 3.10 or newer.
28
+
29
+ ```powershell
30
+ python -m venv .venv
31
+ .venv\Scripts\Activate.ps1
32
+ python -m pip install -e ".[dev]"
33
+ pytest
34
+ ```
35
+
36
+ ## Library usage
37
+
38
+ ```python
39
+ from sql_diff_explainer import explain_sql_change
40
+
41
+ result = explain_sql_change(
42
+ "SELECT department, COUNT(*) FROM employees GROUP BY department",
43
+ "SELECT department, COUNT(*) FROM employees WHERE active = TRUE GROUP BY department",
44
+ )
45
+ print(result.to_dict())
46
+ ```
47
+
48
+ `explain_sql_change` raises `SQLParseError` for invalid SQL and `UnsupportedSQL`
49
+ for non-`SELECT` statements. Pass `dialect="postgres"` (or another sqlglot
50
+ dialect) when parsing dialect-specific syntax.
51
+
52
+ ## CLI
53
+
54
+ ```powershell
55
+ sql-diff-explainer before.sql after.sql
56
+ sql-diff-explainer before.sql after.sql --dialect postgres --format json
57
+ ```
58
+
59
+ JSON output is intended for CI and integrations; text output is optimized for
60
+ human review. The process exits with status 2 for unreadable files or expected
61
+ SQL errors.
@@ -0,0 +1,47 @@
1
+ # SQL Diff Explainer
2
+
3
+ Explain the meaningful structural differences between two SQL `SELECT` queries.
4
+
5
+ The package uses [sqlglot](https://github.com/tobymao/sqlglot) behind an abstract
6
+ parser interface, so callers receive a stable result even if the parser
7
+ implementation changes. It reports changes to projections, joins, filters,
8
+ grouping, aggregation, ordering, pagination, and `DISTINCT`, with a static
9
+ low/medium/high risk classification.
10
+
11
+ ## Development
12
+
13
+ This project uses a `src/` layout and requires Python 3.10 or newer.
14
+
15
+ ```powershell
16
+ python -m venv .venv
17
+ .venv\Scripts\Activate.ps1
18
+ python -m pip install -e ".[dev]"
19
+ pytest
20
+ ```
21
+
22
+ ## Library usage
23
+
24
+ ```python
25
+ from sql_diff_explainer import explain_sql_change
26
+
27
+ result = explain_sql_change(
28
+ "SELECT department, COUNT(*) FROM employees GROUP BY department",
29
+ "SELECT department, COUNT(*) FROM employees WHERE active = TRUE GROUP BY department",
30
+ )
31
+ print(result.to_dict())
32
+ ```
33
+
34
+ `explain_sql_change` raises `SQLParseError` for invalid SQL and `UnsupportedSQL`
35
+ for non-`SELECT` statements. Pass `dialect="postgres"` (or another sqlglot
36
+ dialect) when parsing dialect-specific syntax.
37
+
38
+ ## CLI
39
+
40
+ ```powershell
41
+ sql-diff-explainer before.sql after.sql
42
+ sql-diff-explainer before.sql after.sql --dialect postgres --format json
43
+ ```
44
+
45
+ JSON output is intended for CI and integrations; text output is optimized for
46
+ human review. The process exits with status 2 for unreadable files or expected
47
+ SQL errors.
@@ -0,0 +1,7 @@
1
+ SELECT department, COUNT(*) AS headcount
2
+ FROM employees
3
+ JOIN departments ON employees.department_id = departments.id
4
+ WHERE active = TRUE
5
+ GROUP BY department
6
+ ORDER BY headcount DESC
7
+ LIMIT 50 OFFSET 10;
@@ -0,0 +1,6 @@
1
+ SELECT department, COUNT(*) AS headcount
2
+ FROM employees
3
+ WHERE active = TRUE
4
+ GROUP BY department
5
+ ORDER BY headcount DESC
6
+ LIMIT 20;
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sql-diff-explainer"
7
+ version = "0.1.0"
8
+ description = "Explain SQL schema and migration differences"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "zmodz1010" },
14
+ ]
15
+ dependencies = [
16
+ "sqlglot>=25.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ sql-diff-explainer = "sql_diff_explainer.cli:main"
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest>=8.0",
25
+ "ruff>=0.6",
26
+ ]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/sql_diff_explainer"]
30
+
31
+ [tool.pytest.ini_options]
32
+ addopts = "-q"
33
+ testpaths = ["tests"]
34
+
35
+ [tool.ruff]
36
+ line-length = 100
37
+ target-version = "py310"
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,26 @@
1
+ """Tools for explaining SQL query differences."""
2
+
3
+ from .core import (
4
+ SQLChange,
5
+ SQLDiffError,
6
+ SQLDiffResult,
7
+ SqlglotParser,
8
+ SQLParseError,
9
+ SQLParser,
10
+ UnsupportedSQL,
11
+ explain_diff,
12
+ explain_sql_change,
13
+ )
14
+
15
+ __all__ = [
16
+ "SQLChange",
17
+ "SQLDiffError",
18
+ "SQLDiffResult",
19
+ "SQLParseError",
20
+ "SQLParser",
21
+ "SqlglotParser",
22
+ "UnsupportedSQL",
23
+ "explain_diff",
24
+ "explain_sql_change",
25
+ ]
26
+ __version__ = "0.2.0"
@@ -0,0 +1,47 @@
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 SQLDiffError, explain_sql_change
9
+
10
+
11
+ def main() -> int:
12
+ """Run the command-line interface."""
13
+ parser = argparse.ArgumentParser(
14
+ description="Explain structural differences between two SQL files."
15
+ )
16
+ parser.add_argument("before", type=Path, help="Original SQL file")
17
+ parser.add_argument("after", type=Path, help="Updated SQL file")
18
+ parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
19
+ parser.add_argument("--format", choices=("text", "json"), default="text", dest="output_format")
20
+ args = parser.parse_args()
21
+
22
+ try:
23
+ result = explain_sql_change(
24
+ args.before.read_text(encoding="utf-8"),
25
+ args.after.read_text(encoding="utf-8"),
26
+ dialect=args.dialect,
27
+ )
28
+ except (OSError, SQLDiffError) as exc:
29
+ print(f"error: {exc}", file=sys.stderr)
30
+ return 2
31
+ if args.output_format == "json":
32
+ print(json.dumps(result.to_dict(), indent=2))
33
+ else:
34
+ print(result.summary)
35
+ if result.risk_reasons:
36
+ print(f"Risk: {result.risk.upper()} - {' '.join(result.risk_reasons)}")
37
+ for change in result.changes:
38
+ print(f"- [{change.risk.upper()}] {change.description}")
39
+ if change.before is not None:
40
+ print(f" before: {change.before}")
41
+ if change.after is not None:
42
+ print(f" after: {change.after}")
43
+ return 0
44
+
45
+
46
+ if __name__ == "__main__":
47
+ raise SystemExit(main())
@@ -0,0 +1,277 @@
1
+ """SQL-aware query comparison and risk classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections import Counter
7
+ from dataclasses import dataclass, field
8
+ from difflib import unified_diff
9
+ from typing import Any
10
+
11
+
12
+ class SQLDiffError(ValueError):
13
+ """Base class for expected SQL diff errors."""
14
+
15
+
16
+ class SQLParseError(SQLDiffError):
17
+ """Raised when either SQL document cannot be parsed."""
18
+
19
+
20
+ class UnsupportedSQL(SQLDiffError):
21
+ """Raised when the parsed statements are not supported query statements."""
22
+
23
+
24
+ class SQLParser(ABC):
25
+ """Abstract parser boundary, allowing another SQL parser to be plugged in."""
26
+
27
+ @abstractmethod
28
+ def parse(self, sql: str, dialect: str | None = None) -> Any:
29
+ """Parse one SQL statement and return the parser's syntax tree."""
30
+
31
+
32
+ class SqlglotParser(SQLParser):
33
+ """Parser implementation backed by sqlglot."""
34
+
35
+ def parse(self, sql: str, dialect: str | None = None) -> Any:
36
+ try:
37
+ import sqlglot
38
+ from sqlglot import exp
39
+ except ImportError as exc: # pragma: no cover - dependency is declared
40
+ raise SQLParseError("sqlglot is required to parse SQL") from exc
41
+
42
+ if not sql.strip():
43
+ raise SQLParseError("SQL input is empty")
44
+ try:
45
+ tree = sqlglot.parse_one(sql, read=dialect or None)
46
+ except Exception as exc:
47
+ raise SQLParseError(f"Unable to parse SQL: {exc}") from exc
48
+ if not isinstance(tree, exp.Query):
49
+ raise UnsupportedSQL(
50
+ f"Only SELECT queries are supported (received {tree.__class__.__name__})"
51
+ )
52
+ return tree
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SQLChange:
57
+ """One semantic change found between two query trees."""
58
+
59
+ category: str
60
+ description: str
61
+ before: str | None
62
+ after: str | None
63
+ risk: str
64
+
65
+ def to_dict(self) -> dict[str, str | None]:
66
+ return {
67
+ "category": self.category,
68
+ "description": self.description,
69
+ "before": self.before,
70
+ "after": self.after,
71
+ "risk": self.risk,
72
+ }
73
+
74
+
75
+ @dataclass
76
+ class SQLDiffResult:
77
+ """Structured explanation returned by :func:`explain_sql_change`."""
78
+
79
+ changes: list[SQLChange] = field(default_factory=list)
80
+ risk: str = "low"
81
+ risk_reasons: list[str] = field(default_factory=list)
82
+ dialect: str | None = None
83
+
84
+ @property
85
+ def summary(self) -> str:
86
+ if not self.changes:
87
+ return "No structural SQL changes detected."
88
+ return f"{len(self.changes)} SQL change(s) detected; overall risk is {self.risk}."
89
+
90
+ def to_dict(self) -> dict[str, object]:
91
+ return {
92
+ "summary": self.summary,
93
+ "risk": self.risk,
94
+ "risk_reasons": list(self.risk_reasons),
95
+ "dialect": self.dialect,
96
+ "changes": [change.to_dict() for change in self.changes],
97
+ }
98
+
99
+
100
+ def _sql(node: Any, dialect: str | None) -> str | None:
101
+ if node is None:
102
+ return None
103
+ if isinstance(node, bool):
104
+ return str(node).lower()
105
+ if isinstance(node, (int, float, str)):
106
+ return str(node)
107
+ return node.sql(dialect=dialect or None)
108
+
109
+
110
+ def _values(select: Any, key: str, dialect: str | None) -> list[str]:
111
+ value = select.args.get(key)
112
+ if value is None:
113
+ return []
114
+ if key == "group":
115
+ expressions = value.expressions
116
+ elif key == "order":
117
+ expressions = value.expressions
118
+ else:
119
+ expressions = [value.this]
120
+ return [_sql(expression, dialect) or "" for expression in expressions]
121
+
122
+
123
+ def _select_expression(tree: Any) -> Any:
124
+ from sqlglot import exp
125
+
126
+ select = tree if isinstance(tree, exp.Select) else tree.find(exp.Select)
127
+ if select is None:
128
+ raise UnsupportedSQL("Only SELECT queries are supported")
129
+ return select
130
+
131
+
132
+ def _features(tree: Any, dialect: str | None) -> dict[str, list[str] | str | bool | None]:
133
+ from sqlglot import exp
134
+
135
+ select = _select_expression(tree)
136
+ projections = [_sql(expression, dialect) or "" for expression in select.expressions]
137
+ joins = [_sql(join, dialect) or "" for join in select.args.get("joins", [])]
138
+ aggregations = sorted(
139
+ {_sql(node, dialect) or "" for node in select.find_all(exp.AggFunc)}
140
+ )
141
+ where = select.args.get("where")
142
+ return {
143
+ "select": projections,
144
+ "joins": joins,
145
+ "where": _sql(where.this if where else None, dialect),
146
+ "group_by": _values(select, "group", dialect),
147
+ "aggregation": aggregations,
148
+ "order_by": _values(select, "order", dialect),
149
+ "limit": _sql(
150
+ (
151
+ select.args.get("limit").expression
152
+ if select.args.get("limit")
153
+ else None
154
+ ),
155
+ dialect,
156
+ ),
157
+ "offset": _sql(
158
+ select.args.get("offset").expression if select.args.get("offset") else None,
159
+ dialect,
160
+ ),
161
+ "distinct": bool(select.args.get("distinct")),
162
+ }
163
+
164
+
165
+ _RISK = {
166
+ "removed_where": ("high", "Removing a WHERE predicate can expand the result set."),
167
+ "removed_join": ("high", "Removing a JOIN can change row matching and cardinality."),
168
+ "aggregation": ("high", "Changing aggregation can alter result meaning and cardinality."),
169
+ "select": ("medium", "Changing projected columns changes the result shape."),
170
+ "group_by": ("high", "Changing GROUP BY changes result granularity."),
171
+ "distinct": ("medium", "Changing DISTINCT can introduce or remove duplicate rows."),
172
+ "order_by": ("low", "Changing ORDER BY changes result ordering."),
173
+ "limit": ("medium", "Changing LIMIT changes the number of returned rows."),
174
+ "offset": ("medium", "Changing OFFSET changes which rows are returned."),
175
+ "where": ("medium", "Changing a WHERE predicate changes which rows qualify."),
176
+ "join": ("medium", "Changing a JOIN can change row matching or cardinality."),
177
+ }
178
+
179
+
180
+ def _change(
181
+ category: str,
182
+ before: Any,
183
+ after: Any,
184
+ dialect: str | None,
185
+ ) -> SQLChange:
186
+ before_text = before if isinstance(before, str) or before is None else _sql(before, dialect)
187
+ after_text = after if isinstance(after, str) or after is None else _sql(after, dialect)
188
+ key = "removed_" + category if after_text is None else category
189
+ risk = _RISK.get(key, ("medium", f"{category.title()} changed."))[0]
190
+ action = "removed" if after_text is None else "changed" if before_text else "added"
191
+ return SQLChange(
192
+ category=category,
193
+ description=f"{category.replace('_', ' ').title()} {action}.",
194
+ before=before_text,
195
+ after=after_text,
196
+ risk=risk,
197
+ )
198
+
199
+
200
+ def _sequence_changes(
201
+ category: str,
202
+ before: list[str],
203
+ after: list[str],
204
+ dialect: str | None,
205
+ ) -> list[SQLChange]:
206
+ changes: list[SQLChange] = []
207
+ if Counter(before) == Counter(after):
208
+ return changes
209
+ removed = list((Counter(before) - Counter(after)).elements())
210
+ added = list((Counter(after) - Counter(before)).elements())
211
+ for value in removed:
212
+ changes.append(_change(category, value, None, dialect))
213
+ for value in added:
214
+ changes.append(_change(category, None, value, dialect))
215
+ return changes
216
+
217
+
218
+ def explain_sql_change(
219
+ before: str,
220
+ after: str,
221
+ *,
222
+ dialect: str | None = None,
223
+ parser: SQLParser | None = None,
224
+ ) -> SQLDiffResult:
225
+ """Explain structural changes between two SELECT queries."""
226
+ parser = parser or SqlglotParser()
227
+ before_tree = parser.parse(before, dialect)
228
+ after_tree = parser.parse(after, dialect)
229
+ before_features = _features(before_tree, dialect)
230
+ after_features = _features(after_tree, dialect)
231
+ changes: list[SQLChange] = []
232
+
233
+ sequence_categories = ("select", "joins", "group_by", "aggregation", "order_by")
234
+ for category in sequence_categories:
235
+ before_values = before_features[category]
236
+ after_values = after_features[category]
237
+ if not isinstance(before_values, list) or not isinstance(after_values, list):
238
+ raise UnsupportedSQL(f"Unable to compare {category}")
239
+ changes.extend(
240
+ _sequence_changes(
241
+ category.removesuffix("s"),
242
+ before_values,
243
+ after_values,
244
+ dialect,
245
+ )
246
+ )
247
+ for category in ("where", "limit", "offset", "distinct"):
248
+ if before_features[category] != after_features[category]:
249
+ changes.append(
250
+ _change(category, before_features[category], after_features[category], dialect)
251
+ )
252
+
253
+ reasons: list[str] = []
254
+ severity = {"low": 0, "medium": 1, "high": 2}
255
+ overall = "low"
256
+ for change in changes:
257
+ if severity[change.risk] > severity[overall]:
258
+ overall = change.risk
259
+ reason = _RISK.get(
260
+ ("removed_" + change.category if change.after is None else change.category),
261
+ ("medium", ""),
262
+ )[1]
263
+ if reason and reason not in reasons:
264
+ reasons.append(reason)
265
+ return SQLDiffResult(changes=changes, risk=overall, risk_reasons=reasons, dialect=dialect)
266
+
267
+
268
+ def explain_diff(before: str, after: str) -> str:
269
+ """Return a readable unified diff for two SQL documents."""
270
+ lines = unified_diff(
271
+ before.splitlines(),
272
+ after.splitlines(),
273
+ fromfile="before.sql",
274
+ tofile="after.sql",
275
+ lineterm="",
276
+ )
277
+ return "\n".join(lines)
@@ -0,0 +1,64 @@
1
+ import pytest
2
+
3
+ from sql_diff_explainer import SQLParseError, UnsupportedSQL, explain_diff, explain_sql_change
4
+
5
+
6
+ def test_explain_diff_returns_unified_diff() -> None:
7
+ result = explain_diff("CREATE TABLE users;", "CREATE TABLE accounts;")
8
+
9
+ assert "--- before.sql" in result
10
+ assert "+++ after.sql" in result
11
+ assert "-CREATE TABLE users;" in result
12
+ assert "+CREATE TABLE accounts;" in result
13
+
14
+
15
+ def test_explain_sql_change_detects_core_query_features() -> None:
16
+ before = (
17
+ "SELECT DISTINCT a, COUNT(*) FROM users u WHERE active = 1 "
18
+ "GROUP BY a ORDER BY a LIMIT 10"
19
+ )
20
+ after = (
21
+ "SELECT a, SUM(amount) FROM users u JOIN orders o ON u.id = o.user_id "
22
+ "WHERE active = 1 AND region = 'EU' GROUP BY a ORDER BY a DESC LIMIT 20 OFFSET 5"
23
+ )
24
+
25
+ result = explain_sql_change(before, after)
26
+ categories = {change.category for change in result.changes}
27
+
28
+ assert {"select", "join", "aggregation", "distinct", "limit", "offset"}.issubset(categories)
29
+ assert result.risk == "high"
30
+ assert result.to_dict()["changes"]
31
+
32
+
33
+ def test_explain_sql_change_reports_filter_removal_as_high_risk() -> None:
34
+ result = explain_sql_change(
35
+ "SELECT id FROM users WHERE active = TRUE",
36
+ "SELECT id FROM users",
37
+ )
38
+
39
+ assert result.risk == "high"
40
+ assert any(change.category == "where" and change.risk == "high" for change in result.changes)
41
+
42
+
43
+ def test_explain_sql_change_accepts_dialect() -> None:
44
+ result = explain_sql_change(
45
+ "SELECT TOP 5 id FROM users",
46
+ "SELECT TOP 10 id FROM users",
47
+ dialect="tsql",
48
+ )
49
+
50
+ assert result.changes[0].category == "limit"
51
+
52
+
53
+ def test_parse_and_unsupported_errors_are_clean() -> None:
54
+ with pytest.raises(SQLParseError):
55
+ explain_sql_change("SELECT FROM", "SELECT 1")
56
+ with pytest.raises(UnsupportedSQL):
57
+ explain_sql_change("CREATE TABLE users (id INT)", "SELECT 1")
58
+
59
+
60
+ def test_identical_queries_have_no_changes() -> None:
61
+ result = explain_sql_change("SELECT id FROM users", "SELECT id FROM users")
62
+
63
+ assert result.changes == []
64
+ assert result.risk == "low"