sql-diff-explainer 0.1.0__tar.gz → 0.1.1__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 → sql_diff_explainer-0.1.1}/PKG-INFO +5 -1
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/README.md +4 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/pyproject.toml +1 -1
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/src/sql_diff_explainer/__init__.py +1 -1
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/src/sql_diff_explainer/core.py +87 -5
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/tests/test_core.py +15 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/.github/workflows/ci.yml +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/.gitignore +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/LICENSE +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/examples/after.sql +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/examples/before.sql +0 -0
- {sql_diff_explainer-0.1.0 → sql_diff_explainer-0.1.1}/src/sql_diff_explainer/cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sql-diff-explainer
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Explain SQL schema and migration differences
|
|
5
5
|
Author: zmodz1010
|
|
6
6
|
License: MIT
|
|
@@ -22,6 +22,10 @@ implementation changes. It reports changes to projections, joins, filters,
|
|
|
22
22
|
grouping, aggregation, ordering, pagination, and `DISTINCT`, with a static
|
|
23
23
|
low/medium/high risk classification.
|
|
24
24
|
|
|
25
|
+
The current patch release is **0.1.1**. It includes the stable `risk_level`
|
|
26
|
+
compatibility property, semantic join-type pairing, and high-risk classification
|
|
27
|
+
for outer-to-inner join changes.
|
|
28
|
+
|
|
25
29
|
## Development
|
|
26
30
|
|
|
27
31
|
This project uses a `src/` layout and requires Python 3.10 or newer.
|
|
@@ -8,6 +8,10 @@ implementation changes. It reports changes to projections, joins, filters,
|
|
|
8
8
|
grouping, aggregation, ordering, pagination, and `DISTINCT`, with a static
|
|
9
9
|
low/medium/high risk classification.
|
|
10
10
|
|
|
11
|
+
The current patch release is **0.1.1**. It includes the stable `risk_level`
|
|
12
|
+
compatibility property, semantic join-type pairing, and high-risk classification
|
|
13
|
+
for outer-to-inner join changes.
|
|
14
|
+
|
|
11
15
|
## Development
|
|
12
16
|
|
|
13
17
|
This project uses a `src/` layout and requires Python 3.10 or newer.
|
|
@@ -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:
|
|
@@ -27,6 +27,7 @@ def test_explain_sql_change_detects_core_query_features() -> None:
|
|
|
27
27
|
|
|
28
28
|
assert {"select", "join", "aggregation", "distinct", "limit", "offset"}.issubset(categories)
|
|
29
29
|
assert result.risk == "high"
|
|
30
|
+
assert result.risk_level == "high"
|
|
30
31
|
assert result.to_dict()["changes"]
|
|
31
32
|
|
|
32
33
|
|
|
@@ -62,3 +63,17 @@ def test_identical_queries_have_no_changes() -> None:
|
|
|
62
63
|
|
|
63
64
|
assert result.changes == []
|
|
64
65
|
assert result.risk == "low"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_join_type_change_is_one_semantic_change() -> None:
|
|
69
|
+
result = explain_sql_change(
|
|
70
|
+
"SELECT users.id FROM users LEFT JOIN orders ON users.id = orders.user_id",
|
|
71
|
+
"SELECT users.id FROM users INNER JOIN orders ON users.id = orders.user_id",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert len(result.changes) == 1
|
|
75
|
+
assert result.changes[0].category == "join"
|
|
76
|
+
assert result.changes[0].risk == "high"
|
|
77
|
+
assert result.risk_level == "high"
|
|
78
|
+
assert result.changes[0].before.startswith("LEFT JOIN")
|
|
79
|
+
assert result.changes[0].after.startswith("INNER JOIN")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|