sql-diff-explainer 0.1.0__tar.gz → 0.1.2__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.2/PKG-INFO +147 -0
- sql_diff_explainer-0.1.2/README.md +133 -0
- sql_diff_explainer-0.1.2/examples/aggregation_after.sql +3 -0
- sql_diff_explainer-0.1.2/examples/aggregation_before.sql +3 -0
- sql_diff_explainer-0.1.2/examples/distinct_after.sql +4 -0
- sql_diff_explainer-0.1.2/examples/distinct_before.sql +4 -0
- sql_diff_explainer-0.1.2/examples/filter_after.sql +4 -0
- sql_diff_explainer-0.1.2/examples/filter_before.sql +3 -0
- sql_diff_explainer-0.1.2/examples/join_after.sql +3 -0
- sql_diff_explainer-0.1.2/examples/join_before.sql +3 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/pyproject.toml +1 -1
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/src/sql_diff_explainer/__init__.py +1 -1
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/src/sql_diff_explainer/cli.py +13 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/src/sql_diff_explainer/core.py +87 -5
- sql_diff_explainer-0.1.2/tests/test_core.py +204 -0
- sql_diff_explainer-0.1.0/PKG-INFO +0 -61
- sql_diff_explainer-0.1.0/README.md +0 -47
- sql_diff_explainer-0.1.0/tests/test_core.py +0 -64
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/.github/workflows/ci.yml +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/.gitignore +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/LICENSE +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/examples/after.sql +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/examples/before.sql +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sql-diff-explainer
|
|
3
|
+
Version: 0.1.2
|
|
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
|
+
The next patch release is **0.1.2**. It adds the CI-friendly CLI
|
|
26
|
+
`--fail-on-risk` threshold.
|
|
27
|
+
|
|
28
|
+
## Why use SQL Diff Explainer?
|
|
29
|
+
|
|
30
|
+
Use it in a pull request, migration review, or CI check when a textual SQL diff
|
|
31
|
+
does not explain the behavioral impact of a query change. It is especially
|
|
32
|
+
useful for data-engineering teams reviewing:
|
|
33
|
+
|
|
34
|
+
- dbt model changes where a join or filter can change row counts
|
|
35
|
+
- warehouse reports where grouping or aggregation changes the grain
|
|
36
|
+
- incremental pipeline queries where `DISTINCT`, `ORDER BY`, `LIMIT`, or
|
|
37
|
+
`OFFSET` changes the records consumed downstream
|
|
38
|
+
- generated SQL changes where normalized syntax is easier to compare than
|
|
39
|
+
formatting or alias changes
|
|
40
|
+
|
|
41
|
+
The result is a structured object, so a CI integration can fail or annotate a
|
|
42
|
+
review based on `result.risk_level`, inspect `result.changes`, or serialize
|
|
43
|
+
`result.to_dict()` as JSON.
|
|
44
|
+
|
|
45
|
+
## Real-life data-engineering examples
|
|
46
|
+
|
|
47
|
+
The `examples/` directory contains runnable before/after pairs. For example:
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
sql-diff-explainer examples\join_before.sql examples\join_after.sql
|
|
51
|
+
sql-diff-explainer examples\filter_before.sql examples\filter_after.sql --format json
|
|
52
|
+
sql-diff-explainer examples\aggregation_before.sql examples\aggregation_after.sql
|
|
53
|
+
sql-diff-explainer examples\distinct_before.sql examples\distinct_after.sql
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
These scenarios demonstrate the changes reviewers most often need to discuss:
|
|
57
|
+
|
|
58
|
+
1. **Outer join to inner join:** changing `LEFT JOIN` to `INNER JOIN` can
|
|
59
|
+
exclude dimension or fact rows without a match, so the tool reports one
|
|
60
|
+
semantic join change at high risk.
|
|
61
|
+
2. **Added `WHERE` filter:** adding `WHERE order_status = 'completed'` narrows
|
|
62
|
+
the population and is reported as a filter change.
|
|
63
|
+
3. **Aggregation or grouping change:** changing `COUNT(*)` to `SUM(revenue)`,
|
|
64
|
+
or changing the `GROUP BY` grain from customer to customer and day, can
|
|
65
|
+
change metric meaning and row cardinality.
|
|
66
|
+
4. **`DISTINCT`, ordering, and pagination:** adding `DISTINCT` changes duplicate
|
|
67
|
+
handling; changing `ORDER BY` changes presentation or deterministic paging;
|
|
68
|
+
changing `LIMIT`/`OFFSET` changes which rows a downstream consumer receives.
|
|
69
|
+
|
|
70
|
+
Try the same comparisons from Python:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from sql_diff_explainer import explain_sql_change
|
|
74
|
+
|
|
75
|
+
result = explain_sql_change(
|
|
76
|
+
"SELECT c.id FROM customers c LEFT JOIN orders o ON c.id = o.customer_id",
|
|
77
|
+
"SELECT c.id FROM customers c INNER JOIN orders o ON c.id = o.customer_id",
|
|
78
|
+
)
|
|
79
|
+
assert result.risk_level == "high"
|
|
80
|
+
for change in result.changes:
|
|
81
|
+
print(change.category, change.risk, change.description)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## What it catches—and what it cannot know
|
|
85
|
+
|
|
86
|
+
The analyzer parses both inputs with sqlglot and compares the supported
|
|
87
|
+
`SELECT` structure: projections, joins and join types, `WHERE`, `GROUP BY`,
|
|
88
|
+
aggregate functions, `ORDER BY`, `LIMIT`, `OFFSET`, and `DISTINCT`. It provides
|
|
89
|
+
static risk guidance; it does not execute SQL or connect to a warehouse.
|
|
90
|
+
|
|
91
|
+
Risk is therefore a useful review signal, not a correctness proof. The tool
|
|
92
|
+
cannot infer table statistics, actual null rates, constraints, query plans,
|
|
93
|
+
cost, freshness, permissions, dialect-specific UDF behavior, or whether a
|
|
94
|
+
business metric is correct. It also does not validate schema availability or
|
|
95
|
+
compare multiple statements in a migration file. Pair its output with
|
|
96
|
+
warehouse tests, representative row-count checks, query-plan review, and
|
|
97
|
+
domain-owner approval.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
This project uses a `src/` layout and requires Python 3.10 or newer.
|
|
102
|
+
|
|
103
|
+
```powershell
|
|
104
|
+
python -m venv .venv
|
|
105
|
+
.venv\Scripts\Activate.ps1
|
|
106
|
+
python -m pip install -e ".[dev]"
|
|
107
|
+
pytest
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Library usage
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from sql_diff_explainer import explain_sql_change
|
|
114
|
+
|
|
115
|
+
result = explain_sql_change(
|
|
116
|
+
"SELECT department, COUNT(*) FROM employees GROUP BY department",
|
|
117
|
+
"SELECT department, COUNT(*) FROM employees WHERE active = TRUE GROUP BY department",
|
|
118
|
+
)
|
|
119
|
+
print(result.to_dict())
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`explain_sql_change` raises `SQLParseError` for invalid SQL and `UnsupportedSQL`
|
|
123
|
+
for non-`SELECT` statements. Pass `dialect="postgres"` (or another sqlglot
|
|
124
|
+
dialect) when parsing dialect-specific syntax.
|
|
125
|
+
|
|
126
|
+
## CLI
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
sql-diff-explainer before.sql after.sql
|
|
130
|
+
sql-diff-explainer before.sql after.sql --dialect postgres --format json
|
|
131
|
+
sql-diff-explainer before.sql after.sql --fail-on-risk medium
|
|
132
|
+
sql-diff-explainer before.sql after.sql --format json --fail-on-risk high
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`--fail-on-risk` accepts `low`, `medium`, or `high` and exits with status 1 when
|
|
136
|
+
detected overall risk meets or exceeds the chosen threshold. It does not suppress
|
|
137
|
+
the regular text or JSON report. Comparisons with no detected changes exit
|
|
138
|
+
successfully even though their baseline report risk is `low`. JSON output is
|
|
139
|
+
intended for CI and integrations; text output is optimized for human review.
|
|
140
|
+
|
|
141
|
+
Exit codes:
|
|
142
|
+
|
|
143
|
+
- `0`: the comparison succeeded and no detected change met the configured
|
|
144
|
+
threshold (or `--fail-on-risk` was not set).
|
|
145
|
+
- `1`: detected overall risk met or exceeded `--fail-on-risk`.
|
|
146
|
+
- `2`: an input file could not be read or an expected SQL parse/support error
|
|
147
|
+
occurred.
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
The next patch release is **0.1.2**. It adds the CI-friendly CLI
|
|
12
|
+
`--fail-on-risk` threshold.
|
|
13
|
+
|
|
14
|
+
## Why use SQL Diff Explainer?
|
|
15
|
+
|
|
16
|
+
Use it in a pull request, migration review, or CI check when a textual SQL diff
|
|
17
|
+
does not explain the behavioral impact of a query change. It is especially
|
|
18
|
+
useful for data-engineering teams reviewing:
|
|
19
|
+
|
|
20
|
+
- dbt model changes where a join or filter can change row counts
|
|
21
|
+
- warehouse reports where grouping or aggregation changes the grain
|
|
22
|
+
- incremental pipeline queries where `DISTINCT`, `ORDER BY`, `LIMIT`, or
|
|
23
|
+
`OFFSET` changes the records consumed downstream
|
|
24
|
+
- generated SQL changes where normalized syntax is easier to compare than
|
|
25
|
+
formatting or alias changes
|
|
26
|
+
|
|
27
|
+
The result is a structured object, so a CI integration can fail or annotate a
|
|
28
|
+
review based on `result.risk_level`, inspect `result.changes`, or serialize
|
|
29
|
+
`result.to_dict()` as JSON.
|
|
30
|
+
|
|
31
|
+
## Real-life data-engineering examples
|
|
32
|
+
|
|
33
|
+
The `examples/` directory contains runnable before/after pairs. For example:
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
sql-diff-explainer examples\join_before.sql examples\join_after.sql
|
|
37
|
+
sql-diff-explainer examples\filter_before.sql examples\filter_after.sql --format json
|
|
38
|
+
sql-diff-explainer examples\aggregation_before.sql examples\aggregation_after.sql
|
|
39
|
+
sql-diff-explainer examples\distinct_before.sql examples\distinct_after.sql
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
These scenarios demonstrate the changes reviewers most often need to discuss:
|
|
43
|
+
|
|
44
|
+
1. **Outer join to inner join:** changing `LEFT JOIN` to `INNER JOIN` can
|
|
45
|
+
exclude dimension or fact rows without a match, so the tool reports one
|
|
46
|
+
semantic join change at high risk.
|
|
47
|
+
2. **Added `WHERE` filter:** adding `WHERE order_status = 'completed'` narrows
|
|
48
|
+
the population and is reported as a filter change.
|
|
49
|
+
3. **Aggregation or grouping change:** changing `COUNT(*)` to `SUM(revenue)`,
|
|
50
|
+
or changing the `GROUP BY` grain from customer to customer and day, can
|
|
51
|
+
change metric meaning and row cardinality.
|
|
52
|
+
4. **`DISTINCT`, ordering, and pagination:** adding `DISTINCT` changes duplicate
|
|
53
|
+
handling; changing `ORDER BY` changes presentation or deterministic paging;
|
|
54
|
+
changing `LIMIT`/`OFFSET` changes which rows a downstream consumer receives.
|
|
55
|
+
|
|
56
|
+
Try the same comparisons from Python:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sql_diff_explainer import explain_sql_change
|
|
60
|
+
|
|
61
|
+
result = explain_sql_change(
|
|
62
|
+
"SELECT c.id FROM customers c LEFT JOIN orders o ON c.id = o.customer_id",
|
|
63
|
+
"SELECT c.id FROM customers c INNER JOIN orders o ON c.id = o.customer_id",
|
|
64
|
+
)
|
|
65
|
+
assert result.risk_level == "high"
|
|
66
|
+
for change in result.changes:
|
|
67
|
+
print(change.category, change.risk, change.description)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## What it catches—and what it cannot know
|
|
71
|
+
|
|
72
|
+
The analyzer parses both inputs with sqlglot and compares the supported
|
|
73
|
+
`SELECT` structure: projections, joins and join types, `WHERE`, `GROUP BY`,
|
|
74
|
+
aggregate functions, `ORDER BY`, `LIMIT`, `OFFSET`, and `DISTINCT`. It provides
|
|
75
|
+
static risk guidance; it does not execute SQL or connect to a warehouse.
|
|
76
|
+
|
|
77
|
+
Risk is therefore a useful review signal, not a correctness proof. The tool
|
|
78
|
+
cannot infer table statistics, actual null rates, constraints, query plans,
|
|
79
|
+
cost, freshness, permissions, dialect-specific UDF behavior, or whether a
|
|
80
|
+
business metric is correct. It also does not validate schema availability or
|
|
81
|
+
compare multiple statements in a migration file. Pair its output with
|
|
82
|
+
warehouse tests, representative row-count checks, query-plan review, and
|
|
83
|
+
domain-owner approval.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
This project uses a `src/` layout and requires Python 3.10 or newer.
|
|
88
|
+
|
|
89
|
+
```powershell
|
|
90
|
+
python -m venv .venv
|
|
91
|
+
.venv\Scripts\Activate.ps1
|
|
92
|
+
python -m pip install -e ".[dev]"
|
|
93
|
+
pytest
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Library usage
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from sql_diff_explainer import explain_sql_change
|
|
100
|
+
|
|
101
|
+
result = explain_sql_change(
|
|
102
|
+
"SELECT department, COUNT(*) FROM employees GROUP BY department",
|
|
103
|
+
"SELECT department, COUNT(*) FROM employees WHERE active = TRUE GROUP BY department",
|
|
104
|
+
)
|
|
105
|
+
print(result.to_dict())
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`explain_sql_change` raises `SQLParseError` for invalid SQL and `UnsupportedSQL`
|
|
109
|
+
for non-`SELECT` statements. Pass `dialect="postgres"` (or another sqlglot
|
|
110
|
+
dialect) when parsing dialect-specific syntax.
|
|
111
|
+
|
|
112
|
+
## CLI
|
|
113
|
+
|
|
114
|
+
```powershell
|
|
115
|
+
sql-diff-explainer before.sql after.sql
|
|
116
|
+
sql-diff-explainer before.sql after.sql --dialect postgres --format json
|
|
117
|
+
sql-diff-explainer before.sql after.sql --fail-on-risk medium
|
|
118
|
+
sql-diff-explainer before.sql after.sql --format json --fail-on-risk high
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`--fail-on-risk` accepts `low`, `medium`, or `high` and exits with status 1 when
|
|
122
|
+
detected overall risk meets or exceeds the chosen threshold. It does not suppress
|
|
123
|
+
the regular text or JSON report. Comparisons with no detected changes exit
|
|
124
|
+
successfully even though their baseline report risk is `low`. JSON output is
|
|
125
|
+
intended for CI and integrations; text output is optimized for human review.
|
|
126
|
+
|
|
127
|
+
Exit codes:
|
|
128
|
+
|
|
129
|
+
- `0`: the comparison succeeded and no detected change met the configured
|
|
130
|
+
threshold (or `--fail-on-risk` was not set).
|
|
131
|
+
- `1`: detected overall risk met or exceeded `--fail-on-risk`.
|
|
132
|
+
- `2`: an input file could not be read or an expected SQL parse/support error
|
|
133
|
+
occurred.
|
|
@@ -7,6 +7,8 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from .core import SQLDiffError, explain_sql_change
|
|
9
9
|
|
|
10
|
+
_RISK_SEVERITY = {"low": 0, "medium": 1, "high": 2}
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
def main() -> int:
|
|
12
14
|
"""Run the command-line interface."""
|
|
@@ -17,6 +19,11 @@ def main() -> int:
|
|
|
17
19
|
parser.add_argument("after", type=Path, help="Updated SQL file")
|
|
18
20
|
parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
|
|
19
21
|
parser.add_argument("--format", choices=("text", "json"), default="text", dest="output_format")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--fail-on-risk",
|
|
24
|
+
choices=tuple(_RISK_SEVERITY),
|
|
25
|
+
help="Exit with status 1 when detected risk meets or exceeds this level",
|
|
26
|
+
)
|
|
20
27
|
args = parser.parse_args()
|
|
21
28
|
|
|
22
29
|
try:
|
|
@@ -40,6 +47,12 @@ def main() -> int:
|
|
|
40
47
|
print(f" before: {change.before}")
|
|
41
48
|
if change.after is not None:
|
|
42
49
|
print(f" after: {change.after}")
|
|
50
|
+
if (
|
|
51
|
+
args.fail_on_risk is not None
|
|
52
|
+
and result.changes
|
|
53
|
+
and _RISK_SEVERITY[result.risk] >= _RISK_SEVERITY[args.fail_on_risk]
|
|
54
|
+
):
|
|
55
|
+
return 1
|
|
43
56
|
return 0
|
|
44
57
|
|
|
45
58
|
|
|
@@ -81,6 +81,11 @@ class SQLDiffResult:
|
|
|
81
81
|
risk_reasons: list[str] = field(default_factory=list)
|
|
82
82
|
dialect: str | None = None
|
|
83
83
|
|
|
84
|
+
@property
|
|
85
|
+
def risk_level(self) -> str:
|
|
86
|
+
"""Backward-compatible alias for the overall risk classification."""
|
|
87
|
+
return self.risk
|
|
88
|
+
|
|
84
89
|
@property
|
|
85
90
|
def summary(self) -> str:
|
|
86
91
|
if not self.changes:
|
|
@@ -91,6 +96,7 @@ class SQLDiffResult:
|
|
|
91
96
|
return {
|
|
92
97
|
"summary": self.summary,
|
|
93
98
|
"risk": self.risk,
|
|
99
|
+
"risk_level": self.risk_level,
|
|
94
100
|
"risk_reasons": list(self.risk_reasons),
|
|
95
101
|
"dialect": self.dialect,
|
|
96
102
|
"changes": [change.to_dict() for change in self.changes],
|
|
@@ -129,12 +135,24 @@ def _select_expression(tree: Any) -> Any:
|
|
|
129
135
|
return select
|
|
130
136
|
|
|
131
137
|
|
|
132
|
-
def
|
|
138
|
+
def _join_values(select: Any, dialect: str | None) -> list[tuple[str, str]]:
|
|
139
|
+
joins = []
|
|
140
|
+
for join in select.args.get("joins", []):
|
|
141
|
+
side = (join.args.get("side") or "INNER").upper()
|
|
142
|
+
target = _sql(join.args.get("this"), dialect) or ""
|
|
143
|
+
condition = _sql(join.args.get("on"), dialect) or ""
|
|
144
|
+
joins.append((f"{target} ON {condition}", f"{side} JOIN {target} ON {condition}"))
|
|
145
|
+
return joins
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _features(
|
|
149
|
+
tree: Any, dialect: str | None
|
|
150
|
+
) -> dict[str, list[str] | list[tuple[str, str]] | str | bool | None]:
|
|
133
151
|
from sqlglot import exp
|
|
134
152
|
|
|
135
153
|
select = _select_expression(tree)
|
|
136
154
|
projections = [_sql(expression, dialect) or "" for expression in select.expressions]
|
|
137
|
-
joins =
|
|
155
|
+
joins = _join_values(select, dialect)
|
|
138
156
|
aggregations = sorted(
|
|
139
157
|
{_sql(node, dialect) or "" for node in select.find_all(exp.AggFunc)}
|
|
140
158
|
)
|
|
@@ -174,6 +192,10 @@ _RISK = {
|
|
|
174
192
|
"offset": ("medium", "Changing OFFSET changes which rows are returned."),
|
|
175
193
|
"where": ("medium", "Changing a WHERE predicate changes which rows qualify."),
|
|
176
194
|
"join": ("medium", "Changing a JOIN can change row matching or cardinality."),
|
|
195
|
+
"join_type": (
|
|
196
|
+
"high",
|
|
197
|
+
"Changing an outer JOIN to INNER JOIN can exclude unmatched rows.",
|
|
198
|
+
),
|
|
177
199
|
}
|
|
178
200
|
|
|
179
201
|
|
|
@@ -182,10 +204,11 @@ def _change(
|
|
|
182
204
|
before: Any,
|
|
183
205
|
after: Any,
|
|
184
206
|
dialect: str | None,
|
|
207
|
+
risk_key: str | None = None,
|
|
185
208
|
) -> SQLChange:
|
|
186
209
|
before_text = before if isinstance(before, str) or before is None else _sql(before, dialect)
|
|
187
210
|
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
|
|
211
|
+
key = risk_key or ("removed_" + category if after_text is None else category)
|
|
189
212
|
risk = _RISK.get(key, ("medium", f"{category.title()} changed."))[0]
|
|
190
213
|
action = "removed" if after_text is None else "changed" if before_text else "added"
|
|
191
214
|
return SQLChange(
|
|
@@ -215,6 +238,48 @@ def _sequence_changes(
|
|
|
215
238
|
return changes
|
|
216
239
|
|
|
217
240
|
|
|
241
|
+
def _join_changes(
|
|
242
|
+
before: list[tuple[str, str]],
|
|
243
|
+
after: list[tuple[str, str]],
|
|
244
|
+
dialect: str | None,
|
|
245
|
+
) -> list[SQLChange]:
|
|
246
|
+
"""Pair joins by target and predicate so join-type changes stay semantic."""
|
|
247
|
+
before_by_signature = Counter(signature for signature, _ in before)
|
|
248
|
+
after_by_signature = Counter(signature for signature, _ in after)
|
|
249
|
+
changes: list[SQLChange] = []
|
|
250
|
+
for signature in before_by_signature.keys() & after_by_signature.keys():
|
|
251
|
+
before_rendered = [rendered for item, rendered in before if item == signature]
|
|
252
|
+
after_rendered = [rendered for item, rendered in after if item == signature]
|
|
253
|
+
for before_rendered_value, after_rendered_value in zip(
|
|
254
|
+
before_rendered, after_rendered, strict=True
|
|
255
|
+
):
|
|
256
|
+
if before_rendered_value != after_rendered_value:
|
|
257
|
+
changes.append(
|
|
258
|
+
_change(
|
|
259
|
+
"join",
|
|
260
|
+
before_rendered_value,
|
|
261
|
+
after_rendered_value,
|
|
262
|
+
dialect,
|
|
263
|
+
risk_key=(
|
|
264
|
+
"join_type"
|
|
265
|
+
if before_rendered_value.split(" ", 1)[0].upper()
|
|
266
|
+
in {"LEFT", "RIGHT", "FULL"}
|
|
267
|
+
and after_rendered_value.split(" ", 1)[0].upper() == "INNER"
|
|
268
|
+
else None
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
removed = list((before_by_signature - after_by_signature).elements())
|
|
273
|
+
added = list((after_by_signature - before_by_signature).elements())
|
|
274
|
+
for signature in removed:
|
|
275
|
+
rendered = next(rendered for item, rendered in before if item == signature)
|
|
276
|
+
changes.append(_change("join", rendered, None, dialect))
|
|
277
|
+
for signature in added:
|
|
278
|
+
rendered = next(rendered for item, rendered in after if item == signature)
|
|
279
|
+
changes.append(_change("join", None, rendered, dialect))
|
|
280
|
+
return changes
|
|
281
|
+
|
|
282
|
+
|
|
218
283
|
def explain_sql_change(
|
|
219
284
|
before: str,
|
|
220
285
|
after: str,
|
|
@@ -230,7 +295,13 @@ def explain_sql_change(
|
|
|
230
295
|
after_features = _features(after_tree, dialect)
|
|
231
296
|
changes: list[SQLChange] = []
|
|
232
297
|
|
|
233
|
-
|
|
298
|
+
before_joins = before_features["joins"]
|
|
299
|
+
after_joins = after_features["joins"]
|
|
300
|
+
if not isinstance(before_joins, list) or not isinstance(after_joins, list):
|
|
301
|
+
raise UnsupportedSQL("Unable to compare joins")
|
|
302
|
+
changes.extend(_join_changes(before_joins, after_joins, dialect))
|
|
303
|
+
|
|
304
|
+
sequence_categories = ("select", "group_by", "aggregation", "order_by")
|
|
234
305
|
for category in sequence_categories:
|
|
235
306
|
before_values = before_features[category]
|
|
236
307
|
after_values = after_features[category]
|
|
@@ -256,8 +327,19 @@ def explain_sql_change(
|
|
|
256
327
|
for change in changes:
|
|
257
328
|
if severity[change.risk] > severity[overall]:
|
|
258
329
|
overall = change.risk
|
|
330
|
+
join_type_change = (
|
|
331
|
+
change.category == "join"
|
|
332
|
+
and change.before is not None
|
|
333
|
+
and change.after is not None
|
|
334
|
+
and change.before.split(" ", 1)[0].upper() in {"LEFT", "RIGHT", "FULL"}
|
|
335
|
+
and change.after.split(" ", 1)[0].upper() == "INNER"
|
|
336
|
+
)
|
|
259
337
|
reason = _RISK.get(
|
|
260
|
-
(
|
|
338
|
+
(
|
|
339
|
+
"join_type"
|
|
340
|
+
if join_type_change
|
|
341
|
+
else ("removed_" + change.category if change.after is None else change.category)
|
|
342
|
+
),
|
|
261
343
|
("medium", ""),
|
|
262
344
|
)[1]
|
|
263
345
|
if reason and reason not in reasons:
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from sql_diff_explainer import SQLParseError, UnsupportedSQL, explain_diff, explain_sql_change
|
|
7
|
+
from sql_diff_explainer.cli import main as cli_main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_explain_diff_returns_unified_diff() -> None:
|
|
11
|
+
result = explain_diff("CREATE TABLE users;", "CREATE TABLE accounts;")
|
|
12
|
+
|
|
13
|
+
assert "--- before.sql" in result
|
|
14
|
+
assert "+++ after.sql" in result
|
|
15
|
+
assert "-CREATE TABLE users;" in result
|
|
16
|
+
assert "+CREATE TABLE accounts;" in result
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_explain_sql_change_detects_core_query_features() -> None:
|
|
20
|
+
before = (
|
|
21
|
+
"SELECT DISTINCT a, COUNT(*) FROM users u WHERE active = 1 "
|
|
22
|
+
"GROUP BY a ORDER BY a LIMIT 10"
|
|
23
|
+
)
|
|
24
|
+
after = (
|
|
25
|
+
"SELECT a, SUM(amount) FROM users u JOIN orders o ON u.id = o.user_id "
|
|
26
|
+
"WHERE active = 1 AND region = 'EU' GROUP BY a ORDER BY a DESC LIMIT 20 OFFSET 5"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
result = explain_sql_change(before, after)
|
|
30
|
+
categories = {change.category for change in result.changes}
|
|
31
|
+
|
|
32
|
+
assert {"select", "join", "aggregation", "distinct", "limit", "offset"}.issubset(categories)
|
|
33
|
+
assert result.risk == "high"
|
|
34
|
+
assert result.risk_level == "high"
|
|
35
|
+
assert result.to_dict()["changes"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_explain_sql_change_reports_filter_removal_as_high_risk() -> None:
|
|
39
|
+
result = explain_sql_change(
|
|
40
|
+
"SELECT id FROM users WHERE active = TRUE",
|
|
41
|
+
"SELECT id FROM users",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assert result.risk == "high"
|
|
45
|
+
assert any(change.category == "where" and change.risk == "high" for change in result.changes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_explain_sql_change_accepts_dialect() -> None:
|
|
49
|
+
result = explain_sql_change(
|
|
50
|
+
"SELECT TOP 5 id FROM users",
|
|
51
|
+
"SELECT TOP 10 id FROM users",
|
|
52
|
+
dialect="tsql",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
assert result.changes[0].category == "limit"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_parse_and_unsupported_errors_are_clean() -> None:
|
|
59
|
+
with pytest.raises(SQLParseError):
|
|
60
|
+
explain_sql_change("SELECT FROM", "SELECT 1")
|
|
61
|
+
with pytest.raises(UnsupportedSQL):
|
|
62
|
+
explain_sql_change("CREATE TABLE users (id INT)", "SELECT 1")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_identical_queries_have_no_changes() -> None:
|
|
66
|
+
result = explain_sql_change("SELECT id FROM users", "SELECT id FROM users")
|
|
67
|
+
|
|
68
|
+
assert result.changes == []
|
|
69
|
+
assert result.risk == "low"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_join_type_change_is_one_semantic_change() -> None:
|
|
73
|
+
result = explain_sql_change(
|
|
74
|
+
"SELECT users.id FROM users LEFT JOIN orders ON users.id = orders.user_id",
|
|
75
|
+
"SELECT users.id FROM users INNER JOIN orders ON users.id = orders.user_id",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert len(result.changes) == 1
|
|
79
|
+
assert result.changes[0].category == "join"
|
|
80
|
+
assert result.changes[0].risk == "high"
|
|
81
|
+
assert result.risk_level == "high"
|
|
82
|
+
assert result.changes[0].before.startswith("LEFT JOIN")
|
|
83
|
+
assert result.changes[0].after.startswith("INNER JOIN")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize(
|
|
87
|
+
("before", "after", "threshold", "expected_status"),
|
|
88
|
+
[
|
|
89
|
+
("SELECT id FROM users", "SELECT name FROM users", "high", 0),
|
|
90
|
+
("SELECT id FROM users", "SELECT name FROM users", "medium", 1),
|
|
91
|
+
("SELECT id FROM users", "SELECT name FROM users", "low", 1),
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
@pytest.mark.parametrize("output_format", ["text", "json"])
|
|
95
|
+
def test_cli_fail_on_risk_thresholds_keep_report_output(
|
|
96
|
+
tmp_path, monkeypatch, capsys, before, after, threshold, expected_status, output_format
|
|
97
|
+
) -> None:
|
|
98
|
+
before_path = tmp_path / "before.sql"
|
|
99
|
+
after_path = tmp_path / "after.sql"
|
|
100
|
+
before_path.write_text(before, encoding="utf-8")
|
|
101
|
+
after_path.write_text(after, encoding="utf-8")
|
|
102
|
+
monkeypatch.setattr(
|
|
103
|
+
sys,
|
|
104
|
+
"argv",
|
|
105
|
+
[
|
|
106
|
+
"sql-diff-explainer",
|
|
107
|
+
str(before_path),
|
|
108
|
+
str(after_path),
|
|
109
|
+
"--fail-on-risk",
|
|
110
|
+
threshold,
|
|
111
|
+
"--format",
|
|
112
|
+
output_format,
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert cli_main() == expected_status
|
|
117
|
+
stdout = capsys.readouterr().out
|
|
118
|
+
if output_format == "json":
|
|
119
|
+
report = json.loads(stdout)
|
|
120
|
+
assert report["risk"] == "medium"
|
|
121
|
+
assert report["changes"]
|
|
122
|
+
else:
|
|
123
|
+
assert "SQL change(s) detected; overall risk is medium." in stdout
|
|
124
|
+
assert "Risk: MEDIUM" in stdout
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize("output_format", ["text", "json"])
|
|
128
|
+
def test_cli_fail_on_risk_allows_no_changes(tmp_path, monkeypatch, capsys, output_format) -> None:
|
|
129
|
+
query = "SELECT id FROM users"
|
|
130
|
+
before_path = tmp_path / "before.sql"
|
|
131
|
+
after_path = tmp_path / "after.sql"
|
|
132
|
+
before_path.write_text(query, encoding="utf-8")
|
|
133
|
+
after_path.write_text(query, encoding="utf-8")
|
|
134
|
+
monkeypatch.setattr(
|
|
135
|
+
sys,
|
|
136
|
+
"argv",
|
|
137
|
+
[
|
|
138
|
+
"sql-diff-explainer",
|
|
139
|
+
str(before_path),
|
|
140
|
+
str(after_path),
|
|
141
|
+
"--fail-on-risk",
|
|
142
|
+
"low",
|
|
143
|
+
"--format",
|
|
144
|
+
output_format,
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
assert cli_main() == 0
|
|
149
|
+
stdout = capsys.readouterr().out
|
|
150
|
+
if output_format == "json":
|
|
151
|
+
report = json.loads(stdout)
|
|
152
|
+
assert report["risk"] == "low"
|
|
153
|
+
assert report["changes"] == []
|
|
154
|
+
else:
|
|
155
|
+
assert stdout.strip() == "No structural SQL changes detected."
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_cli_fail_on_risk_preserves_sql_error_exit_status(
|
|
159
|
+
tmp_path, monkeypatch, capsys
|
|
160
|
+
) -> None:
|
|
161
|
+
before_path = tmp_path / "before.sql"
|
|
162
|
+
after_path = tmp_path / "after.sql"
|
|
163
|
+
before_path.write_text("SELECT FROM", encoding="utf-8")
|
|
164
|
+
after_path.write_text("SELECT id FROM users", encoding="utf-8")
|
|
165
|
+
monkeypatch.setattr(
|
|
166
|
+
sys,
|
|
167
|
+
"argv",
|
|
168
|
+
[
|
|
169
|
+
"sql-diff-explainer",
|
|
170
|
+
str(before_path),
|
|
171
|
+
str(after_path),
|
|
172
|
+
"--fail-on-risk",
|
|
173
|
+
"low",
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
assert cli_main() == 2
|
|
178
|
+
captured = capsys.readouterr()
|
|
179
|
+
assert captured.out == ""
|
|
180
|
+
assert captured.err.startswith("error: Unable to parse SQL:")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_cli_fail_on_risk_preserves_unreadable_file_exit_status(
|
|
184
|
+
tmp_path, monkeypatch, capsys
|
|
185
|
+
) -> None:
|
|
186
|
+
before_path = tmp_path / "missing.sql"
|
|
187
|
+
after_path = tmp_path / "after.sql"
|
|
188
|
+
after_path.write_text("SELECT id FROM users", encoding="utf-8")
|
|
189
|
+
monkeypatch.setattr(
|
|
190
|
+
sys,
|
|
191
|
+
"argv",
|
|
192
|
+
[
|
|
193
|
+
"sql-diff-explainer",
|
|
194
|
+
str(before_path),
|
|
195
|
+
str(after_path),
|
|
196
|
+
"--fail-on-risk",
|
|
197
|
+
"low",
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
assert cli_main() == 2
|
|
202
|
+
captured = capsys.readouterr()
|
|
203
|
+
assert captured.out == ""
|
|
204
|
+
assert captured.err.startswith("error:")
|
|
@@ -1,61 +0,0 @@
|
|
|
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.
|
|
@@ -1,47 +0,0 @@
|
|
|
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.
|
|
@@ -1,64 +0,0 @@
|
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|