sqlquality 0.2.0__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.
sqlquality/report.py ADDED
@@ -0,0 +1,124 @@
1
+ """Render a GateReport to a JSON payload and a self-contained HTML document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html as _html
6
+
7
+ from sqlquality.gate import GateReport
8
+
9
+
10
+ def _md_escape(value: object) -> str:
11
+ """Neutralize a value for a markdown table cell / inline text.
12
+
13
+ Escapes `|` (table cell breakout) and backticks, and HTML-escapes `<>&`
14
+ so a hostile unique_id or skip reason cannot inject markup or fake columns.
15
+ """
16
+ text = str(value)
17
+ text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
18
+ return text.replace("|", "\\|").replace("`", "\\`")
19
+
20
+
21
+ def verdict_label(report: GateReport, *, emoji: bool) -> str:
22
+ """Human verdict string. `emoji` toggles the decorated vs plain variant."""
23
+ if report.warned:
24
+ head = "⚠️ WARN" if emoji else "WARN"
25
+ n = len(report.regressions)
26
+ noun = "regression" if n == 1 else "regressions"
27
+ return f"{head} ({n} {noun}, gate mode: {report.mode})"
28
+ if report.passed:
29
+ return "✅ PASS" if emoji else "PASS"
30
+ return "❌ FAIL" if emoji else "FAIL"
31
+
32
+
33
+ def gate_payload(
34
+ report: GateReport, neighbors: list[str], skipped: list[tuple[str, str]] | None = None
35
+ ) -> dict:
36
+ """JSON-serializable summary of a gate report."""
37
+ return {
38
+ "passed": report.passed,
39
+ "mode": report.mode,
40
+ "warned": report.warned,
41
+ "regressions": report.regressions,
42
+ "neighbors": neighbors,
43
+ "models": [
44
+ {
45
+ "unique_id": d.unique_id,
46
+ "baseline": d.baseline,
47
+ "candidate": d.candidate,
48
+ "delta": d.delta,
49
+ "is_new": d.is_new,
50
+ }
51
+ for d in report.deltas
52
+ ],
53
+ "skipped": [{"unique_id": uid, "reason": reason} for uid, reason in (skipped or [])],
54
+ }
55
+
56
+
57
+ def render_markdown(report: GateReport, skipped: list[tuple[str, str]] | None = None) -> str:
58
+ """Render a gate report as markdown (suitable for a PR comment)."""
59
+ lines = [
60
+ f"# sqlquality: {verdict_label(report, emoji=True)}",
61
+ "",
62
+ "| model | baseline | candidate | delta | |",
63
+ "|---|---:|---:|---:|:--:|",
64
+ ]
65
+ for d in report.deltas:
66
+ flag = "⚠️" if d.unique_id in report.regressions else ("🆕" if d.is_new else "")
67
+ lines.append(
68
+ f"| {_md_escape(d.unique_id)} | {d.baseline} | {d.candidate} | {d.delta:+} | {flag} |"
69
+ )
70
+ for uid, reason in skipped or []:
71
+ if len(lines) and not lines[-1].startswith("_skipped_"):
72
+ lines.append("")
73
+ lines.append(f"_skipped_ `{_md_escape(uid)}`: {_md_escape(reason)}")
74
+ return "\n".join(lines) + "\n"
75
+
76
+
77
+ def render_html(report: GateReport, skipped: list[tuple[str, str]] | None = None) -> str:
78
+ """A self-contained HTML report (no external assets)."""
79
+ verdict = verdict_label(report, emoji=False)
80
+ if report.warned:
81
+ color = "#a15c00" # amber: passed, but regressions slipped through warn mode
82
+ elif report.passed:
83
+ color = "#137333"
84
+ else:
85
+ color = "#b3261e"
86
+ rows = []
87
+ for d in report.deltas:
88
+ tag = " (new)" if d.is_new else ""
89
+ flag = "⚠️" if d.unique_id in report.regressions else ""
90
+ rows.append(
91
+ "<tr>"
92
+ f"<td>{_html.escape(d.unique_id)}{tag}</td>"
93
+ f"<td>{d.baseline}</td>"
94
+ f"<td>{d.candidate}</td>"
95
+ f"<td>{d.delta:+}</td>"
96
+ f"<td>{flag}</td>"
97
+ "</tr>"
98
+ )
99
+ table_body = "\n".join(rows)
100
+ skipped_rows = "\n".join(
101
+ f"<li>{_html.escape(uid)}: {_html.escape(reason)}</li>" for uid, reason in (skipped or [])
102
+ )
103
+ skipped_html = f"<h3>Skipped</h3>\n<ul>\n{skipped_rows}\n</ul>" if skipped else ""
104
+ return f"""<!doctype html>
105
+ <html lang="en"><head><meta charset="utf-8">
106
+ <title>sqlquality report</title>
107
+ <style>
108
+ body {{ font-family: system-ui, sans-serif; margin: 2rem; }}
109
+ .banner {{ color: #fff; background: {color}; padding: .6rem 1rem; border-radius: 6px; font-weight: 600; }}
110
+ table {{ border-collapse: collapse; margin-top: 1rem; }}
111
+ th, td {{ border: 1px solid #ddd; padding: .4rem .8rem; text-align: right; }}
112
+ th:first-child, td:first-child {{ text-align: left; }}
113
+ </style></head>
114
+ <body>
115
+ <div class="banner">sqlquality: {_html.escape(verdict)}</div>
116
+ <table>
117
+ <thead><tr><th>model</th><th>baseline</th><th>candidate</th><th>delta</th><th></th></tr></thead>
118
+ <tbody>
119
+ {table_body}
120
+ </tbody>
121
+ </table>
122
+ {skipped_html}
123
+ </body></html>
124
+ """
sqlquality/sqlast.py ADDED
@@ -0,0 +1,128 @@
1
+ """SQLGlot-backed parsing and structural-metric extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import cast
7
+
8
+ import sqlglot
9
+ from sqlglot import exp
10
+ from sqlglot.errors import ParseError, TokenError
11
+
12
+ from sqlquality.models import ComplexityMetrics
13
+
14
+ #: Identifier substituted for each ``{{ ... }}`` Jinja expression by :func:`strip_jinja`.
15
+ JINJA_PLACEHOLDER = "__sqlquality_jinja__"
16
+
17
+ _JINJA_COMMENT = re.compile(r"\{#.*?#\}", re.DOTALL)
18
+ _JINJA_STATEMENT = re.compile(r"\{%.*?%\}", re.DOTALL)
19
+ _JINJA_EXPRESSION = re.compile(r"\{\{.*?\}\}", re.DOTALL)
20
+ _FIRST_STATEMENT_KEYWORD = re.compile(r"\b(?:with|select)\b", re.IGNORECASE)
21
+ _SQL_LINE_COMMENT = re.compile(r"--[^\n]*")
22
+ _SQL_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
23
+
24
+
25
+ class SqlParseError(ValueError):
26
+ """Raised when a SQL string cannot be parsed for the given dialect."""
27
+
28
+
29
+ def parse(sql: str, dialect: str) -> exp.Expression:
30
+ """Parse one SQL statement into a SQLGlot AST, or raise SqlParseError."""
31
+ try:
32
+ tree = sqlglot.parse_one(sql, dialect=dialect)
33
+ except (ParseError, TokenError) as exc:
34
+ raise SqlParseError(f"Could not parse SQL ({dialect}): {exc}") from exc
35
+ if tree is None:
36
+ raise SqlParseError("Empty SQL produced no AST")
37
+ return cast(exp.Expression, tree)
38
+
39
+
40
+ def analyze_sql(sql: str, dialect: str) -> ComplexityMetrics:
41
+ """Extract raw structural complexity counts from one SQL statement."""
42
+ tree = parse(sql, dialect)
43
+
44
+ def count(node_type: type[exp.Expression]) -> int:
45
+ return sum(1 for _ in tree.find_all(node_type))
46
+
47
+ max_depth = 0
48
+ for select in tree.find_all(exp.Select):
49
+ depth = 1
50
+ parent = select.parent
51
+ while parent is not None:
52
+ if isinstance(parent, exp.Select):
53
+ depth += 1
54
+ parent = parent.parent
55
+ max_depth = max(max_depth, depth)
56
+
57
+ top_select = tree if isinstance(tree, exp.Select) else tree.find(exp.Select)
58
+ projected = len(top_select.expressions) if top_select is not None else 0
59
+
60
+ # EXISTS (SELECT ...) is a correlated subquery too, but sqlglot models it as an
61
+ # exp.Exists holding an exp.Select directly (no exp.Subquery), so `WHERE EXISTS`
62
+ # would otherwise score lower than the equivalent `WHERE ... IN (SELECT ...)`.
63
+ # Guard against double-counting EXISTS((SELECT ...)), which produces both nodes.
64
+ exists_subqueries = sum(
65
+ 1 for node in tree.find_all(exp.Exists) if not isinstance(node.this, exp.Subquery)
66
+ )
67
+
68
+ return ComplexityMetrics(
69
+ join_count=count(exp.Join),
70
+ cte_count=count(exp.CTE),
71
+ subquery_count=count(exp.Subquery) + exists_subqueries,
72
+ window_count=count(exp.Window),
73
+ case_count=count(exp.Case),
74
+ union_count=count(exp.SetOperation), # Union + Except + Intersect
75
+ distinct_count=count(exp.Distinct),
76
+ select_count=count(exp.Select),
77
+ max_select_depth=max_depth,
78
+ projected_columns=projected,
79
+ )
80
+
81
+
82
+ def strip_jinja(sql: str) -> str:
83
+ """Best-effort removal of dbt/Jinja templating so a raw model roughly parses.
84
+
85
+ The result is *approximate* — it is meant to make an uncompiled dbt model
86
+ parseable for structural analysis, not to reproduce dbt's compiled SQL:
87
+
88
+ * ``{# ... #}`` comment blocks are removed entirely (multi-line aware).
89
+ * ``{% ... %}`` statement blocks are removed entirely (multi-line aware).
90
+ * ``{{ ... }}`` expressions are replaced with the placeholder identifier
91
+ :data:`JINJA_PLACEHOLDER`, so ``from {{ ref('stg') }}`` becomes a valid table.
92
+ * Any statement-leading Jinja is dropped: everything before the first
93
+ ``WITH``/``SELECT`` keyword that is only placeholders, whitespace, or
94
+ comments is removed, so a model opening with ``{{ config(...) }}`` parses.
95
+
96
+ Because ``{% ... %}`` tags are removed but the text between them is kept, a
97
+ conditional such as ``{% if %} ... {% else %} ... {% endif %}`` leaves *both*
98
+ branches concatenated, which may be unparseable — hence best-effort only.
99
+ """
100
+ text = _JINJA_COMMENT.sub(" ", sql)
101
+ text = _JINJA_STATEMENT.sub(" ", text)
102
+ text = _JINJA_EXPRESSION.sub(JINJA_PLACEHOLDER, text)
103
+
104
+ # Search for the first statement keyword on a comment-masked copy (SQL comments
105
+ # blanked to equal-length spans so offsets survive), so a leading comment that
106
+ # happens to contain "with"/"select" cannot be mistaken for the statement start.
107
+ masked = _mask_sql_comments(text)
108
+ keyword = _FIRST_STATEMENT_KEYWORD.search(masked)
109
+ if keyword is not None:
110
+ prefix = text[: keyword.start()]
111
+ residue = prefix.replace(JINJA_PLACEHOLDER, " ")
112
+ residue = _SQL_BLOCK_COMMENT.sub(" ", residue)
113
+ residue = _SQL_LINE_COMMENT.sub(" ", residue)
114
+ if not residue.strip():
115
+ text = text[keyword.start() :]
116
+
117
+ return text
118
+
119
+
120
+ def _mask_sql_comments(text: str) -> str:
121
+ """Replace SQL ``--`` line and ``/* */`` block comments with equal-length spaces."""
122
+
123
+ def _blank(match: re.Match[str]) -> str:
124
+ return " " * (match.end() - match.start())
125
+
126
+ masked = _SQL_BLOCK_COMMENT.sub(_blank, text)
127
+ masked = _SQL_LINE_COMMENT.sub(_blank, masked)
128
+ return masked