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/dbtproject.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Read a dbt project's manifest.json (schema v12) as a model graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sqlquality.models import DagFacts
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DbtProjectError(ValueError):
|
|
13
|
+
"""Raised when the manifest is malformed or a node is missing/uncompiled."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ModelNode:
|
|
18
|
+
unique_id: str
|
|
19
|
+
name: str
|
|
20
|
+
resource_type: str
|
|
21
|
+
materialized: str | None
|
|
22
|
+
compiled_code: str | None
|
|
23
|
+
relation_name: str | None
|
|
24
|
+
depends_on: list[str]
|
|
25
|
+
config: dict
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DbtProject:
|
|
29
|
+
"""A loaded dbt manifest, indexed for model-graph queries."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, manifest: dict) -> None:
|
|
32
|
+
self._manifest = manifest
|
|
33
|
+
self._nodes: dict = manifest.get("nodes", {})
|
|
34
|
+
self._parent_map: dict = manifest.get("parent_map", {})
|
|
35
|
+
self._child_map: dict = manifest.get("child_map", {})
|
|
36
|
+
self._depth_cache: dict[str, int] = {}
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_manifest(cls, manifest: dict) -> "DbtProject":
|
|
40
|
+
return cls(manifest)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_path(cls, manifest_path: str | Path) -> "DbtProject":
|
|
44
|
+
path = Path(manifest_path)
|
|
45
|
+
try:
|
|
46
|
+
manifest = json.loads(path.read_text())
|
|
47
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
48
|
+
raise DbtProjectError(f"Could not read manifest {path}: {exc}") from exc
|
|
49
|
+
return cls(manifest)
|
|
50
|
+
|
|
51
|
+
def adapter_type(self) -> str:
|
|
52
|
+
return self._manifest.get("metadata", {}).get("adapter_type", "")
|
|
53
|
+
|
|
54
|
+
def schema_version(self) -> str:
|
|
55
|
+
return self._manifest.get("metadata", {}).get("dbt_schema_version", "")
|
|
56
|
+
|
|
57
|
+
def _is_model(self, uid: str) -> bool:
|
|
58
|
+
node = self._nodes.get(uid)
|
|
59
|
+
return node is not None and node.get("resource_type") == "model"
|
|
60
|
+
|
|
61
|
+
def model_ids(self) -> list[str]:
|
|
62
|
+
return sorted(uid for uid in self._nodes if self._is_model(uid))
|
|
63
|
+
|
|
64
|
+
def node(self, uid: str) -> ModelNode:
|
|
65
|
+
raw = self._nodes.get(uid)
|
|
66
|
+
if raw is None:
|
|
67
|
+
raise DbtProjectError(f"No such node: {uid}")
|
|
68
|
+
config = raw.get("config") or {}
|
|
69
|
+
return ModelNode(
|
|
70
|
+
unique_id=uid,
|
|
71
|
+
name=raw.get("name", ""),
|
|
72
|
+
resource_type=raw.get("resource_type", ""),
|
|
73
|
+
materialized=config.get("materialized"),
|
|
74
|
+
compiled_code=raw.get("compiled_code"),
|
|
75
|
+
relation_name=raw.get("relation_name"),
|
|
76
|
+
depends_on=list(raw.get("depends_on", {}).get("nodes", [])),
|
|
77
|
+
config=config,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def model_parents(self, uid: str) -> list[str]:
|
|
81
|
+
return sorted(p for p in self._parent_map.get(uid, []) if self._is_model(p))
|
|
82
|
+
|
|
83
|
+
def model_children(self, uid: str) -> list[str]:
|
|
84
|
+
return sorted(c for c in self._child_map.get(uid, []) if self._is_model(c))
|
|
85
|
+
|
|
86
|
+
def compiled_sql(self, uid: str) -> str:
|
|
87
|
+
node = self.node(uid)
|
|
88
|
+
if not node.compiled_code:
|
|
89
|
+
raise DbtProjectError(f"{uid} has no compiled_code — run `dbt compile` first")
|
|
90
|
+
return node.compiled_code
|
|
91
|
+
|
|
92
|
+
def dag_facts(self, uid: str) -> DagFacts:
|
|
93
|
+
return DagFacts(
|
|
94
|
+
fan_in=len(self.model_parents(uid)),
|
|
95
|
+
fan_out=len(self.model_children(uid)),
|
|
96
|
+
lineage_depth=self._lineage_depth(uid),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _lineage_depth(self, uid: str) -> int:
|
|
100
|
+
if uid in self._depth_cache:
|
|
101
|
+
return self._depth_cache[uid]
|
|
102
|
+
# Iterative post-order DFS: a recursive walk overflows the stack on very
|
|
103
|
+
# deep chains and never terminates on cycles. `on_stack` marks nodes whose
|
|
104
|
+
# depth is still being computed; a parent still on the stack closes a cycle
|
|
105
|
+
# and contributes depth 0, keeping the result finite.
|
|
106
|
+
stack: list[str] = [uid]
|
|
107
|
+
on_stack: set[str] = set()
|
|
108
|
+
while stack:
|
|
109
|
+
node = stack[-1]
|
|
110
|
+
if node in self._depth_cache:
|
|
111
|
+
stack.pop()
|
|
112
|
+
continue
|
|
113
|
+
on_stack.add(node)
|
|
114
|
+
parents = self.model_parents(node)
|
|
115
|
+
pending = [p for p in parents if p not in self._depth_cache and p not in on_stack]
|
|
116
|
+
if pending:
|
|
117
|
+
stack.extend(pending)
|
|
118
|
+
continue
|
|
119
|
+
depth = 1 + max(
|
|
120
|
+
(self._depth_cache[p] for p in parents if p in self._depth_cache),
|
|
121
|
+
default=0,
|
|
122
|
+
)
|
|
123
|
+
self._depth_cache[node] = depth
|
|
124
|
+
on_stack.discard(node)
|
|
125
|
+
stack.pop()
|
|
126
|
+
return self._depth_cache[uid]
|
sqlquality/delta.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Compute per-model complexity deltas between a baseline and candidate project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from sqlquality.complexity import ComplexityEngine
|
|
8
|
+
from sqlquality.dbtproject import DbtProject, DbtProjectError
|
|
9
|
+
from sqlquality.sqlast import SqlParseError, analyze_sql
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ModelDelta:
|
|
14
|
+
unique_id: str
|
|
15
|
+
baseline: float
|
|
16
|
+
candidate: float
|
|
17
|
+
delta: float
|
|
18
|
+
is_new: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_ENGINE = ComplexityEngine()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _composite(project: DbtProject, uid: str, dialect: str) -> float | None:
|
|
25
|
+
"""Complexity composite for one model, or None if SQL missing/unparseable."""
|
|
26
|
+
try:
|
|
27
|
+
sql = project.compiled_sql(uid)
|
|
28
|
+
metrics = analyze_sql(sql, dialect)
|
|
29
|
+
except (DbtProjectError, SqlParseError):
|
|
30
|
+
return None
|
|
31
|
+
return _ENGINE.score(metrics, project.dag_facts(uid)).composite
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compute_deltas(
|
|
35
|
+
baseline: DbtProject | None,
|
|
36
|
+
candidate: DbtProject,
|
|
37
|
+
changed_ids: list[str],
|
|
38
|
+
dialect: str,
|
|
39
|
+
) -> tuple[list[ModelDelta], list[tuple[str, str]]]:
|
|
40
|
+
"""Score each changed model on candidate and baseline; return deltas + skips."""
|
|
41
|
+
deltas: list[ModelDelta] = []
|
|
42
|
+
skipped: list[tuple[str, str]] = []
|
|
43
|
+
baseline_model_ids = set() if baseline is None else set(baseline.model_ids())
|
|
44
|
+
for uid in changed_ids:
|
|
45
|
+
cand = _composite(candidate, uid, dialect)
|
|
46
|
+
if cand is None:
|
|
47
|
+
skipped.append((uid, "no compiled SQL or unparseable in candidate"))
|
|
48
|
+
continue
|
|
49
|
+
if baseline is None:
|
|
50
|
+
is_new = True
|
|
51
|
+
base_value = 0.0
|
|
52
|
+
else:
|
|
53
|
+
base = _composite(baseline, uid, dialect)
|
|
54
|
+
if base is None:
|
|
55
|
+
# A node absent from the baseline is genuinely net-new. A node
|
|
56
|
+
# *present* in the baseline but uncompiled/unparseable must NOT be
|
|
57
|
+
# exempted as "new" — that would let a real regression through the
|
|
58
|
+
# gate. Skip it instead of emitting a misleading delta.
|
|
59
|
+
if uid in baseline_model_ids:
|
|
60
|
+
skipped.append((uid, "baseline present but unscoreable"))
|
|
61
|
+
continue
|
|
62
|
+
is_new = True
|
|
63
|
+
base_value = 0.0
|
|
64
|
+
else:
|
|
65
|
+
is_new = False
|
|
66
|
+
base_value = base
|
|
67
|
+
deltas.append(
|
|
68
|
+
ModelDelta(
|
|
69
|
+
unique_id=uid,
|
|
70
|
+
baseline=base_value,
|
|
71
|
+
candidate=cand,
|
|
72
|
+
delta=round(cand - base_value, 1),
|
|
73
|
+
is_new=is_new,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return deltas, skipped
|
sqlquality/dialects.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Validation of SQL dialect names against SQLGlot's registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlglot
|
|
6
|
+
|
|
7
|
+
#: A short, friendly sample of dialects to suggest when an unknown name is given.
|
|
8
|
+
KNOWN_DIALECTS: tuple[str, ...] = ("postgres", "redshift", "snowflake", "bigquery", "duckdb")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_dialect(name: str) -> str:
|
|
12
|
+
"""Return the normalized dialect name, or raise ``ValueError`` if unknown.
|
|
13
|
+
|
|
14
|
+
The name is lowercased and stripped, then checked against SQLGlot's dialect
|
|
15
|
+
registry via :meth:`sqlglot.Dialect.get`. An empty/blank name is rejected too,
|
|
16
|
+
since it is not a meaningful dialect to validate.
|
|
17
|
+
"""
|
|
18
|
+
normalized = name.strip().lower()
|
|
19
|
+
if not normalized or sqlglot.Dialect.get(normalized) is None:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"Unknown SQL dialect {name!r}. Known dialects include: {', '.join(KNOWN_DIALECTS)}."
|
|
22
|
+
)
|
|
23
|
+
return normalized
|
sqlquality/gate.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Apply gate config to model deltas to produce a pass/fail verdict."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from sqlquality.config import Config
|
|
8
|
+
from sqlquality.delta import ModelDelta
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class GateReport:
|
|
13
|
+
deltas: list[ModelDelta]
|
|
14
|
+
regressions: list[str]
|
|
15
|
+
passed: bool
|
|
16
|
+
mode: str = "warn"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def warned(self) -> bool:
|
|
20
|
+
"""True when warn mode let regressions through (passed, but not clean)."""
|
|
21
|
+
return self.mode == "warn" and bool(self.regressions)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def evaluate_gate(deltas: list[ModelDelta], config: Config) -> GateReport:
|
|
25
|
+
"""Flag regressions (over threshold, not waived); fail only in 'fail' mode."""
|
|
26
|
+
threshold = config.gate.max_complexity_increase
|
|
27
|
+
waivers = set(config.waivers)
|
|
28
|
+
# A brand-new model (no baseline) has delta == its full composite; a *delta*
|
|
29
|
+
# gate does not treat net-new surface as a regression. Absolute-complexity
|
|
30
|
+
# gating of new models is a separate (future) feature.
|
|
31
|
+
regressions = [
|
|
32
|
+
d.unique_id
|
|
33
|
+
for d in deltas
|
|
34
|
+
if not d.is_new and d.unique_id not in waivers and d.delta > threshold
|
|
35
|
+
]
|
|
36
|
+
passed = config.gate.mode != "fail" or not regressions
|
|
37
|
+
return GateReport(deltas=deltas, regressions=regressions, passed=passed, mode=config.gate.mode)
|
sqlquality/keys.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Redshift DISTKEY/SORTKEY inference from a model's JOIN/FILTER columns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
|
|
7
|
+
from sqlglot import exp
|
|
8
|
+
|
|
9
|
+
from sqlquality.models import Finding, Severity
|
|
10
|
+
from sqlquality.sqlast import SqlParseError, parse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _join_key_counts(tree: exp.Expression) -> Counter[str]:
|
|
14
|
+
"""Count each column's occurrences across all equi-join predicates."""
|
|
15
|
+
counts: Counter[str] = Counter()
|
|
16
|
+
for join in tree.find_all(exp.Join):
|
|
17
|
+
on = join.args.get("on")
|
|
18
|
+
if on is None:
|
|
19
|
+
continue
|
|
20
|
+
for eq in on.find_all(exp.EQ):
|
|
21
|
+
left, right = eq.this, eq.expression
|
|
22
|
+
if isinstance(left, exp.Column) and isinstance(right, exp.Column):
|
|
23
|
+
# Dedupe per predicate so `a.k = b.k` counts `k` once, not twice, and
|
|
24
|
+
# cannot outweigh a column that appears across two separate joins.
|
|
25
|
+
counts.update({left.name, right.name})
|
|
26
|
+
return counts
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _join_key_columns(tree: exp.Expression) -> list[str]:
|
|
30
|
+
return sorted(_join_key_counts(tree))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _filter_columns(tree: exp.Expression) -> list[str]:
|
|
34
|
+
where = tree.find(exp.Where)
|
|
35
|
+
if where is None:
|
|
36
|
+
return []
|
|
37
|
+
names: set[str] = set()
|
|
38
|
+
for node in where.find_all(exp.EQ, exp.GT, exp.LT, exp.GTE, exp.LTE, exp.Between, exp.In):
|
|
39
|
+
if isinstance(node, exp.Between):
|
|
40
|
+
col = node.this
|
|
41
|
+
low = node.args.get("low")
|
|
42
|
+
high = node.args.get("high")
|
|
43
|
+
if (
|
|
44
|
+
isinstance(col, exp.Column)
|
|
45
|
+
and isinstance(low, exp.Literal)
|
|
46
|
+
and isinstance(high, exp.Literal)
|
|
47
|
+
):
|
|
48
|
+
names.add(col.name)
|
|
49
|
+
elif isinstance(node, exp.In):
|
|
50
|
+
col = node.this
|
|
51
|
+
values = node.args.get("expressions") or []
|
|
52
|
+
if (
|
|
53
|
+
isinstance(col, exp.Column)
|
|
54
|
+
and values
|
|
55
|
+
and all(isinstance(value, exp.Literal) for value in values)
|
|
56
|
+
):
|
|
57
|
+
names.add(col.name)
|
|
58
|
+
else:
|
|
59
|
+
left, right = node.this, node.expression
|
|
60
|
+
if isinstance(left, exp.Column) and isinstance(right, exp.Literal):
|
|
61
|
+
names.add(left.name)
|
|
62
|
+
elif isinstance(right, exp.Column) and isinstance(left, exp.Literal):
|
|
63
|
+
names.add(right.name)
|
|
64
|
+
return sorted(names)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def join_key_columns(sql: str, dialect: str) -> list[str]:
|
|
68
|
+
return _join_key_columns(parse(sql, dialect))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def filter_columns(sql: str, dialect: str) -> list[str]:
|
|
72
|
+
return _filter_columns(parse(sql, dialect))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dist_sort_findings(sql: str, dialect: str) -> list[Finding]:
|
|
76
|
+
"""Suggest a DISTKEY (join keys) and SORTKEY (filter columns) for the model."""
|
|
77
|
+
try:
|
|
78
|
+
tree = parse(sql, dialect)
|
|
79
|
+
except SqlParseError:
|
|
80
|
+
return []
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
counts = _join_key_counts(tree)
|
|
83
|
+
if counts:
|
|
84
|
+
# Redshift allows only ONE DISTKEY column: pick the most frequent equi-join
|
|
85
|
+
# key (ties broken alphabetically); surface the rest as alternates.
|
|
86
|
+
best = min(counts, key=lambda name: (-counts[name], name))
|
|
87
|
+
alternates = sorted(name for name in counts if name != best)
|
|
88
|
+
message = (
|
|
89
|
+
f"Consider a single-column DISTKEY on the most frequent join key: {best} "
|
|
90
|
+
"— colocates joined rows and avoids redistribution (Redshift permits only one DISTKEY)."
|
|
91
|
+
)
|
|
92
|
+
if alternates:
|
|
93
|
+
message += f" Alternate candidate(s): {', '.join(alternates)}."
|
|
94
|
+
findings.append(Finding("RS001", message, 0, Severity.INFO, False))
|
|
95
|
+
fc = _filter_columns(tree)
|
|
96
|
+
if fc:
|
|
97
|
+
findings.append(
|
|
98
|
+
Finding(
|
|
99
|
+
"RS002",
|
|
100
|
+
f"Consider a compound SORTKEY leading with the filter column(s): {', '.join(fc)} — enables zone-map block skipping.",
|
|
101
|
+
0,
|
|
102
|
+
Severity.INFO,
|
|
103
|
+
False,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return findings
|
sqlquality/linter.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Best-practice linting + auto-fix via SQLFluff's programmatic API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlfluff
|
|
6
|
+
|
|
7
|
+
from sqlquality.models import Finding, Severity
|
|
8
|
+
|
|
9
|
+
# Templating-caused codes: raised when SQLFluff's Jinja templater can't resolve a
|
|
10
|
+
# macro (dbt_utils.*, custom macros). TMP is the undefined-variable error; PRS is
|
|
11
|
+
# the unparsable section that follows. On a raw dbt model these are advisory only.
|
|
12
|
+
_TEMPLATING_CODES = frozenset({"TMP", "PRS"})
|
|
13
|
+
_JINJA_HINT = (
|
|
14
|
+
" (unresolved Jinja — lint the compiled SQL under target/compiled/ "
|
|
15
|
+
"or pass a dbt-templater config via --sqlfluff-config)"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_finding(violation: dict, *, templating: bool) -> Finding:
|
|
20
|
+
code = violation.get("code", "")
|
|
21
|
+
message = violation.get("description", "")
|
|
22
|
+
if templating and code in _TEMPLATING_CODES:
|
|
23
|
+
# Unresolvable-Jinja noise the user can't fix on the raw model: advise, never gate.
|
|
24
|
+
severity = Severity.INFO
|
|
25
|
+
message = message + _JINJA_HINT
|
|
26
|
+
elif code == "PRS":
|
|
27
|
+
# Genuine parse error on plain SQL.
|
|
28
|
+
severity = Severity.ERROR
|
|
29
|
+
else:
|
|
30
|
+
severity = Severity.WARNING
|
|
31
|
+
# PRS (parse-error) dicts have no "fixes" key — always use .get().
|
|
32
|
+
fixes = violation.get("fixes") or []
|
|
33
|
+
return Finding(
|
|
34
|
+
code=code,
|
|
35
|
+
message=message,
|
|
36
|
+
line=violation.get("start_line_no", 0),
|
|
37
|
+
severity=severity,
|
|
38
|
+
fixable=bool(fixes),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lint_sql(
|
|
43
|
+
sql: str,
|
|
44
|
+
dialect: str,
|
|
45
|
+
exclude_rules: list[str] | None = None,
|
|
46
|
+
config_path: str | None = None,
|
|
47
|
+
) -> list[Finding]:
|
|
48
|
+
"""Lint one SQL string; return findings (parse errors included as PRS).
|
|
49
|
+
|
|
50
|
+
A file whose violations include a TMP code is treated as unresolved-templating:
|
|
51
|
+
its TMP/PRS findings are demoted to INFO. TMP is the only reliable evidence that
|
|
52
|
+
templating failed — a genuine PRS on rendered SQL (Jinja only in comments, valid
|
|
53
|
+
Jinja that renders to broken SQL, templater=raw configs) carries no TMP and stays
|
|
54
|
+
ERROR.
|
|
55
|
+
"""
|
|
56
|
+
violations = sqlfluff.lint(
|
|
57
|
+
sql, dialect=dialect, exclude_rules=exclude_rules, config_path=config_path
|
|
58
|
+
)
|
|
59
|
+
templating = any(v.get("code") == "TMP" for v in violations)
|
|
60
|
+
return [_to_finding(v, templating=templating) for v in violations]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fix_sql(
|
|
64
|
+
sql: str,
|
|
65
|
+
dialect: str,
|
|
66
|
+
exclude_rules: list[str] | None = None,
|
|
67
|
+
config_path: str | None = None,
|
|
68
|
+
) -> str:
|
|
69
|
+
"""Return SQL with SQLFluff auto-fixes applied (unchanged if unparseable)."""
|
|
70
|
+
return sqlfluff.fix(sql, dialect=dialect, exclude_rules=exclude_rules, config_path=config_path)
|
sqlquality/llm.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Optional, provider-agnostic LLM layer for enriching findings (advisory only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from sqlquality.models import Finding
|
|
11
|
+
|
|
12
|
+
MAX_PROMPT_SQL_CHARS = 20_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class LLMProvider(Protocol):
|
|
17
|
+
"""Anything that can turn a prompt into a suggestion string."""
|
|
18
|
+
|
|
19
|
+
def suggest(self, prompt: str) -> str: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Suggestion:
|
|
24
|
+
code: str
|
|
25
|
+
text: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_prompt(finding: Finding, sql: str) -> str:
|
|
29
|
+
"""Build the prompt asking for a concrete fix for one finding."""
|
|
30
|
+
if len(sql) > MAX_PROMPT_SQL_CHARS:
|
|
31
|
+
sql = sql[:MAX_PROMPT_SQL_CHARS] + "... [truncated]"
|
|
32
|
+
return (
|
|
33
|
+
"You are a SQL performance and maintainability expert. "
|
|
34
|
+
"A static analyzer flagged this finding on a dbt model's SQL:\n"
|
|
35
|
+
f"- code: {finding.code}\n"
|
|
36
|
+
f"- message: {finding.message}\n\n"
|
|
37
|
+
f"SQL:\n{sql}\n\n"
|
|
38
|
+
"Suggest a concrete, minimal rewrite or configuration change that "
|
|
39
|
+
"addresses the finding. Be brief (2-4 sentences). If no change is "
|
|
40
|
+
"warranted, say so and explain why."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CallableProvider:
|
|
45
|
+
"""Adapt any Callable[[str], str] into an LLMProvider (bring your own model)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, fn: Callable[[str], str]) -> None:
|
|
48
|
+
self._fn = fn
|
|
49
|
+
|
|
50
|
+
def suggest(self, prompt: str) -> str:
|
|
51
|
+
return self._fn(prompt)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def enrich_findings(findings: list[Finding], sql: str, provider: LLMProvider) -> list[Suggestion]:
|
|
55
|
+
"""One suggestion per finding. Advisory — never affects severity or the gate.
|
|
56
|
+
|
|
57
|
+
Each provider call is isolated: a failure on one finding is skipped rather
|
|
58
|
+
than aborting the rest, so a single bad call never discards every suggestion.
|
|
59
|
+
"""
|
|
60
|
+
suggestions: list[Suggestion] = []
|
|
61
|
+
for f in findings:
|
|
62
|
+
try:
|
|
63
|
+
text = provider.suggest(build_prompt(f, sql))
|
|
64
|
+
except Exception: # advisory-only: skip this finding, keep the others
|
|
65
|
+
continue
|
|
66
|
+
suggestions.append(Suggestion(f.code, text))
|
|
67
|
+
return suggestions
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AnthropicProvider:
|
|
71
|
+
"""LLM provider backed by the Anthropic Messages API (optional extra)."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, model: str | None = None, client: object | None = None) -> None:
|
|
74
|
+
if client is None:
|
|
75
|
+
try:
|
|
76
|
+
import anthropic
|
|
77
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
"The 'anthropic' package is required for AnthropicProvider. "
|
|
80
|
+
"Install it with: pip install 'sqlquality[llm]'"
|
|
81
|
+
) from exc
|
|
82
|
+
client = anthropic.Anthropic()
|
|
83
|
+
self._client: Any = client
|
|
84
|
+
self._model = model or os.environ.get("SQLQUALITY_LLM_MODEL", "claude-opus-4-8")
|
|
85
|
+
|
|
86
|
+
def suggest(self, prompt: str) -> str:
|
|
87
|
+
message = self._client.messages.create(
|
|
88
|
+
model=self._model,
|
|
89
|
+
max_tokens=1024,
|
|
90
|
+
messages=[{"role": "user", "content": prompt}],
|
|
91
|
+
)
|
|
92
|
+
return "".join(
|
|
93
|
+
block.text for block in message.content if getattr(block, "type", None) == "text"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resolve_provider() -> LLMProvider | None:
|
|
98
|
+
"""Return a provider if configured via SQLQUALITY_LLM, else None (off by default)."""
|
|
99
|
+
if os.environ.get("SQLQUALITY_LLM", "").strip().lower() not in {"anthropic", "1", "true"}:
|
|
100
|
+
return None
|
|
101
|
+
return AnthropicProvider()
|
sqlquality/models.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared data models for sqlquality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ComplexityMetrics:
|
|
11
|
+
"""Raw structural complexity counts for one SQL statement."""
|
|
12
|
+
|
|
13
|
+
join_count: int
|
|
14
|
+
cte_count: int
|
|
15
|
+
subquery_count: int
|
|
16
|
+
window_count: int
|
|
17
|
+
case_count: int
|
|
18
|
+
union_count: int
|
|
19
|
+
distinct_count: int
|
|
20
|
+
select_count: int
|
|
21
|
+
max_select_depth: int
|
|
22
|
+
projected_columns: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DagFacts:
|
|
27
|
+
"""A model's position in the dbt DAG (0 when unknown/offline)."""
|
|
28
|
+
|
|
29
|
+
fan_in: int = 0
|
|
30
|
+
fan_out: int = 0
|
|
31
|
+
lineage_depth: int = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ComplexityScore:
|
|
36
|
+
"""A weighted, open-ended complexity score with per-component contributions."""
|
|
37
|
+
|
|
38
|
+
composite: float
|
|
39
|
+
components: dict[str, float]
|
|
40
|
+
metrics: ComplexityMetrics
|
|
41
|
+
dag: DagFacts | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Severity(str, Enum):
|
|
45
|
+
INFO = "info"
|
|
46
|
+
WARNING = "warning"
|
|
47
|
+
ERROR = "error"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Finding:
|
|
52
|
+
code: str
|
|
53
|
+
message: str
|
|
54
|
+
line: int
|
|
55
|
+
severity: Severity
|
|
56
|
+
fixable: bool
|
sqlquality/py.typed
ADDED
|
File without changes
|