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.
- sql_diff_explainer-0.1.0/.github/workflows/ci.yml +21 -0
- sql_diff_explainer-0.1.0/.gitignore +8 -0
- sql_diff_explainer-0.1.0/LICENSE +21 -0
- sql_diff_explainer-0.1.0/PKG-INFO +61 -0
- sql_diff_explainer-0.1.0/README.md +47 -0
- sql_diff_explainer-0.1.0/examples/after.sql +7 -0
- sql_diff_explainer-0.1.0/examples/before.sql +6 -0
- sql_diff_explainer-0.1.0/pyproject.toml +40 -0
- sql_diff_explainer-0.1.0/src/sql_diff_explainer/__init__.py +26 -0
- sql_diff_explainer-0.1.0/src/sql_diff_explainer/cli.py +47 -0
- sql_diff_explainer-0.1.0/src/sql_diff_explainer/core.py +277 -0
- sql_diff_explainer-0.1.0/tests/test_core.py +64 -0
|
@@ -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,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,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"
|