sql-diff-explainer 0.1.2__py3-none-any.whl → 0.1.4__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.
@@ -4,23 +4,34 @@ from .core import (
4
4
  SQLChange,
5
5
  SQLDiffError,
6
6
  SQLDiffResult,
7
+ SQLDirectoryDiffResult,
8
+ SQLFileDiff,
7
9
  SqlglotParser,
8
10
  SQLParseError,
9
11
  SQLParser,
10
12
  UnsupportedSQL,
13
+ compare_sql_directories,
14
+ compare_sql_git,
11
15
  explain_diff,
12
16
  explain_sql_change,
13
17
  )
18
+ from .reports import render_html_report, to_sarif
14
19
 
15
20
  __all__ = [
16
21
  "SQLChange",
22
+ "SQLDirectoryDiffResult",
17
23
  "SQLDiffError",
18
24
  "SQLDiffResult",
25
+ "SQLFileDiff",
19
26
  "SQLParseError",
20
27
  "SQLParser",
21
28
  "SqlglotParser",
22
29
  "UnsupportedSQL",
30
+ "compare_sql_directories",
31
+ "compare_sql_git",
23
32
  "explain_diff",
24
33
  "explain_sql_change",
34
+ "render_html_report",
35
+ "to_sarif",
25
36
  ]
26
- __version__ = "0.1.2"
37
+ __version__ = "0.1.4"
sql_diff_explainer/cli.py CHANGED
@@ -5,7 +5,15 @@ import json
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
- from .core import SQLDiffError, explain_sql_change
8
+ from .core import (
9
+ SQLDiffError,
10
+ SQLDiffResult,
11
+ SQLDirectoryDiffResult,
12
+ compare_sql_directories,
13
+ compare_sql_git,
14
+ explain_sql_change,
15
+ )
16
+ from .reports import render_html_report, to_sarif
9
17
 
10
18
  _RISK_SEVERITY = {"low": 0, "medium": 1, "high": 2}
11
19
 
@@ -13,12 +21,30 @@ _RISK_SEVERITY = {"low": 0, "medium": 1, "high": 2}
13
21
  def main() -> int:
14
22
  """Run the command-line interface."""
15
23
  parser = argparse.ArgumentParser(
16
- description="Explain structural differences between two SQL files."
24
+ description="Explain structural differences between SQL files or directories."
25
+ )
26
+ parser.add_argument("before", type=Path, nargs="?", help="Original SQL file")
27
+ parser.add_argument("after", type=Path, nargs="?", help="Updated SQL file")
28
+ parser.add_argument(
29
+ "--compare-dirs",
30
+ nargs=2,
31
+ type=Path,
32
+ metavar=("BEFORE_DIR", "AFTER_DIR"),
33
+ help="Recursively compare matching .sql files in two directories",
34
+ )
35
+ parser.add_argument(
36
+ "--git-base",
37
+ metavar="REF",
38
+ help="Compare Git-reported .sql changes from REF to the current worktree",
17
39
  )
18
- parser.add_argument("before", type=Path, help="Original SQL file")
19
- parser.add_argument("after", type=Path, help="Updated SQL file")
20
40
  parser.add_argument("--dialect", help="sqlglot dialect used to parse both files")
21
- parser.add_argument("--format", choices=("text", "json"), default="text", dest="output_format")
41
+ parser.add_argument(
42
+ "--format",
43
+ choices=("text", "json", "sarif", "html"),
44
+ default="text",
45
+ dest="output_format",
46
+ )
47
+ parser.add_argument("--output", type=Path, help="Write the report to this file")
22
48
  parser.add_argument(
23
49
  "--fail-on-risk",
24
50
  choices=tuple(_RISK_SEVERITY),
@@ -26,30 +52,90 @@ def main() -> int:
26
52
  )
27
53
  args = parser.parse_args()
28
54
 
