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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Perf adapter registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlquality.adapters.base import PerfAdapter
|
|
6
|
+
from sqlquality.adapters.postgres import PostgresAdapter
|
|
7
|
+
from sqlquality.adapters.redshift import RedshiftAdapter
|
|
8
|
+
|
|
9
|
+
_ADAPTERS: dict[str, type[PerfAdapter]] = {
|
|
10
|
+
"postgres": PostgresAdapter,
|
|
11
|
+
"redshift": RedshiftAdapter,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_adapter(engine: str) -> PerfAdapter:
|
|
16
|
+
"""Return the perf adapter for an engine, or raise ValueError."""
|
|
17
|
+
try:
|
|
18
|
+
return _ADAPTERS[engine]()
|
|
19
|
+
except KeyError:
|
|
20
|
+
raise ValueError(f"No perf adapter for dialect '{engine}'")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""PerfAdapter interface — one per SQL dialect/engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from sqlquality.models import Finding
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PerfAdapter(ABC):
|
|
11
|
+
"""Per-engine performance analyzer (static + EXPLAIN-plan)."""
|
|
12
|
+
|
|
13
|
+
engine: str
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def static_findings(self, sql: str) -> list[Finding]:
|
|
17
|
+
"""Static anti-pattern findings from the SQL text."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def plan_findings(self, explain_text: str) -> list[Finding]:
|
|
21
|
+
"""Findings from a captured EXPLAIN output (raw file text; format per engine)."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Postgres performance adapter: static anti-patterns + EXPLAIN-JSON parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlquality.adapters.base import PerfAdapter
|
|
6
|
+
from sqlquality.antipatterns import antipattern_findings
|
|
7
|
+
from sqlquality.models import Finding, Severity
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_pg_plan(plan: object) -> list[Finding]:
|
|
11
|
+
"""Findings from Postgres EXPLAIN (FORMAT JSON) output."""
|
|
12
|
+
findings: list[Finding] = []
|
|
13
|
+
|
|
14
|
+
def walk(node: dict) -> None:
|
|
15
|
+
if node.get("Node Type") == "Seq Scan":
|
|
16
|
+
rel = node.get("Relation Name", "?")
|
|
17
|
+
findings.append(
|
|
18
|
+
Finding(
|
|
19
|
+
"PG001",
|
|
20
|
+
f"Seq Scan on {rel} — consider an index if the filter is selective.",
|
|
21
|
+
0,
|
|
22
|
+
Severity.WARNING,
|
|
23
|
+
False,
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
if node.get("Node Type") == "Sort" and node.get("Sort Space Type") == "Disk":
|
|
27
|
+
findings.append(
|
|
28
|
+
Finding(
|
|
29
|
+
"PG002",
|
|
30
|
+
"Sort spilled to disk (external merge) — raise work_mem or reduce the sorted set.",
|
|
31
|
+
0,
|
|
32
|
+
Severity.WARNING,
|
|
33
|
+
False,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
for child in node.get("Plans") or []:
|
|
37
|
+
walk(child)
|
|
38
|
+
|
|
39
|
+
items = plan if isinstance(plan, list) else [plan]
|
|
40
|
+
for item in items:
|
|
41
|
+
node = item.get("Plan", item) if isinstance(item, dict) else {}
|
|
42
|
+
if node:
|
|
43
|
+
walk(node)
|
|
44
|
+
return findings
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PostgresAdapter(PerfAdapter):
|
|
48
|
+
engine = "postgres"
|
|
49
|
+
|
|
50
|
+
def static_findings(self, sql: str) -> list[Finding]:
|
|
51
|
+
return antipattern_findings(sql, "postgres")
|
|
52
|
+
|
|
53
|
+
def plan_findings(self, explain_text: str) -> list[Finding]:
|
|
54
|
+
import json
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
plan = json.loads(explain_text)
|
|
58
|
+
except json.JSONDecodeError as exc:
|
|
59
|
+
raise ValueError(f"Postgres EXPLAIN must be FORMAT JSON: {exc}") from exc
|
|
60
|
+
return parse_pg_plan(plan)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Redshift performance adapter: anti-patterns + dist/sort inference + EXPLAIN markers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlquality.adapters.base import PerfAdapter
|
|
6
|
+
from sqlquality.antipatterns import antipattern_findings
|
|
7
|
+
from sqlquality.keys import dist_sort_findings
|
|
8
|
+
from sqlquality.models import Finding, Severity
|
|
9
|
+
|
|
10
|
+
_MARKERS = [
|
|
11
|
+
(
|
|
12
|
+
"DS_BCAST_INNER",
|
|
13
|
+
"RS010",
|
|
14
|
+
"Broadcast of inner table (DS_BCAST_INNER) — tables not joined on their DISTKEYs.",
|
|
15
|
+
),
|
|
16
|
+
(
|
|
17
|
+
"DS_DIST_BOTH",
|
|
18
|
+
"RS011",
|
|
19
|
+
"Both sides redistributed (DS_DIST_BOTH) — the heaviest redistribution; align DISTKEYs on the join key.",
|
|
20
|
+
),
|
|
21
|
+
(
|
|
22
|
+
"DS_DIST_ALL_INNER",
|
|
23
|
+
"RS012",
|
|
24
|
+
"Serial execution (DS_DIST_ALL_INNER) — inner table sent to a single slice.",
|
|
25
|
+
),
|
|
26
|
+
("Nested Loop", "RS013", "Nested Loop join — usually a missing join condition / cross join."),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_redshift_plan(explain_text: str) -> list[Finding]:
|
|
31
|
+
"""Findings from a Redshift EXPLAIN text plan (redistribution / join markers)."""
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
for marker, code, message in _MARKERS:
|
|
34
|
+
if marker in explain_text:
|
|
35
|
+
findings.append(Finding(code, message, 0, Severity.WARNING, False))
|
|
36
|
+
return findings
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RedshiftAdapter(PerfAdapter):
|
|
40
|
+
engine = "redshift"
|
|
41
|
+
|
|
42
|
+
def static_findings(self, sql: str) -> list[Finding]:
|
|
43
|
+
return antipattern_findings(sql, "redshift") + dist_sort_findings(sql, "redshift")
|
|
44
|
+
|
|
45
|
+
def plan_findings(self, explain_text: str) -> list[Finding]:
|
|
46
|
+
return parse_redshift_plan(explain_text)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Dialect-agnostic static SQL anti-pattern detectors (SQLGlot)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
|
|
7
|
+
from sqlquality.models import Finding, Severity
|
|
8
|
+
from sqlquality.sqlast import SqlParseError, parse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_star_projection(projection: exp.Expression) -> bool:
|
|
12
|
+
return isinstance(projection, exp.Star) or (
|
|
13
|
+
isinstance(projection, exp.Column) and isinstance(projection.this, exp.Star)
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _within_exists(node: exp.Expression) -> bool:
|
|
18
|
+
"""True if ``node`` sits inside an ``EXISTS (...)`` subquery."""
|
|
19
|
+
parent = node.parent
|
|
20
|
+
while parent is not None:
|
|
21
|
+
if isinstance(parent, exp.Exists):
|
|
22
|
+
return True
|
|
23
|
+
parent = parent.parent
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_cte_closer(select: exp.Select, cte_names: set[str]) -> bool:
|
|
28
|
+
"""True for the idiomatic dbt closer ``select * from <cte>`` (sole star, no joins)."""
|
|
29
|
+
if len(select.expressions) != 1 or not isinstance(select.expressions[0], exp.Star):
|
|
30
|
+
return False
|
|
31
|
+
if select.args.get("joins"):
|
|
32
|
+
return False
|
|
33
|
+
from_clause = select.args.get("from_")
|
|
34
|
+
if from_clause is None:
|
|
35
|
+
return False
|
|
36
|
+
table = from_clause.this
|
|
37
|
+
return isinstance(table, exp.Table) and table.name.lower() in cte_names
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _has_select_star(tree: exp.Expression) -> bool:
|
|
41
|
+
cte_names = {cte.alias.lower() for cte in tree.find_all(exp.CTE)}
|
|
42
|
+
for select in tree.find_all(exp.Select):
|
|
43
|
+
if not any(_is_star_projection(p) for p in select.expressions):
|
|
44
|
+
continue
|
|
45
|
+
# (a) `EXISTS (SELECT * ...)` only probes for row existence — semantically free.
|
|
46
|
+
if _within_exists(select):
|
|
47
|
+
continue
|
|
48
|
+
# (b) idiomatic dbt closer `select * from final` where `final` is a local CTE.
|
|
49
|
+
if _is_cte_closer(select, cte_names):
|
|
50
|
+
continue
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_constant_true(on: exp.Expression) -> bool:
|
|
56
|
+
"""True for a constant-true join condition: ``ON TRUE`` or ``ON 1=1``."""
|
|
57
|
+
if isinstance(on, exp.Boolean) and on.this is True:
|
|
58
|
+
return True
|
|
59
|
+
if isinstance(on, exp.EQ):
|
|
60
|
+
left, right = on.this, on.expression
|
|
61
|
+
if isinstance(left, exp.Literal) and isinstance(right, exp.Literal) and left == right:
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _nearest_select(node: exp.Expression) -> exp.Select | None:
|
|
67
|
+
parent = node.parent
|
|
68
|
+
while parent is not None:
|
|
69
|
+
if isinstance(parent, exp.Select):
|
|
70
|
+
return parent
|
|
71
|
+
parent = parent.parent
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _where_joins_relation(select: exp.Select, relation: str) -> bool:
|
|
76
|
+
"""True if ``select``'s own WHERE equates ``relation`` to a *different* relation.
|
|
77
|
+
|
|
78
|
+
Scoped deliberately: only the WHERE of the join's owning SELECT is consulted, and
|
|
79
|
+
only equalities whose nearest SELECT ancestor is that same SELECT count — so a
|
|
80
|
+
predicate nested in a subquery/EXISTS cannot exonerate an outer comma join, and an
|
|
81
|
+
outer predicate cannot exonerate a comma join buried in a CTE.
|
|
82
|
+
"""
|
|
83
|
+
where = select.args.get("where")
|
|
84
|
+
if where is None:
|
|
85
|
+
return False
|
|
86
|
+
for eq in where.find_all(exp.EQ):
|
|
87
|
+
if _nearest_select(eq) is not select:
|
|
88
|
+
continue
|
|
89
|
+
left, right = eq.this, eq.expression
|
|
90
|
+
if not (isinstance(left, exp.Column) and isinstance(right, exp.Column)):
|
|
91
|
+
continue
|
|
92
|
+
left_tbl, right_tbl = left.table, right.table
|
|
93
|
+
if left_tbl == relation and right_tbl and right_tbl != relation:
|
|
94
|
+
return True
|
|
95
|
+
if right_tbl == relation and left_tbl and left_tbl != relation:
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _has_cartesian_join(tree: exp.Expression) -> bool:
|
|
101
|
+
for join in tree.find_all(exp.Join):
|
|
102
|
+
if (join.args.get("method") or "").upper() == "NATURAL":
|
|
103
|
+
continue
|
|
104
|
+
# LATERAL joins correlate through the lateral body, not an ON clause; the
|
|
105
|
+
# `... ON TRUE` idiom is expected and must not be flagged.
|
|
106
|
+
if isinstance(join.this, exp.Lateral):
|
|
107
|
+
continue
|
|
108
|
+
if join.args.get("using"):
|
|
109
|
+
continue
|
|
110
|
+
on = join.args.get("on")
|
|
111
|
+
if on is not None:
|
|
112
|
+
# False negative: a constant-true ON is a disguised cross join.
|
|
113
|
+
if _is_constant_true(on):
|
|
114
|
+
return True
|
|
115
|
+
continue
|
|
116
|
+
# Explicit CROSS JOIN is always flagged (behavior kept as-is).
|
|
117
|
+
if (join.args.get("kind") or "").upper() == "CROSS":
|
|
118
|
+
return True
|
|
119
|
+
# Old-style comma join: not cartesian if this SELECT's own WHERE joins it.
|
|
120
|
+
owner = join.parent
|
|
121
|
+
relation = join.this.alias_or_name if isinstance(join.this, exp.Expression) else ""
|
|
122
|
+
if isinstance(owner, exp.Select) and relation and _where_joins_relation(owner, relation):
|
|
123
|
+
continue
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _has_leading_wildcard_like(tree: exp.Expression) -> bool:
|
|
129
|
+
for node in tree.find_all(exp.Like, exp.ILike):
|
|
130
|
+
pattern = node.args.get("expression")
|
|
131
|
+
if isinstance(pattern, exp.Literal) and pattern.is_string and pattern.this.startswith("%"):
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def antipattern_findings(sql: str, dialect: str) -> list[Finding]:
|
|
137
|
+
"""Static anti-pattern findings for one SQL statement."""
|
|
138
|
+
try:
|
|
139
|
+
tree = parse(sql, dialect)
|
|
140
|
+
except SqlParseError as exc:
|
|
141
|
+
return [Finding("SQ000", f"Unparseable SQL: {exc}", 0, Severity.ERROR, False)]
|
|
142
|
+
|
|
143
|
+
findings: list[Finding] = []
|
|
144
|
+
if _has_select_star(tree):
|
|
145
|
+
findings.append(
|
|
146
|
+
Finding(
|
|
147
|
+
"SQ001",
|
|
148
|
+
"SELECT * projects an unknown/wide column set; list columns explicitly.",
|
|
149
|
+
0,
|
|
150
|
+
Severity.WARNING,
|
|
151
|
+
False,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
if _has_cartesian_join(tree):
|
|
155
|
+
findings.append(
|
|
156
|
+
Finding(
|
|
157
|
+
"SQ002",
|
|
158
|
+
"Cartesian/cross join without an ON/USING condition.",
|
|
159
|
+
0,
|
|
160
|
+
Severity.WARNING,
|
|
161
|
+
False,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
if _has_leading_wildcard_like(tree):
|
|
165
|
+
findings.append(
|
|
166
|
+
Finding(
|
|
167
|
+
"SQ003",
|
|
168
|
+
"Leading-wildcard LIKE ('%...') is non-sargable and cannot use an index.",
|
|
169
|
+
0,
|
|
170
|
+
Severity.WARNING,
|
|
171
|
+
False,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return findings
|
sqlquality/changeset.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Turn dbt `state:modified` selection into a changed-model ChangeSet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from sqlquality.dbtproject import DbtProject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChangeSetError(RuntimeError):
|
|
14
|
+
"""Raised when the `dbt ls` invocation fails."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ChangeSet:
|
|
19
|
+
changed: list[str]
|
|
20
|
+
neighbors: list[str]
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def analysis_set(self) -> list[str]:
|
|
24
|
+
return sorted(set(self.changed) | set(self.neighbors))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_state_modified(stdout: str) -> list[str]:
|
|
28
|
+
"""Extract model unique_ids from `dbt ls --output json` JSONL output."""
|
|
29
|
+
ids: set[str] = set()
|
|
30
|
+
for line in stdout.splitlines():
|
|
31
|
+
line = line.strip()
|
|
32
|
+
if not line:
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
obj = json.loads(line)
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
continue # dbt can interleave non-JSON log lines
|
|
38
|
+
if isinstance(obj, dict) and obj.get("resource_type") == "model":
|
|
39
|
+
uid = obj.get("unique_id")
|
|
40
|
+
if uid:
|
|
41
|
+
ids.add(uid)
|
|
42
|
+
return sorted(ids)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compute_changeset(project: DbtProject, ls_stdout: str) -> ChangeSet:
|
|
46
|
+
"""Changed models (from `dbt ls`) plus their 1-hop model neighbors."""
|
|
47
|
+
models = set(project.model_ids())
|
|
48
|
+
changed = [uid for uid in parse_state_modified(ls_stdout) if uid in models]
|
|
49
|
+
changed_set = set(changed)
|
|
50
|
+
neighbors: set[str] = set()
|
|
51
|
+
for uid in changed:
|
|
52
|
+
neighbors.update(project.model_parents(uid))
|
|
53
|
+
neighbors.update(project.model_children(uid))
|
|
54
|
+
neighbors -= changed_set
|
|
55
|
+
return ChangeSet(changed=changed, neighbors=sorted(neighbors))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_state_modified(project_dir: str | Path, state_dir: str | Path, dbt: str = "dbt") -> str:
|
|
59
|
+
"""Run `dbt ls --select state:modified ... --output json` and return stdout."""
|
|
60
|
+
# --no-write-json: `dbt ls` would otherwise rewrite the candidate's
|
|
61
|
+
# target/manifest.json without compiled_code, self-neutralizing the gate on
|
|
62
|
+
# the next run. --state is resolved absolute because the subprocess runs
|
|
63
|
+
# with cwd=project_dir, but the CLI resolves --state against the real cwd.
|
|
64
|
+
cmd = [
|
|
65
|
+
dbt,
|
|
66
|
+
"ls",
|
|
67
|
+
"--no-write-json",
|
|
68
|
+
"--select",
|
|
69
|
+
"state:modified",
|
|
70
|
+
"--state",
|
|
71
|
+
str(Path(state_dir).resolve()),
|
|
72
|
+
"--resource-type",
|
|
73
|
+
"model",
|
|
74
|
+
"--output",
|
|
75
|
+
"json",
|
|
76
|
+
]
|
|
77
|
+
try:
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
cmd, cwd=Path(project_dir), capture_output=True, text=True, timeout=600
|
|
80
|
+
)
|
|
81
|
+
except FileNotFoundError as exc:
|
|
82
|
+
raise ChangeSetError(
|
|
83
|
+
f"dbt executable '{dbt}' not found on PATH — install dbt or pass --dbt"
|
|
84
|
+
) from exc
|
|
85
|
+
except subprocess.TimeoutExpired as exc:
|
|
86
|
+
raise ChangeSetError(f"`dbt ls` timed out after {exc.timeout:.0f}s") from exc
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
# dbt logs errors to stdout, not stderr; fall back to the stdout tail.
|
|
89
|
+
detail = result.stderr.strip()
|
|
90
|
+
if not detail:
|
|
91
|
+
detail = "\n".join(result.stdout.strip().splitlines()[-20:])
|
|
92
|
+
raise ChangeSetError(f"`dbt ls` failed (exit {result.returncode}): {detail}")
|
|
93
|
+
return result.stdout
|