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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sql-diff-explainer
3
- Version: 0.1.0
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.
@@ -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.1"
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.1"
@@ -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:
@@ -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")