29
- try:
30
- result = explain_sql_change(
31
- args.before.read_text(encoding="utf-8"),
32
- args.after.read_text(encoding="utf-8"),
33
- dialect=args.dialect,
55
+ directory_mode = args.compare_dirs is not None
56
+ git_mode = args.git_base is not None
57
+ selected_modes = sum((directory_mode, git_mode))
58
+ 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")
60
+ if selected_modes > 1:
61
+ parser.error("Use either --git-base or --compare-dirs, not both")
62
+ if not selected_modes and (args.before is None or args.after is None):
63
+ parser.error(
64
+ "Provide two SQL files, use --compare-dirs BEFORE_DIR AFTER_DIR, or use --git-base REF"
34
65
  )
35
- except (OSError, SQLDiffError) as exc:
66
+
67
+ try:
68
+ if git_mode:
69
+ result = compare_sql_git(args.git_base, dialect=args.dialect)
70
+ report_path = ""
71
+ elif directory_mode:
72
+ result: SQLDiffResult | SQLDirectoryDiffResult = compare_sql_directories(
73
+ args.compare_dirs[0],
74
+ args.compare_dirs[1],
75
+ dialect=args.dialect,
76
+ )
77
+ report_path = ""
78
+ else:
79
+ result = explain_sql_change(
80
+ args.before.read_text(encoding="utf-8"),
81
+ args.after.read_text(encoding="utf-8"),
82
+ dialect=args.dialect,
83
+ )
84
+ report_path = args.after.as_posix()
85
+ except (OSError, UnicodeDecodeError, SQLDiffError) as exc:
36
86
  print(f"error: {exc}", file=sys.stderr)
37
87
  return 2
88
+
38
89
  if args.output_format == "json":
39
- print(json.dumps(result.to_dict(), indent=2))
90
+ report = json.dumps(result.to_dict(), indent=2)
91
+ elif args.output_format == "sarif":
92
+ report = json.dumps(to_sarif(result, path=report_path or "after.sql"), indent=2)
93
+ elif args.output_format == "html":
94
+ report = render_html_report(result, path=report_path or "after.sql")
40
95
  else:
41
- print(result.summary)
42
- if result.risk_reasons:
43
- print(f"Risk: {result.risk.upper()} - {' '.join(result.risk_reasons)}")
44
- for change in result.changes:
45
- print(f"- [{change.risk.upper()}] {change.description}")
46
- if change.before is not None:
47
- print(f" before: {change.before}")
48
- if change.after is not None:
49
- print(f" after: {change.after}")
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)
105
+ lines.extend(
106
+ f"Renamed SQL file: {before} -> {after}"
107
+ for before, after in result.renamed_files
108
+ )
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}")
118
+ report = "\n".join(lines)
119
+
120
+ try:
121
+ if args.output is None:
122
+ print(report)
123
+ else:
124
+ output_text = report if report.endswith("\n") else report + "\n"
125
+ args.output.write_text(output_text, encoding="utf-8")
126
+ except OSError as exc:
127
+ print(f"error: Unable to write report: {exc}", file=sys.stderr)
128
+ return 2
129
+
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
+ )
50
136
  if (
51
137
  args.fail_on_risk is not None
52
- and result.changes
138
+ and has_changes
53
139
  and _RISK_SEVERITY[result.risk] >= _RISK_SEVERITY[args.fail_on_risk]
54
140
  ):
55
141
  return 1
@@ -2,10 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+ import subprocess
5
7
  from abc import ABC, abstractmethod
6
8
  from collections import Counter
7
9
  from dataclasses import dataclass, field
8
10
  from difflib import unified_diff
11
+ from pathlib import Path, PurePosixPath
9
12
  from typing import Any
10
13
 
11
14
 
@@ -103,6 +106,72 @@ class SQLDiffResult:
103
106
  }
104
107
 
105
108
 
109
+ @dataclass(frozen=True)
110
+ class SQLFileDiff:
111
+ """The semantic comparison for one matched relative SQL file path."""
112
+
113
+ path: str
114
+ result: SQLDiffResult
115
+
116
+ def to_dict(self) -> dict[str, object]:
117
+ return {"path": self.path, **self.result.to_dict()}
118
+
119
+
120
+ @dataclass
121
+ class SQLDirectoryDiffResult:
122
+ """Comparisons and unmatched SQL files from two directory trees."""
123
+
124
+ files: list[SQLFileDiff] = field(default_factory=list)
125
+ unmatched_before: list[str] = field(default_factory=list)
126
+ unmatched_after: list[str] = field(default_factory=list)
127
+ renamed_files: list[tuple[str, str]] = field(default_factory=list)
128
+
129
+ @property
130
+ def risk(self) -> str:
131
+ """Return the highest file risk; whole-file additions/removals are high risk."""
132
+ if self.unmatched_before or self.unmatched_after:
133
+ return "high"
134
+ severity = {"low": 0, "medium": 1, "high": 2}
135
+ return max(
136
+ (item.result.risk for item in self.files),
137
+ key=severity.__getitem__,
138
+ default="low",
139
+ )
140
+
141
+ @property
142
+ def risk_level(self) -> str:
143
+ return self.risk
144
+
145
+ @property
146
+ def summary(self) -> str:
147
+ changed = sum(bool(item.result.changes) for item in self.files)
148
+ unmatched = len(self.unmatched_before) + len(self.unmatched_after)
149
+ if not changed and not unmatched and not self.renamed_files:
150
+ return f"No structural SQL changes detected across {len(self.files)} file(s)."
151
+ if self.renamed_files:
152
+ return (
153
+ f"{changed} changed SQL file(s), {unmatched} unmatched SQL file(s), "
154
+ f"{len(self.renamed_files)} renamed SQL file(s); overall risk is {self.risk}."
155
+ )
156
+ return (
157
+ f"{changed} changed SQL file(s), {unmatched} unmatched SQL file(s); "
158
+ f"overall risk is {self.risk}."
159
+ )
160
+
161
+ def to_dict(self) -> dict[str, object]:
162
+ return {
163
+ "summary": self.summary,
164
+ "risk": self.risk,
165
+ "risk_level": self.risk_level,
166
+ "files": [item.to_dict() for item in self.files],
167
+ "unmatched_before": list(self.unmatched_before),
168
+ "unmatched_after": list(self.unmatched_after),
169
+ "renamed_files": [
170
+ {"before": before, "after": after} for before, after in self.renamed_files
171
+ ],
172
+ }
173
+
174
+
106
175
  def _sql(node: Any, dialect: str | None) -> str | None:
107
176
  if node is None:
108
177
  return None
