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 ADDED
@@ -0,0 +1,3 @@
1
+ """sqlquality — measure dbt model performance and complexity."""
2
+
3
+ __version__ = "0.2.0"
@@ -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
@@ -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