sql-diff-explainer 0.1.4__py3-none-any.whl → 0.1.5__py3-none-any.whl

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/cli.py CHANGED
@@ -5,6 +5,7 @@ import json
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
+ from .analysis import MigrationAnalysisResult, analyze_migration
8
9
  from .core import (
9
10
  SQLDiffError,
10
11
  SQLDiffResult,
@@ -37,6 +38,12 @@ def main() -> int:
37
38
  metavar="REF",
38
39
  help="Compare Git-reported .sql changes from REF to the current worktree",
39
40
  )
41
+ parser.add_argument(
42
+ "--analyze-migration",
43
+ type=Path,
44
+ metavar="FILE",
45
+ help="Analyze common ALTER TABLE operations in a migration script",
46
+ )
40
47
  parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
41
48
  parser.add_argument(
42
49
  "--format",
@@ -54,22 +61,37 @@ def main() -> int:
54
61
 
55
62
  directory_mode = args.compare_dirs is not None
56
63
  git_mode = args.git_base is not None
57
- selected_modes = sum((directory_mode, git_mode))
64
+ migration_mode = args.analyze_migration is not None
65
+ selected_modes = sum((directory_mode, git_mode, migration_mode))
58
66
  if selected_modes and (args.before is not None or args.after is not None):
59
- parser.error("Use only one of --git-base, --compare-dirs, or the two SQL file arguments")
67
+ parser.error(
68
+ "Use only one of --git-base, --compare-dirs, --analyze-migration, "
69
+ "or the two SQL file arguments"
70
+ )
60
71
  if selected_modes > 1:
61
- parser.error("Use either --git-base or --compare-dirs, not both")
72
+ parser.error("Choose only one of --git-base, --compare-dirs, or --analyze-migration")
62
73
  if not selected_modes and (args.before is None or args.after is None):
63
74
  parser.error(
64
- "Provide two SQL files, use --compare-dirs BEFORE_DIR AFTER_DIR, or use --git-base REF"
75
+ "Provide two SQL files, use --compare-dirs BEFORE_DIR AFTER_DIR, "
76
+ "--git-base REF, or --analyze-migration FILE"
65
77
  )
78
+ if migration_mode and args.output_format in {"sarif", "html"}:
79
+ parser.error("--analyze-migration supports text and JSON output")
66
80
 
67
81
  try:
68
- if git_mode:
82
+ if migration_mode:
83
+ result: SQLDiffResult | SQLDirectoryDiffResult | MigrationAnalysisResult = (
84
+ analyze_migration(
85
+ args.analyze_migration.read_text(encoding="utf-8"),
86
+ dialect=args.dialect,
87
+ )
88
+ )
89
+ report_path = ""
90
+ elif git_mode:
69
91
  result = compare_sql_git(args.git_base, dialect=args.dialect)
70
92
  report_path = ""
71
93
  elif directory_mode:
72
- result: SQLDiffResult | SQLDirectoryDiffResult = compare_sql_directories(
94
+ result = compare_sql_directories(
73
95
  args.compare_dirs[0],
74
96
  args.compare_dirs[1],
75
97
  dialect=args.dialect,
@@ -89,32 +111,118 @@ def main() -> int:
89
111
  if args.output_format == "json":
90
112
  report = json.dumps(result.to_dict(), indent=2)
91
113
  elif args.output_format == "sarif":
114
+ if isinstance(result, MigrationAnalysisResult):
115
+ raise AssertionError("Migration SARIF output should be rejected before analysis")
92
116
  report = json.dumps(to_sarif(result, path=report_path or "after.sql"), indent=2)
93
117
  elif args.output_format == "html":
118
+ if isinstance(result, MigrationAnalysisResult):
119
+ raise AssertionError("Migration HTML output should be rejected before analysis")
94
120
  report = render_html_report(result, path=report_path or "after.sql")
95
121
  else:
96
- lines = [result.summary]
97
- if isinstance(result, SQLDiffResult):
98
- if result.risk_reasons:
99
- lines.append(f"Risk: {result.risk.upper()} - {' '.join(result.risk_reasons)}")
100
- file_results = [("", result)]
101
- else:
102
- file_results = [(item.path, item.result) for item in result.files]
103
- lines.extend(f"Removed SQL file: {path}" for path in result.unmatched_before)
104
- lines.extend(f"Added SQL file: {path}" for path in result.unmatched_after)
122
+ if isinstance(result, MigrationAnalysisResult):
123
+ lines = [result.summary]
124
+ for operation in result.operations:
125
+ lines.append(
126
+ f"- [{operation.risk.upper()}] {operation.kind}: "
127
+ f"{operation.table}.{operation.column}"
128
+ )
129
+ if operation.before is not None:
130
+ lines.append(f" before: {operation.before}")
131
+ if operation.after is not None:
132
+ lines.append(f" after: {operation.after}")
105
133
  lines.extend(
106
- f"Renamed SQL file: {before} -> {after}"
107
- for before, after in result.renamed_files
134
+ f"- [UNSUPPORTED] {item.reason}: {item.statement}"
135
+ for item in result.unsupported
108
136
  )
109
- for file_path, file_result in file_results:
110
- if file_path and file_result.changes:
111
- lines.append(f"{file_path}:")
112
- for change in file_result.changes:
113
- lines.append(f"- [{change.risk.upper()}] {change.description}")
114
- if change.before is not None:
115
- lines.append(f" before: {change.before}")
116
- if change.after is not None:
117
- lines.append(f" after: {change.after}")
137
+ else:
138
+ lines = [result.summary]
139
+ if isinstance(result, SQLDiffResult):
140
+ if result.risk_reasons:
141
+ lines.append(f"Risk: {result.risk.upper()} - {' '.join(result.risk_reasons)}")
142
+ file_results = [("", result)]
143
+ else:
144
+ file_results = [(item.path, item.result) for item in result.files]
145
+ lines.extend(f"Removed SQL file: {path}" for path in result.unmatched_before)
146
+ lines.extend(f"Added SQL file: {path}" for path in result.unmatched_after)
147
+ lines.extend(
148
+ f"Renamed SQL file: {before} -> {after}"
149
+ for before, after in result.renamed_files
150
+ )
151
+ for file_path, file_result in file_results:
152
+ if file_path and (
153
+ file_result.changes
154
+ or file_result.validation_checks
155
+ or (
156
+ file_result.lineage
157
+ and (
158
+ file_result.lineage.get("changes")
159
+ or file_result.lineage.get("supported") is False
160
+ )
161
+ )
162
+ or (
163
+ file_result.schema_contract
164
+ and (
165
+ file_result.schema_contract.get("changes")
166
+ or file_result.schema_contract.get("supported") is False
167
+ )
168
+ )
169
+ ):
170
+ lines.append(f"{file_path}:")
171
+ for change in file_result.changes:
172
+ lines.append(f"- [{change.risk.upper()}] {change.description}")
173
+ if change.before is not None:
174
+ lines.append(f" before: {change.before}")
175
+ if change.after is not None:
176
+ lines.append(f" after: {change.after}")
177
+ if file_result.schema_contract is not None:
178
+ contract = file_result.schema_contract
179
+ if contract.get("supported") is False:
180
+ lines.append(
181
+ " Output-schema contract unsupported: "
182
+ f"{contract.get('unsupported_reason')}"
183
+ )
184
+ for item in contract.get("changes", []):
185
+ old = item["before"]
186
+ new = item["after"]
187
+ old_label = (
188
+ f"{old['name']} ({old['type'] or 'type unknown'})" if old else "absent"
189
+ )
190
+ new_label = (
191
+ f"{new['name']} ({new['type'] or 'type unknown'})" if new else "absent"
192
+ )
193
+ lines.append(
194
+ f" Output schema {item['kind']}: {old_label} -> {new_label}"
195
+ )
196
+ if file_result.lineage is not None:
197
+ lineage = file_result.lineage
198
+ if lineage.get("supported") is False:
199
+ lines.append(
200
+ f" CTE lineage unsupported: {lineage.get('unsupported_reason')}"
201
+ )
202
+ for item in lineage.get("changes", []):
203
+ old = item.get("before")
204
+ new = item.get("after")
205
+ old_sources = ", ".join(old["sources"]) if old else "none"
206
+ new_sources = ", ".join(new["sources"]) if new else "none"
207
+ output_name = (new or old)["name"]
208
+ old_expression = old["expression"] if old else "absent"
209
+ new_expression = new["expression"] if new else "absent"
210
+ lines.append(
211
+ f" CTE lineage {item['kind']} for {output_name}: "
212
+ f"{old_expression} [{old_sources}] -> "
213
+ f"{new_expression} [{new_sources}]"
214
+ )
215
+ for check in file_result.validation_checks:
216
+ lines.append(
217
+ f" Validation suggestion (not executed, {check['kind']}): "
218
+ f"{check['description']}"
219
+ )
220
+ if check["sql"]:
221
+ lines.append(f" SQL:\n{check['sql']}")
222
+ for item in check["checklist"]:
223
+ lines.append(f" Checklist: {item}")
224
+ if check["unsupported_reason"]:
225
+ lines.append(f" SQL not generated: {check['unsupported_reason']}")
118
226
  report = "\n".join(lines)
119
227
 
120
228
  try:
@@ -127,12 +235,15 @@ def main() -> int:
127
235
  print(f"error: Unable to write report: {exc}", file=sys.stderr)
128
236
  return 2
129
237
 
130
- has_changes = (
131
- bool(result.changes)
132
- if isinstance(result, SQLDiffResult)
133
- else any(item.result.changes for item in result.files)
134
- or bool(result.unmatched_before or result.unmatched_after or result.renamed_files)
135
- )
238
+ if isinstance(result, SQLDiffResult):
239
+ has_changes = bool(result.changes)
240
+ elif isinstance(result, SQLDirectoryDiffResult):
241
+ has_changes = (
242
+ any(item.result.changes for item in result.files)
243
+ or bool(result.unmatched_before or result.unmatched_after or result.renamed_files)
244
+ )
245
+ else:
246
+ has_changes = bool(result.operations or result.unsupported)
136
247
  if (
137
248
  args.fail_on_risk is not None
138
249
  and has_changes
@@ -83,6 +83,9 @@ class SQLDiffResult:
83
83
  risk: str = "low"
84
84
  risk_reasons: list[str] = field(default_factory=list)
85
85
  dialect: str | None = None
86
+ schema_contract: dict[str, object] | None = None
87
+ lineage: dict[str, object] | None = None
88
+ validation_checks: list[dict[str, object]] = field(default_factory=list)
86
89
 
87
90
  @property
88
91
  def risk_level(self) -> str:
@@ -103,6 +106,9 @@ class SQLDiffResult:
103
106
  "risk_reasons": list(self.risk_reasons),
104
107
  "dialect": self.dialect,
105
108
  "changes": [change.to_dict() for change in self.changes],
109
+ "schema_contract": self.schema_contract,
110
+ "lineage": self.lineage,
111
+ "validation_checks": list(self.validation_checks),
106
112
  }
107
113
 
108
114
 
@@ -413,7 +419,91 @@ def explain_sql_change(
413
419
  )[1]
414
420
  if reason and reason not in reasons:
415
421
  reasons.append(reason)
416
- return SQLDiffResult(changes=changes, risk=overall, risk_reasons=reasons, dialect=dialect)
422
+ from .analysis import (
423
+ CTELineageDiff,
424
+ SchemaContractDiff,
425
+ compare_output_schema,
426
+ generate_validation_suggestions,
427
+ trace_cte_lineage,
428
+ )
429
+
430
+ schema_contract: SchemaContractDiff
431
+ lineage: CTELineageDiff | None = None
432
+ if isinstance(parser, SqlglotParser):
433
+ schema_contract = compare_output_schema(before, after, dialect=dialect)
434
+ if before_tree.args.get("with_") is not None or after_tree.args.get("with_") is not None:
435
+ lineage = trace_cte_lineage(before, after, dialect=dialect)
436
+ else:
437
+ schema_contract = SchemaContractDiff(
438
+ supported=False,
439
+ unsupported_reason="Output-schema analysis requires the SQLGlot parser.",
440
+ )
441
+ if schema_contract.supported:
442
+ for item in schema_contract.changes:
443
+ if item.kind != "reordered" or item.before is None or item.after is None:
444
+ continue
445
+ before_column = item.before
446
+ after_column = item.after
447
+ changes.append(
448
+ SQLChange(
449
+ category="schema_contract",
450
+ description=f"Output column {after_column.name} changed position.",
451
+ before=f"{before_column.name} at position {before_column.position + 1}",
452
+ after=f"{after_column.name} at position {after_column.position + 1}",
453
+ risk="medium",
454
+ )
455
+ )
456
+ if severity["medium"] > severity[overall]:
457
+ overall = "medium"
458
+ reason = "Reordering output columns changes the result contract."
459
+ if reason not in reasons:
460
+ reasons.append(reason)
461
+ if lineage is not None and lineage.supported:
462
+ for item in lineage.changes:
463
+ before_lineage = item.before
464
+ after_lineage = item.after
465
+ before_text = (
466
+ f"{before_lineage.expression} <- {', '.join(before_lineage.sources)}"
467
+ if before_lineage
468
+ else None
469
+ )
470
+ after_text = (
471
+ f"{after_lineage.expression} <- {', '.join(after_lineage.sources)}"
472
+ if after_lineage
473
+ else None
474
+ )
475
+ risk = "high" if item.kind == "removed" else "medium"
476
+ changes.append(
477
+ SQLChange(
478
+ category="cte_lineage",
479
+ description=f"CTE lineage {item.kind} for output "
480
+ f"{(after_lineage or before_lineage).name}.",
481
+ before=before_text,
482
+ after=after_text,
483
+ risk=risk,
484
+ )
485
+ )
486
+ if severity[risk] > severity[overall]:
487
+ overall = risk
488
+ reason = "A CTE source or expression change can alter a final output."
489
+ if reason not in reasons:
490
+ reasons.append(reason)
491
+ validation_checks = generate_validation_suggestions(
492
+ before,
493
+ after,
494
+ changes,
495
+ schema_contract,
496
+ dialect=dialect,
497
+ )
498
+ return SQLDiffResult(
499
+ changes=changes,
500
+ risk=overall,
501
+ risk_reasons=reasons,
502
+ dialect=dialect,
503
+ schema_contract=schema_contract.to_dict(),
504
+ lineage=lineage.to_dict() if lineage else None,
505
+ validation_checks=[item.to_dict() for item in validation_checks],
506
+ )
417
507
 
418
508
 
419
509
  def compare_sql_directories(
@@ -84,6 +84,117 @@ def to_sarif(
84
84
  },
85
85
  )
86
86
  )
87
+ contract = sql_result.schema_contract
88
+ if contract and contract.get("supported"):
89
+ risks = {
90
+ "added": "medium",
91
+ "removed": "high",
92
+ "renamed": "medium",
93
+ "reordered": "medium",
94
+ "changed": "medium",
95
+ "type_changed": "high",
96
+ }
97
+ for item in contract.get("changes", []):
98
+ kind = item["kind"]
99
+ before = item["before"]
100
+ after = item["after"]
101
+ before_label = (
102
+ f"{before['name']} ({before['type'] or 'type unknown'})"
103
+ if before
104
+ else "absent"
105
+ )
106
+ after_label = (
107
+ f"{after['name']} ({after['type'] or 'type unknown'})"
108
+ if after
109
+ else "absent"
110
+ )
111
+ rule_id = f"sql-schema-{kind.replace('_', '-')}"
112
+ add_rule(
113
+ rule_id,
114
+ f"Output column {kind.replace('_', ' ')}",
115
+ f"SQL output column {kind.replace('_', ' ')}",
116
+ )
117
+ sarif_results.append(
118
+ _sarif_result(
119
+ rule_id=rule_id,
120
+ risk=risks[kind],
121
+ message=(
122
+ f"Output column {kind.replace('_', ' ')}: "
123
+ f"{before_label} -> {after_label}."
124
+ ),
125
+ path=file_path,
126
+ properties={"schema_change": item},
127
+ )
128
+ )
129
+ elif contract:
130
+ rule_id = "sql-output-schema-unsupported"
131
+ add_rule(
132
+ rule_id,
133
+ "Output schema analysis unsupported",
134
+ "The output-schema contract could not be determined statically",
135
+ )
136
+ sarif_results.append(
137
+ _sarif_result(
138
+ rule_id=rule_id,
139
+ risk="low",
140
+ message=(
141
+ "Output-schema contract unsupported: "
142
+ f"{contract.get('unsupported_reason')}"
143
+ ),
144
+ path=file_path,
145
+ properties={
146
+ "analysis": "output_schema",
147
+ "supported": False,
148
+ "reason": contract.get("unsupported_reason"),
149
+ },
150
+ )
151
+ )
152
+ lineage = sql_result.lineage
153
+ if lineage and lineage.get("supported"):
154
+ for item in lineage.get("changes", []):
155
+ output = item["after"] or item["before"]
156
+ before = item["before"]
157
+ after = item["after"]
158
+ before_expression = before["expression"] if before else "absent"
159
+ after_expression = after["expression"] if after else "absent"
160
+ rule_id = "sql-cte-lineage-change"
161
+ add_rule(
162
+ rule_id,
163
+ "CTE lineage changed",
164
+ "Source columns or expressions contributing to an output changed",
165
+ )
166
+ sarif_results.append(
167
+ _sarif_result(
168
+ rule_id=rule_id,
169
+ risk="medium",
170
+ message=(
171
+ f"CTE lineage {item['kind']} for output {output['name']}: "
172
+ f"{before_expression} -> {after_expression}."
173
+ ),
174
+ path=file_path,
175
+ properties={"lineage_change": item},
176
+ )
177
+ )
178
+ elif lineage:
179
+ rule_id = "sql-cte-lineage-unsupported"
180
+ add_rule(
181
+ rule_id,
182
+ "CTE lineage analysis unsupported",
183
+ "Source-to-output lineage could not be determined statically",
184
+ )
185
+ sarif_results.append(
186
+ _sarif_result(
187
+ rule_id=rule_id,
188
+ risk="low",
189
+ message=f"CTE lineage unsupported: {lineage.get('unsupported_reason')}",
190
+ path=file_path,
191
+ properties={
192
+ "analysis": "cte_lineage",
193
+ "supported": False,
194
+ "reason": lineage.get("unsupported_reason"),
195
+ },
196
+ )
197
+ )
87
198
 
88
199
  if isinstance(result, SQLDiffResult):
89
200
  add_changes(result, path)
@@ -143,6 +254,7 @@ def render_html_report(
143
254
  ) -> str:
144
255
  """Render a self-contained HTML report, escaping all SQL and path content."""
145
256
  rows: list[str] = []
257
+ details: list[str] = []
146
258
 
147
259
  def add_changes(sql_result: SQLDiffResult, file_path: str) -> None:
148
260
  for change in sql_result.changes:
@@ -155,6 +267,80 @@ def render_html_report(
155
267
  f"<td><pre>{escape(change.after or '', quote=True)}</pre></td>"
156
268
  "</tr>"
157
269
  )
270
+ prefix = f"{escape(file_path, quote=True)}: " if file_path else ""
271
+ contract = sql_result.schema_contract
272
+ if contract:
273
+ if contract.get("supported") is False:
274
+ details.append(
275
+ "<li>"
276
+ f"{prefix}output-schema contract unsupported: "
277
+ f"{escape(str(contract.get('unsupported_reason')), quote=True)}"
278
+ "</li>"
279
+ )
280
+ for item in contract.get("changes", []):
281
+ old = item["before"]
282
+ new = item["after"]
283
+ old_label = (
284
+ f"{old['name']} ({old['type'] or 'type unknown'})" if old else "absent"
285
+ )
286
+ new_label = (
287
+ f"{new['name']} ({new['type'] or 'type unknown'})" if new else "absent"
288
+ )
289
+ details.append(
290
+ "<li>"
291
+ f"{prefix}output column "
292
+ f"{escape(item['kind'].replace('_', ' '), quote=True)}: "
293
+ f"{escape(old_label, quote=True)} &rarr; "
294
+ f"{escape(new_label, quote=True)}"
295
+ "</li>"
296
+ )
297
+ lineage = sql_result.lineage
298
+ if lineage:
299
+ if lineage.get("supported") is False:
300
+ details.append(
301
+ "<li>"
302
+ f"{prefix}CTE lineage unsupported: "
303
+ f"{escape(str(lineage.get('unsupported_reason')), quote=True)}"
304
+ "</li>"
305
+ )
306
+ for item in lineage.get("changes", []):
307
+ old = item.get("before")
308
+ new = item.get("after")
309
+ name = (new or old)["name"]
310
+ old_sources = ", ".join(old["sources"]) if old else "none"
311
+ new_sources = ", ".join(new["sources"]) if new else "none"
312
+ old_expression = old["expression"] if old else "absent"
313
+ new_expression = new["expression"] if new else "absent"
314
+ details.append(
315
+ "<li>"
316
+ f"{prefix}CTE lineage {escape(item['kind'], quote=True)} for "
317
+ f"{escape(name, quote=True)}: "
318
+ f"{escape(old_expression, quote=True)} [{escape(old_sources, quote=True)}] "
319
+ f"&rarr; {escape(new_expression, quote=True)} "
320
+ f"[{escape(new_sources, quote=True)}]"
321
+ "</li>"
322
+ )
323
+ for check in sql_result.validation_checks:
324
+ details.append(
325
+ "<li>"
326
+ f"{prefix}validation ({escape(check['kind'], quote=True)}): "
327
+ f"{escape(check['description'], quote=True)}"
328
+ + "".join(
329
+ f"<ul><li>{escape(item, quote=True)}</li></ul>"
330
+ for item in check["checklist"]
331
+ )
332
+ + "</li>"
333
+ )
334
+ if check["sql"]:
335
+ details.append(
336
+ "<li><strong>Suggested SQL (not executed)</strong>"
337
+ f"<pre>{escape(check['sql'], quote=True)}</pre></li>"
338
+ )
339
+ if check["unsupported_reason"]:
340
+ details.append(
341
+ "<li>SQL not generated: "
342
+ f"{escape(check['unsupported_reason'], quote=True)}</li>"
343
+ )
158
344
 
159
345
  if isinstance(result, SQLDiffResult):
160
346
  risk = result.risk
@@ -193,6 +379,11 @@ def render_html_report(
193
379
 
194
380
  if not rows:
195
381
  rows.append('<tr><td colspan="5">No structural SQL changes detected.</td></tr>')
382
+ details_section = (
383
+ ["<section><h2>Analysis details</h2><ul>", *details, "</ul></section>"]
384
+ if details
385
+ else []
386
+ )
196
387
  return "\n".join(
197
388
  [
198
389
  "<!doctype html>",
@@ -213,7 +404,9 @@ def render_html_report(
213
404
  "<table><thead><tr><th>File</th><th>Risk</th><th>Change</th>"
214
405
  "<th>Before</th><th>After</th></tr></thead><tbody>",
215
406
  *rows,
216
- "</tbody></table></body></html>",
407
+ "</tbody></table>",
408
+ *details_section,
409
+ "</body></html>",
217
410
  "",
218
411
  ]
219
412
  )