@@ -347,6 +416,193 @@ def explain_sql_change(
347
416
  return SQLDiffResult(changes=changes, risk=overall, risk_reasons=reasons, dialect=dialect)
348
417
 
349
418
 
419
+ def compare_sql_directories(
420
+ before_dir: str | Path,
421
+ after_dir: str | Path,
422
+ *,
423
+ dialect: str | None = None,
424
+ parser: SQLParser | None = None,
425
+ ) -> SQLDirectoryDiffResult:
426
+ """Compare recursively discovered SQL files paired by exact relative path.
427
+
428
+ Files present on only one side are returned as unmatched instead of being
429
+ parsed as empty SQL. An unmatched file makes the aggregate risk high.
430
+ """
431
+ before_root = Path(before_dir)
432
+ after_root = Path(after_dir)
433
+ for label, root in (("before", before_root), ("after", after_root)):
434
+ if not root.exists():
435
+ raise SQLDiffError(f"{label} directory does not exist: {root}")
436
+ if not root.is_dir():
437
+ raise SQLDiffError(f"{label} path is not a directory: {root}")
438
+
439
+ def discover(root: Path) -> dict[str, Path]:
440
+ return {
441
+ path.relative_to(root).as_posix(): path
442
+ for path in sorted(root.rglob("*"))
443
+ if path.is_file() and path.suffix.lower() == ".sql"
444
+ }
445
+
446
+ before_files = discover(before_root)
447
+ after_files = discover(after_root)
448
+ before_paths = set(before_files)
449
+ after_paths = set(after_files)
450
+ shared_paths = sorted(before_paths & after_paths)
451
+ try:
452
+ comparisons = [
453
+ SQLFileDiff(
454
+ path=relative_path,
455
+ result=explain_sql_change(
456
+ before_files[relative_path].read_text(encoding="utf-8"),
457
+ after_files[relative_path].read_text(encoding="utf-8"),
458
+ dialect=dialect,
459
+ parser=parser,
460
+ ),
461
+ )
462
+ for relative_path in shared_paths
463
+ ]
464
+ except UnicodeDecodeError as exc:
465
+ raise SQLDiffError(f"Unable to read SQL file as UTF-8: {exc}") from exc
466
+ except OSError as exc:
467
+ raise SQLDiffError(f"Unable to read SQL file: {exc}") from exc
468
+ return SQLDirectoryDiffResult(
469
+ files=comparisons,
470
+ unmatched_before=sorted(before_paths - after_paths),
471
+ unmatched_after=sorted(after_paths - before_paths),
472
+ )
473
+
474
+
475
+ def compare_sql_git(
476
+ base_ref: str,
477
+ *,
478
+ cwd: str | Path = ".",
479
+ dialect: str | None = None,
480
+ parser: SQLParser | None = None,
481
+ ) -> SQLDirectoryDiffResult:
482
+ """Compare Git-reported SQL changes from a base commit to the current worktree."""
483
+ working_dir = Path(cwd)
484
+
485
+ def run_git(*arguments: str) -> bytes:
486
+ try:
487
+ completed = subprocess.run(
488
+ ["git", *arguments],
489
+ cwd=working_dir,
490
+ capture_output=True,
491
+ check=False,
492
+ )
493
+ except OSError as exc:
494
+ raise SQLDiffError(f"Unable to run git: {exc}") from exc
495
+ if completed.returncode:
496
+ message = os.fsdecode(completed.stderr).strip()
497
+ raise SQLDiffError(f"Git command failed: {message or 'unknown git error'}")
498
+ return completed.stdout
499
+
500
+ root_output = run_git("rev-parse", "--show-toplevel").removesuffix(b"\n").removesuffix(b"\r")
501
+ root = Path(os.fsdecode(root_output))
502
+ commit = os.fsdecode(
503
+ run_git("rev-parse", "--verify", "--end-of-options", f"{base_ref}^{{commit}}")
504
+ ).strip()
505
+ if not commit:
506
+ raise SQLDiffError(f"Git base ref did not resolve to a commit: {base_ref}")
507
+
508
+ status_fields = run_git(
509
+ "diff", "--name-status", "-z", "--find-renames", commit, "--"
510
+ ).split(b"\0")
511
+ if status_fields and status_fields[-1] == b"":
512
+ status_fields.pop()
513
+
514
+ changed: list[tuple[str, str, str | None]] = []
515
+ index = 0
516
+ while index < len(status_fields):
517
+ status = os.fsdecode(status_fields[index])
518
+ index += 1
519
+ path_count = 2 if status.startswith(("R", "C")) else 1
520
+ if index + path_count > len(status_fields):
521
+ raise SQLDiffError("Git returned an incomplete file change record")
522
+ paths = [os.fsdecode(item) for item in status_fields[index : index + path_count]]
523
+ index += path_count
524
+ if path_count == 2:
525
+ changed.append((status[0], paths[0], paths[1]))
526
+ else:
527
+ changed.append(
528
+ (status[0], paths[0], None if status.startswith("D") else paths[0])
529
+ )
530
+
531
+ def validate_repo_path(path: str) -> None:
532
+ candidate = PurePosixPath(path)
533
+ if candidate.is_absolute() or "\\" in path or ".." in candidate.parts:
534
+ raise SQLDiffError(f"Unsafe path returned by git: {path!r}")
535
+
536
+ def read_base(path: str) -> str:
537
+ validate_repo_path(path)
538
+ try:
539
+ return run_git("cat-file", "blob", f"{commit}:{path}").decode("utf-8")
540
+ except UnicodeDecodeError as exc:
541
+ raise SQLDiffError(
542
+ f"Unable to read SQL file as UTF-8 from Git base ({path}): {exc}"
543
+ ) from exc
544
+ except SQLDiffError as exc:
545
+ raise SQLDiffError(f"Unable to read SQL file from Git base ({path}): {exc}") from exc
546
+
547
+ def read_worktree(path: str) -> str:
548
+ validate_repo_path(path)
549
+ file_path = root.joinpath(*PurePosixPath(path).parts)
550
+ try:
551
+ resolved = file_path.resolve(strict=True)
552
+ if not resolved.is_relative_to(root.resolve()):
553
+ raise SQLDiffError(f"SQL file path resolves outside the Git repository: {path}")
554
+ if not resolved.is_file():
555
+ raise SQLDiffError(f"SQL path is not a file in the worktree: {path}")
556
+ return resolved.read_text(encoding="utf-8")
557
+ except UnicodeDecodeError as exc:
558
+ raise SQLDiffError(f"Unable to read SQL file as UTF-8 ({path}): {exc}") from exc
559
+ except OSError as exc:
560
+ raise SQLDiffError(f"Unable to read SQL file from worktree ({path}): {exc}") from exc
561
+
562
+ def is_sql(path: str) -> bool:
563
+ return PurePosixPath(path).suffix.lower() == ".sql"
564
+
565
+ files: list[SQLFileDiff] = []
566
+ removed: list[str] = []
567
+ added: list[str] = []
568
+ renamed: list[tuple[str, str]] = []
569
+ for status, before_path, after_path in changed:
570
+ if after_path is None:
571
+ if is_sql(before_path):
572
+ removed.append(before_path)
573
+ continue
574
+
575
+ before_is_sql = is_sql(before_path)
576
+ after_is_sql = is_sql(after_path)
577
+ if status == "A":
578
+ if after_is_sql:
579
+ added.append(after_path)
580
+ continue
581
+ if before_is_sql and after_is_sql:
582
+ if before_path != after_path:
583
+ renamed.append((before_path, after_path))
584
+ files.append(
585
+ SQLFileDiff(
586
+ after_path,
587
+ explain_sql_change(
588
+ read_base(before_path), read_worktree(after_path),
589
+ dialect=dialect, parser=parser,
590
+ ),
591
+ )
592
+ )
593
+ elif before_is_sql:
594
+ removed.append(before_path)
595
+ elif after_is_sql:
596
+ added.append(after_path)
597
+
598
+ return SQLDirectoryDiffResult(
599
+ files=sorted(files, key=lambda item: item.path),
600
+ unmatched_before=sorted(removed),
601
+ unmatched_after=sorted(added),
602
+ renamed_files=sorted(renamed),
603
+ )
604
+
605
+
350
606
  def explain_diff(before: str, after: str) -> str:
351
607
  """Return a readable unified diff for two SQL documents."""
352
608
  lines = unified_diff(
@@ -0,0 +1,219 @@
1
+ """Machine-readable and standalone HTML reports for SQL comparisons."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html import escape
6
+ from pathlib import Path
7
+ from urllib.parse import quote
8
+
9
+ from .core import SQLDiffResult, SQLDirectoryDiffResult
10
+
11
+ _SARIF_LEVEL = {"low": "note", "medium": "warning", "high": "error"}
12
+ _SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
13
+
14
+
15
+ def _artifact_uri(path: str) -> str:
16
+ candidate = Path(path)
17
+ if candidate.is_absolute():
18
+ return candidate.as_uri()
19
+ return quote(candidate.as_posix(), safe="/-._~")
20
+
21
+
22
+ def _sarif_result(
23
+ *,
24
+ rule_id: str,
25
+ risk: str,
26
+ message: str,
27
+ path: str,
28
+ properties: dict[str, object] | None = None,
29
+ ) -> dict[str, object]:
30
+ result: dict[str, object] = {
31
+ "ruleId": rule_id,
32
+ "level": _SARIF_LEVEL[risk],
33
+ "message": {"text": message},
34
+ "locations": [
35
+ {
36
+ "physicalLocation": {
37
+ "artifactLocation": {"uri": _artifact_uri(path)},
38
+ }
39
+ }
40
+ ],
41
+ "properties": {"risk": risk, **(properties or {})},
42
+ }
43
+ return result
44
+
45
+
46
+ def to_sarif(
47
+ result: SQLDiffResult | SQLDirectoryDiffResult,
48
+ *,
49
+ path: str = "after.sql",
50
+ ) -> dict[str, object]:
51
+ """Convert a single-file or directory comparison to a SARIF 2.1.0 document."""
52
+ sarif_results: list[dict[str, object]] = []
53
+ rules: dict[str, dict[str, object]] = {}
54
+
55
+ def add_rule(rule_id: str, name: str, description: str) -> None:
56
+ rules.setdefault(
57
+ rule_id,
58
+ {
59
+ "id": rule_id,
60
+ "name": name,
61
+ "shortDescription": {"text": description},
62
+ },
63
+ )
64
+
65
+ def add_changes(sql_result: SQLDiffResult, file_path: str) -> None:
66
+ for change in sql_result.changes:
67
+ rule_id = f"sql-diff-{change.category.replace('_', '-')}"
68
+ add_rule(rule_id, change.category, f"SQL {change.category.replace('_', ' ')} change")
69
+ message = change.description
70
+ if change.before is not None:
71
+ message += f" Before: {change.before}"
72
+ if change.after is not None:
73
+ message += f" After: {change.after}"
74
+ sarif_results.append(
75
+ _sarif_result(
76
+ rule_id=rule_id,
77
+ risk=change.risk,
78
+ message=message,
79
+ path=file_path,
80
+ properties={
81
+ "category": change.category,
82
+ "before": change.before,
83
+ "after": change.after,
84
+ },
85
+ )
86
+ )
87
+
88
+ if isinstance(result, SQLDiffResult):
89
+ add_changes(result, path)
90
+ else:
91
+ for file_diff in result.files:
92
+ add_changes(file_diff.result, file_diff.path)
93
+ for unmatched, rule_id, action in (
94
+ (result.unmatched_before, "sql-file-removed", "removed"),
95
+ (result.unmatched_after, "sql-file-added", "added"),
96
+ ):
97
+ if unmatched:
98
+ add_rule(rule_id, f"SQL file {action}", f"SQL file {action} between directories")
99
+ for file_path in unmatched:
100
+ sarif_results.append(
101
+ _sarif_result(
102
+ rule_id=rule_id,
103
+ risk="high",
104
+ message=f"SQL file {action}: {file_path}",
105
+ path=file_path,
106
+ properties={"change": action},
107
+ )
108
+ )
109
+ if result.renamed_files:
110
+ add_rule("sql-file-renamed", "SQL file renamed", "SQL file renamed in the worktree")
111
+ for before_path, after_path in result.renamed_files:
112
+ sarif_results.append(
113
+ _sarif_result(
114
+ rule_id="sql-file-renamed",
115
+ risk="low",
116
+ message=f"SQL file renamed: {before_path} -> {after_path}",
117
+ path=after_path,
118
+ properties={"change": "renamed", "before": before_path, "after": after_path},
119
+ )
120
+ )
121
+
122
+ return {
123
+ "$schema": _SARIF_SCHEMA,
124
+ "version": "2.1.0",
125
+ "runs": [
126
+ {
127
+ "tool": {
128
+ "driver": {
129
+ "name": "SQL Diff Explainer",
130
+ "rules": list(rules.values()),
131
+ }
132
+ },
133
+ "results": sarif_results,
134
+ }
135
+ ],
136
+ }
137
+
138
+
139
+ def render_html_report(
140
+ result: SQLDiffResult | SQLDirectoryDiffResult,
141
+ *,
142
+ path: str = "after.sql",
143
+ ) -> str:
144
+ """Render a self-contained HTML report, escaping all SQL and path content."""
145
+ rows: list[str] = []
146
+
147
+ def add_changes(sql_result: SQLDiffResult, file_path: str) -> None:
148
+ for change in sql_result.changes:
149
+ rows.append(
150
+ "<tr>"
151
+ f"<td>{escape(file_path, quote=True)}</td>"
152
+ f'<td class="{escape(change.risk, quote=True)}">{escape(change.risk.upper())}</td>'
153
+ f"<td>{escape(change.description, quote=True)}</td>"
154
+ f"<td><pre>{escape(change.before or '', quote=True)}</pre></td>"
155
+ f"<td><pre>{escape(change.after or '', quote=True)}</pre></td>"
156
+ "</tr>"
157
+ )
158
+
159
+ if isinstance(result, SQLDiffResult):
160
+ risk = result.risk
161
+ summary = result.summary
162
+ add_changes(result, path)
163
+ else:
164
+ risk = result.risk
165
+ summary = result.summary
166
+ for file_diff in result.files:
167
+ add_changes(file_diff.result, file_diff.path)
168
+ for file_path in result.unmatched_before:
169
+ rows.append(
170
+ "<tr>"
171
+ f"<td>{escape(file_path, quote=True)}</td>"
172
+ '<td class="high">HIGH</td>'
173
+ "<td>SQL file removed.</td><td><pre>Present before</pre></td>"
174
+ "<td><pre></pre></td></tr>"
175
+ )
176
+ for file_path in result.unmatched_after:
177
+ rows.append(
178
+ "<tr>"
179
+ f"<td>{escape(file_path, quote=True)}</td>"
180
+ '<td class="high">HIGH</td>'
181
+ "<td>SQL file added.</td><td><pre></pre></td>"
182
+ "<td><pre>Present after</pre></td></tr>"
183
+ )
184
+ for before_path, after_path in result.renamed_files:
185
+ rows.append(
186
+ "<tr>"
187
+ f"<td>{escape(after_path, quote=True)}</td>"
188
+ '<td class="low">LOW</td>'
189
+ f"<td>SQL file renamed from {escape(before_path, quote=True)}.</td>"
190
+ "<td><pre>Present before</pre></td>"
191
+ "<td><pre>Present after</pre></td></tr>"
192
+ )
193
+
194
+ if not rows:
195
+ rows.append('<tr><td colspan="5">No structural SQL changes detected.</td></tr>')
196
+ return "\n".join(
197
+ [
198
+ "<!doctype html>",
199
+ '<html lang="en"><head><meta charset="utf-8">',
200
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
201
+ "<title>SQL Diff Explainer report</title>",
202
+ "<style>",
203
+ "body{font:15px system-ui,sans-serif;margin:2rem;color:#24292f}"
204
+ "table{border-collapse:collapse;width:100%}th,td{border:1px solid #d0d7de;"
205
+ "padding:.6rem;text-align:left;vertical-align:top}pre{white-space:pre-wrap;"
206
+ "overflow-wrap:anywhere;margin:0}.high{color:#cf222e}.medium{color:#9a6700}"
207
+ ".low{color:#1a7f37}",
208
+ "</style></head><body>",
209
+ "<h1>SQL Diff Explainer</h1>",
210
+ f'<p class="{escape(risk, quote=True)}">Overall risk: '
211
+ f"{escape(risk.upper(), quote=True)}</p>",
212
+ f"<p>{escape(summary, quote=True)}</p>",
213
+ "<table><thead><tr><th>File</th><th>Risk</th><th>Change</th>"
214
+ "<th>Before</th><th>After</th></tr></thead><tbody>",
215
+ *rows,
216
+ "</tbody></table></body></html>",
217
+ "",
218
+ ]
219
+ )
@@ -0,0 +1,284 @@
1
+ Metadata-Version: 2.5
2
+ Name: sql-diff-explainer
3
+ Version: 0.1.4
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
+ **See what changed in a SQL query—and why it might matter.**
18
+
19
+ SQL Diff Explainer compares the structure of two `SELECT` queries and returns
20
+ the changes with a static risk signal, so reviews focus on query behavior rather
21
+ than formatting.
22
+
23
+ [![PyPI version](https://img.shields.io/pypi/v/sql-diff-explainer?cacheSeconds=60)](https://pypi.org/project/sql-diff-explainer/)
24
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/sql-diff-explainer/)
25
+ [![License: MIT](https://img.shields.io/pypi/l/sql-diff-explainer)](LICENSE)
26
+
27
+ ## Contents
28
+
29
+ - [Quick start](#quick-start)
30
+ - [Command-line usage](#command-line-usage)
31
+ - [Directory comparison](#directory-comparison)
32
+ - [Git-aware pull-request comparison](#git-aware-pull-request-comparison)
33
+ - [SARIF and HTML reports](#sarif-and-html-reports)
34
+ - [What it compares](#what-it-compares)
35
+ - [Use cases](#use-cases)
36
+ - [Scope and limitations](#scope-and-limitations)
37
+ - [Development](#development)
38
+ - [Contributing](#contributing)
39
+ - [License](#license)
40
+
41
+ ## Quick start
42
+
43
+ Install the latest release from [PyPI](https://pypi.org/project/sql-diff-explainer/):
44
+
45
+ ```console
46
+ python -m pip install sql-diff-explainer
47
+ ```
48
+
49
+ Compare two queries from Python:
50
+
51
+ ```python
52
+ from sql_diff_explainer import explain_sql_change
53
+
54
+ before = """
55
+ SELECT c.customer_id, c.segment, o.order_id
56
+ FROM customers AS c
57
+ LEFT JOIN orders AS o ON c.customer_id = o.customer_id
58
+ """
59
+ after = """
60
+ SELECT c.customer_id, c.segment, o.order_id
61
+ FROM customers AS c
62
+ INNER JOIN orders AS o ON c.customer_id = o.customer_id
63
+ """
64
+
65
+ result = explain_sql_change(before, after)
66
+ print(result.summary)
67
+ for change in result.changes:
68
+ print(
69
+ f"{change.category}: {change.before} -> {change.after} "
70
+ f"({change.risk} risk)"
71
+ )
72
+ ```
73
+
74
+ Output:
75
+
76
+ ```text
77
+ 1 SQL change(s) detected; overall risk is high.
78
+ join: LEFT JOIN orders AS o ON c.customer_id = o.customer_id -> INNER JOIN orders AS o ON c.customer_id = o.customer_id (high risk)
79
+ ```
80
+
81
+ The change is classified as high risk because an `INNER JOIN` excludes
82
+ customers without a matching order, whereas a `LEFT JOIN` retains them. The
83
+ result is structured: inspect `result.risk_level` and `result.changes`, or call
84
+ `result.to_dict()` for JSON-ready data. Pass `dialect="postgres"` (or another
85
+ [sqlglot dialect](https://sqlglot.com/sqlglot/dialects.html)) for
86
+ dialect-specific SQL. Invalid SQL raises `SQLParseError`; non-`SELECT` input
87
+ raises `UnsupportedSQL`.
88
+
89
+ ## Command-line usage
90
+
91
+ The CLI compares two SQL files and prints a human-readable report by default:
92
+
93
+ ```console
94
+ sql-diff-explainer examples/join_before.sql examples/join_after.sql
95
+ ```
96
+
97
+ Choose JSON output, set a parsing dialect, or make a risk threshold fail a CI
98
+ step:
99
+
100
+ ```console
101
+ sql-diff-explainer before.sql after.sql --format json
102
+ sql-diff-explainer before.sql after.sql --dialect postgres
103
+ sql-diff-explainer before.sql after.sql --fail-on-risk medium
104
+ ```
105
+
106
+ `--fail-on-risk` accepts `low`, `medium`, or `high`. It exits with status `1`
107
+ when detected changes meet or exceed the threshold; the text or JSON report is
108
+ still printed. No detected changes exit successfully. Input-file errors and
109
+ SQL parse/support errors exit with status `2`.
110
+
111
+ Try the runnable before/after pairs in [`examples/`](examples/):
112
+
113
+ ```console
114
+ sql-diff-explainer examples/filter_before.sql examples/filter_after.sql --format json
115
+ sql-diff-explainer examples/aggregation_before.sql examples/aggregation_after.sql
116
+ sql-diff-explainer examples/distinct_before.sql examples/distinct_after.sql
117
+ ```
118
+
119
+ ### Directory comparison
120
+
121
+ Compare two directory trees recursively. SQL files are paired by their exact
122
+ relative path (with `/` separators); the `.sql` extension is matched
123
+ case-insensitively. Files present on only one side are reported as additions
124
+ or removals rather than parsed as empty SQL, and raise the aggregate risk to
125
+ high:
126
+
127
+ ```console
128
+ sql-diff-explainer --compare-dirs models/main models/review --format json
129
+ sql-diff-explainer --compare-dirs models/main models/review --fail-on-risk high
130
+ ```
131
+
132
+ The JSON report includes each matched file's ordinary SQL diff, plus
133
+ `unmatched_before` and `unmatched_after` path lists. A missing or non-directory
134
+ input, unreadable file, or invalid SQL exits with status `2`.
135
+
136
+ The same operation is available in Python:
137
+
138
+ ```python
139
+ from sql_diff_explainer import compare_sql_directories
140
+
141
+ result = compare_sql_directories("models/main", "models/review")
142
+ print(result.to_dict())
143
+ ```
144
+
145
+ ### Git-aware pull-request comparison
146
+
147
+ Compare SQL paths Git reports as changed between a base ref and the current
148
+ worktree. Modified files and SQL-to-SQL renames are compared semantically;
149
+ added or removed SQL files are reported explicitly as high risk. Untracked
150
+ files are not included. The base can be a branch, tag, or commit:
151
+
152
+ ```console
153
+ sql-diff-explainer --git-base origin/main --format json
154
+ sql-diff-explainer --git-base "$GITHUB_BASE_REF" --format sarif --output sql-diff.sarif
155
+ sql-diff-explainer --git-base origin/main --fail-on-risk high
156
+ ```
157
+
158
+ For GitHub Actions, fetch the base history and use the pull request's base
159
+ commit. This example checks out the pull-request worktree, installs this
160
+ repository's source (so the CLI includes the feature), emits SARIF, and fails
161
+ the job on high-risk changes:
162
+
163
+ ```yaml
164
+ name: SQL review
165
+ on:
166
+ pull_request:
167
+
168
+ jobs:
169
+ sql-diff:
170
+ runs-on: ubuntu-latest
171
+ steps:
172
+ - uses: actions/checkout@v4
173
+ with:
174
+ fetch-depth: 0
175
+ - uses: actions/setup-python@v5
176
+ with:
177
+ python-version: "3.12"
178
+ - run: python -m pip install .
179
+ - name: Analyze changed SQL
180
+ run: >-
181
+ sql-diff-explainer
182
+ --git-base "${{ github.event.pull_request.base.sha }}"
183
+ --format sarif
184
+ --output sql-diff.sarif
185
+ --fail-on-risk high
186
+ - name: Upload SQL findings
187
+ if: always() && hashFiles('sql-diff.sarif') != ''
188
+ uses: github/codeql-action/upload-sarif@v3
189
+ with:
190
+ sarif_file: sql-diff.sarif
191
+ ```
192
+
193
+ An unresolved base ref, Git failure, unreadable SQL, or invalid SQL exits with
194
+ status `2` and a diagnostic. Renames appear in every report; a rename does not
195
+ raise risk by itself, while semantic changes in its content retain their
196
+ normal risk level.
197
+
198
+ ### SARIF and HTML reports
199
+
200
+ Emit SARIF 2.1.0 results for GitHub code scanning integrations, or generate a
201
+ self-contained HTML report that can be attached to a review:
202
+
203
+ ```console
204
+ sql-diff-explainer before.sql after.sql --format sarif --output sql-diff.sarif
205
+ sql-diff-explainer --compare-dirs models/main models/review --format html --output review.html
206
+ ```
207
+
208
+ Both formats work with the existing `--fail-on-risk` threshold. SARIF maps
209
+ high, medium, and low risk to `error`, `warning`, and `note`; report output is
210
+ still written when the threshold causes exit status `1`. HTML escapes SQL text
211
+ and file paths. Python callers can use `to_sarif(result)` or
212
+ `render_html_report(result)` with either `SQLDiffResult` or
213
+ `SQLDirectoryDiffResult`.
214
+
215
+ ## What it compares
216
+
217
+ The analyzer compares supported `SELECT` query features and reports additions,
218
+ removals, or changes with a low, medium, or high risk classification:
219
+
220
+ | Feature | Examples of changes |
221
+ | --- | --- |
222
+ | Projections | Selected columns and expressions |
223
+ | Joins | Join target, predicate, or type |
224
+ | Filters | `WHERE` predicates |
225
+ | Aggregation | Aggregate functions and `GROUP BY` expressions |
226
+ | Ordering and pagination | `ORDER BY`, `LIMIT`, and `OFFSET` |
227
+ | Duplicate handling | `DISTINCT` added or removed |
228
+
229
+ Join or filter changes can alter which rows qualify; grouping or aggregation
230
+ changes can change a metric's meaning or result grain; and pagination changes
231
+ can affect which rows downstream consumers receive.
232
+
233
+ ## Use cases
234
+
235
+ - Review dbt models or warehouse SQL in a pull request.
236
+ - Spot join and filter changes that could alter row counts.
237
+ - Check grouping and aggregation changes that could shift metric grain.
238
+ - Add a lightweight risk threshold to a CI check.
239
+ - Explain generated SQL changes without treating formatting-only differences
240
+ as query changes.
241
+
242
+ ## Scope and limitations
243
+
244
+ This is static analysis, not a SQL execution engine or correctness proof. It
245
+ does not connect to a database, validate schemas or permissions, estimate query
246
+ plans or cost, inspect table statistics or null rates, or decide whether a
247
+ business metric is correct. It compares supported query structure; it does not
248
+ analyze a multi-statement migration file or guarantee complete semantic
249
+ equivalence across SQL dialects. Risk levels are review signals, not
250
+ predictions. Pair the report with warehouse tests, representative row-count
251
+ checks, query-plan review, and domain-owner input.
252
+
253
+ ## Development
254
+
255
+ The package requires Python 3.10 or newer and uses a `src/` layout.
256
+
257
+ ```console
258
+ python -m venv .venv
259
+ ```
260
+
261
+ Activate the environment, then install the development dependencies and run
262
+ the checks:
263
+
264
+ ```console
265
+ # macOS / Linux
266
+ source .venv/bin/activate
267
+
268
+ # Windows PowerShell
269
+ .venv\Scripts\Activate.ps1
270
+
271
+ python -m pip install -e ".[dev]"
272
+ python -m pytest
273
+ ruff check .
274
+ ```
275
+
276
+ ## Contributing
277
+
278
+ Bug reports, focused improvements, and pull requests are welcome. Please
279
+ include or update tests for behavior changes and keep documentation aligned
280
+ with the supported API.
281
+
282
+ ## License
283
+
284
+ Distributed under the [MIT License](LICENSE).
@@ -0,0 +1,9 @@
1
+ sql_diff_explainer/__init__.py,sha256=WLNMW_RRKVgtdpjVARZXURnFawVxN5eZn7z-s15tBP4,789
2
+ sql_diff_explainer/cli.py,sha256=aOXVY-aivbe62DfERqxyGUNQ9QqadH6Bak3t3tPNn7s,5616
3
+ sql_diff_explainer/core.py,sha256=cS-k8mgX_TWKQe1aX2HIZii5ial8DN2XLDU2-wkQjw4,23136
4
+ sql_diff_explainer/reports.py,sha256=HrCFzxaxew-J62FQK4TC4mB-cTfEn6dLS8gLlvY24L0,8254
5
+ sql_diff_explainer-0.1.4.dist-info/METADATA,sha256=mDI0Vyz1drIxfacjybYpUTHolar00roj1AN1GRE3_d0,9676
6
+ sql_diff_explainer-0.1.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ sql_diff_explainer-0.1.4.dist-info/entry_points.txt,sha256=Pu2gG46es25JExzGO58plyLEsFedeCUM-TQnUeLpOlI,67
8
+ sql_diff_explainer-0.1.4.dist-info/licenses/LICENSE,sha256=OJ2eEuGmpMxVb7jSmuVgeDcVHCEOMBkZgLgTlnK5mQQ,1087
9
+ sql_diff_explainer-0.1.4.dist-info/RECORD,,
@@ -1,147 +0,0 @@
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.
@@ -1,8 +0,0 @@
1
- sql_diff_explainer/__init__.py,sha256=P-jgWkLNkeuM1kcLChW7rSYWn6EpqOY8K0qMvALxqyA,488
2
- sql_diff_explainer/cli.py,sha256=XNYIVozEXbjJa_1PXX6ZYmgqxmqHkLHAYNuKaovMCJI,2091
3
- sql_diff_explainer/core.py,sha256=P9NrPjHGivx-sGlyIyVi7V41x4R2lH3ETC4y8opz_yQ,13265
4
- sql_diff_explainer-0.1.2.dist-info/METADATA,sha256=0UOgvJOzI3j8gbI64wGVOq1YkGKkNNajipAEGyYJjL4,5863
5
- sql_diff_explainer-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
- sql_diff_explainer-0.1.2.dist-info/entry_points.txt,sha256=Pu2gG46es25JExzGO58plyLEsFedeCUM-TQnUeLpOlI,67
7
- sql_diff_explainer-0.1.2.dist-info/licenses/LICENSE,sha256=OJ2eEuGmpMxVb7jSmuVgeDcVHCEOMBkZgLgTlnK5mQQ,1087
8
- sql_diff_explainer-0.1.2.dist-info/RECORD,,