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.
Files changed (23) hide show
  1. sql_diff_explainer-0.1.2/PKG-INFO +147 -0
  2. sql_diff_explainer-0.1.2/README.md +133 -0
  3. sql_diff_explainer-0.1.2/examples/aggregation_after.sql +3 -0
  4. sql_diff_explainer-0.1.2/examples/aggregation_before.sql +3 -0
  5. sql_diff_explainer-0.1.2/examples/distinct_after.sql +4 -0
  6. sql_diff_explainer-0.1.2/examples/distinct_before.sql +4 -0
  7. sql_diff_explainer-0.1.2/examples/filter_after.sql +4 -0
  8. sql_diff_explainer-0.1.2/examples/filter_before.sql +3 -0
  9. sql_diff_explainer-0.1.2/examples/join_after.sql +3 -0
  10. sql_diff_explainer-0.1.2/examples/join_before.sql +3 -0
  11. {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/pyproject.toml +1 -1
  12. {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/src/sql_diff_explainer/__init__.py +1 -1
  13. {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/src/sql_diff_explainer/cli.py +13 -0
  14. {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/src/sql_diff_explainer/core.py +87 -5
  15. sql_diff_explainer-0.1.2/tests/test_core.py +204 -0
  16. sql_diff_explainer-0.1.0/PKG-INFO +0 -61
  17. sql_diff_explainer-0.1.0/README.md +0 -47
  18. sql_diff_explainer-0.1.0/tests/test_core.py +0 -64
  19. {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/.github/workflows/ci.yml +0 -0
  20. {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/.gitignore +0 -0
  21. {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/LICENSE +0 -0
  22. {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.2}/examples/after.sql +0 -0
  23. {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.
@@ -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.0"
7
+ version = "0.1.2"
8
8
  description = "Explain SQL schema and migration differences"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -23,4 +23,4 @@ __all__ = [
23
23
  "explain_diff",
24
24
  "explain_sql_change",
25
25
  ]
26
- __version__ = "0.2.0"
26
+ __version__ = "0.1.2"
@@ -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 _features(tree: Any, dialect: str | None) -> dict[str, list[str] | str | bool | None]:
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 = [_sql(join, dialect) or "" for join in select.args.get("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
- sequence_categories = ("select", "joins", "group_by", "aggregation", "order_by")
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
- ("removed_" + change.category if change.after is None else change.category),
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"