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/__init__.py +3 -0
- sqlquality/adapters/__init__.py +20 -0
- sqlquality/adapters/base.py +21 -0
- sqlquality/adapters/postgres.py +60 -0
- sqlquality/adapters/redshift.py +46 -0
- sqlquality/antipatterns.py +174 -0
- sqlquality/changeset.py +93 -0
- sqlquality/cli.py +540 -0
- sqlquality/complexity.py +41 -0
- sqlquality/config.py +86 -0
- sqlquality/dbtproject.py +126 -0
- sqlquality/delta.py +76 -0
- sqlquality/dialects.py +23 -0
- sqlquality/gate.py +37 -0
- sqlquality/keys.py +106 -0
- sqlquality/linter.py +70 -0
- sqlquality/llm.py +101 -0
- sqlquality/models.py +56 -0
- sqlquality/py.typed +0 -0
- sqlquality/report.py +124 -0
- sqlquality/sqlast.py +128 -0
- sqlquality-0.2.0.dist-info/METADATA +519 -0
- sqlquality-0.2.0.dist-info/RECORD +26 -0
- sqlquality-0.2.0.dist-info/WHEEL +4 -0
- sqlquality-0.2.0.dist-info/entry_points.txt +2 -0
- sqlquality-0.2.0.dist-info/licenses/LICENSE +21 -0
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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
|