sqlquality 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.3.0/PKG-INFO +1223 -0
- sqlquality-0.3.0/README.md +1191 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/pyproject.toml +10 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/__init__.py +1 -1
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/antipatterns.py +3 -2
- sqlquality-0.3.0/src/sqlquality/cli.py +1002 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/dbtproject.py +13 -0
- sqlquality-0.3.0/src/sqlquality/models.py +290 -0
- sqlquality-0.3.0/src/sqlquality/report.py +330 -0
- sqlquality-0.3.0/src/sqlquality/workload/__init__.py +22 -0
- sqlquality-0.3.0/src/sqlquality/workload/aggregate.py +201 -0
- sqlquality-0.3.0/src/sqlquality/workload/base.py +137 -0
- sqlquality-0.3.0/src/sqlquality/workload/connection.py +86 -0
- sqlquality-0.3.0/src/sqlquality/workload/dbt.py +1168 -0
- sqlquality-0.3.0/src/sqlquality/workload/extract.py +261 -0
- sqlquality-0.3.0/src/sqlquality/workload/fingerprint.py +229 -0
- sqlquality-0.3.0/src/sqlquality/workload/postgres.py +2109 -0
- sqlquality-0.3.0/src/sqlquality/workload/profiles.py +154 -0
- sqlquality-0.3.0/src/sqlquality/workload/redshift.py +1604 -0
- sqlquality-0.3.0/src/sqlquality/workload/secrets.py +79 -0
- sqlquality-0.3.0/src/sqlquality/workload/session.py +179 -0
- sqlquality-0.3.0/tests/integration/__init__.py +0 -0
- sqlquality-0.3.0/tests/integration/conftest.py +336 -0
- sqlquality-0.3.0/tests/integration/docker-compose.yml +34 -0
- sqlquality-0.3.0/tests/integration/test_advise_live.py +380 -0
- sqlquality-0.3.0/tests/integration/test_introspection_live.py +132 -0
- sqlquality-0.3.0/tests/integration/test_redshift_connect_live.py +104 -0
- sqlquality-0.3.0/tests/integration/test_redshift_introspection_bindable_live.py +132 -0
- sqlquality-0.3.0/tests/test_advise_cli.py +1616 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_check_gate.py +84 -0
- sqlquality-0.3.0/tests/test_ci_integration_job.py +282 -0
- sqlquality-0.3.0/tests/test_integration_fixture.py +172 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_lint_cli.py +26 -0
- sqlquality-0.3.0/tests/test_models.py +134 -0
- sqlquality-0.3.0/tests/test_report_markdown.py +436 -0
- sqlquality-0.3.0/tests/test_workload_aggregate.py +497 -0
- sqlquality-0.3.0/tests/test_workload_connection.py +342 -0
- sqlquality-0.3.0/tests/test_workload_dbt.py +1692 -0
- sqlquality-0.3.0/tests/test_workload_extract.py +343 -0
- sqlquality-0.3.0/tests/test_workload_fingerprint.py +406 -0
- sqlquality-0.3.0/tests/test_workload_postgres.py +1200 -0
- sqlquality-0.3.0/tests/test_workload_redaction.py +185 -0
- sqlquality-0.3.0/tests/test_workload_redshift.py +1101 -0
- sqlquality-0.3.0/tests/test_workload_redshift_rules.py +1609 -0
- sqlquality-0.3.0/tests/test_workload_rules.py +2991 -0
- sqlquality-0.3.0/tests/test_workload_secrets.py +57 -0
- sqlquality-0.3.0/tests/test_workload_session.py +226 -0
- sqlquality-0.2.0/PKG-INFO +0 -519
- sqlquality-0.2.0/README.md +0 -491
- sqlquality-0.2.0/src/sqlquality/cli.py +0 -540
- sqlquality-0.2.0/src/sqlquality/models.py +0 -56
- sqlquality-0.2.0/src/sqlquality/report.py +0 -124
- sqlquality-0.2.0/tests/test_models.py +0 -35
- sqlquality-0.2.0/tests/test_report_markdown.py +0 -78
- {sqlquality-0.2.0 → sqlquality-0.3.0}/.gitignore +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/.pre-commit-hooks.yaml +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/LICENSE +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/adapters/__init__.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/adapters/base.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/adapters/postgres.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/adapters/redshift.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/changeset.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/complexity.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/config.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/delta.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/dialects.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/gate.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/keys.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/linter.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/llm.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/py.typed +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/sqlast.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/fixtures/manifest_v12.json +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_adapters_postgres.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_adapters_redshift.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_antipatterns.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_changeset.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_cli.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_complexity.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_config.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_dbtproject.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_delta.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_dialects.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_gate.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_keys.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_linter.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_llm.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_llm_anthropic.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_perf_cli.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_perf_suggest.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_precommit_hooks.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_report.py +0 -0
- {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_sqlast.py +0 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlquality
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Measure dbt model performance and complexity, and gate changes on the delta.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hanslemm/sqlquality
|
|
6
|
+
Project-URL: Issues, https://github.com/hanslemm/sqlquality/issues
|
|
7
|
+
Author: Hans Lemm
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: complexity,data-engineering,dbt,lint,sql
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: pyyaml>=6
|
|
21
|
+
Requires-Dist: rich>=13
|
|
22
|
+
Requires-Dist: sqlfluff<5,>=4
|
|
23
|
+
Requires-Dist: sqlglot<31,>=30.12
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Provides-Extra: llm
|
|
26
|
+
Requires-Dist: anthropic>=0.40; extra == 'llm'
|
|
27
|
+
Provides-Extra: postgres
|
|
28
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
29
|
+
Provides-Extra: warehouse
|
|
30
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'warehouse'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# sqlquality
|
|
34
|
+
|
|
35
|
+
Measure the **structural complexity** of dbt models' SQL and **gate pull requests
|
|
36
|
+
on the complexity delta** between two dbt manifests. Alongside the gate, `sqlquality`
|
|
37
|
+
runs per-engine static **performance anti-pattern** checks (with optional
|
|
38
|
+
captured-`EXPLAIN` analysis), sqlfluff-backed **linting**, and optional, advisory
|
|
39
|
+
**LLM suggestions**.
|
|
40
|
+
|
|
41
|
+
sqlquality **never executes your SQL**. `complexity`, `lint`, `perf` and `check` are
|
|
42
|
+
fully offline and never open a connection. `advise` is the one exception: it opens a
|
|
43
|
+
**read-only** session to read query history and catalog metadata, using only a fixed set
|
|
44
|
+
of built-in introspection statements. Run `sqlquality advise --dry-run` to print every
|
|
45
|
+
statement it can issue, without connecting.
|
|
46
|
+
|
|
47
|
+
- **Complexity** is computed from the SQL AST (via [sqlglot](https://github.com/tobymao/sqlglot)).
|
|
48
|
+
- **Performance** is static anti-pattern detection plus ingestion of an `EXPLAIN`
|
|
49
|
+
plan you captured yourself — no query is ever run.
|
|
50
|
+
- **Neighbors** (a changed model's direct upstream/downstream models) are *reported*
|
|
51
|
+
for context; they are not scored or gated.
|
|
52
|
+
- **Advice** is derived from your query history and catalog statistics, and is emitted as
|
|
53
|
+
a report plus a DDL file for you to review and apply. sqlquality never writes to your
|
|
54
|
+
database.
|
|
55
|
+
|
|
56
|
+
Requires Python 3.11+.
|
|
57
|
+
|
|
58
|
+
## Contents
|
|
59
|
+
|
|
60
|
+
- [Install](#install)
|
|
61
|
+
- [Commands](#commands)
|
|
62
|
+
- [complexity](#complexity)
|
|
63
|
+
- [lint](#lint)
|
|
64
|
+
- [perf](#perf)
|
|
65
|
+
- [advise](#advise)
|
|
66
|
+
- [check](#check-the-ci-gate)
|
|
67
|
+
- [Configuration](#configuration-sqlqualityyml)
|
|
68
|
+
- [Exit codes](#exit-codes)
|
|
69
|
+
- [CI recipe (a gate that actually gates)](#ci-recipe-a-gate-that-actually-gates)
|
|
70
|
+
- [Pre-commit hook](#pre-commit-hook)
|
|
71
|
+
- [LLM suggestions](#llm-suggestions-optional-advisory)
|
|
72
|
+
- [Limitations](#limitations)
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
Once published to PyPI:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install sqlquality
|
|
80
|
+
# or
|
|
81
|
+
uv add sqlquality
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Until then, install from git:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install "sqlquality @ git+https://github.com/hanslemm/sqlquality"
|
|
88
|
+
# or
|
|
89
|
+
uv add "git+https://github.com/hanslemm/sqlquality"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The optional LLM suggestions feature needs the `llm` extra (pulls in the Anthropic
|
|
93
|
+
SDK):
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install "sqlquality[llm]"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`advise` needs a database driver. For Postgres:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install "sqlquality[postgres]"
|
|
103
|
+
# or, for the driver bundle covering every engine advise targets over time:
|
|
104
|
+
pip install "sqlquality[warehouse]"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Today `[warehouse]` pulls in the same driver as `[postgres]` (psycopg) — Redshift and
|
|
108
|
+
Snowflake support is designed but not yet implemented; see
|
|
109
|
+
[Limitations](#limitations). Without the extra, `advise` degrades with an install hint
|
|
110
|
+
instead of a traceback.
|
|
111
|
+
|
|
112
|
+
`--version` prints the installed version:
|
|
113
|
+
|
|
114
|
+
```console
|
|
115
|
+
$ sqlquality --version
|
|
116
|
+
0.2.0
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Commands
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
sqlquality complexity Score the structural complexity of a single SQL file.
|
|
123
|
+
sqlquality check Gate a dbt change on the complexity delta of its changed models.
|
|
124
|
+
sqlquality lint Lint SQL files for best-practice violations (SQLFluff); --fix rewrites them.
|
|
125
|
+
sqlquality perf Analyze a SQL file for performance anti-patterns (+ optional EXPLAIN plan).
|
|
126
|
+
sqlquality advise Propose database optimizations from query history and catalog metadata.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The `--dialect` / `-d` flag is validated against sqlglot's dialect registry on every
|
|
130
|
+
command; an unknown value fails fast with exit 2 and a suggestion. `complexity` and
|
|
131
|
+
`lint` also accept `-` to read SQL from stdin.
|
|
132
|
+
|
|
133
|
+
### complexity
|
|
134
|
+
|
|
135
|
+
Scores one SQL file and prints a per-metric contribution breakdown plus a composite.
|
|
136
|
+
|
|
137
|
+
```console
|
|
138
|
+
$ sqlquality complexity model.sql
|
|
139
|
+
Complexity — model.sql (composite 18.4)
|
|
140
|
+
┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━┓
|
|
141
|
+
┃ metric ┃ value ┃ contribution ┃
|
|
142
|
+
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━┩
|
|
143
|
+
│ join_count │ 0 │ 0.0 │
|
|
144
|
+
│ cte_count │ 2 │ 4.0 │
|
|
145
|
+
│ subquery_count │ 0 │ 0.0 │
|
|
146
|
+
│ window_count │ 1 │ 4.0 │
|
|
147
|
+
│ case_count │ 0 │ 0.0 │
|
|
148
|
+
│ union_count │ 0 │ 0.0 │
|
|
149
|
+
│ distinct_count │ 0 │ 0.0 │
|
|
150
|
+
│ max_select_depth │ 2 │ 10.0 │
|
|
151
|
+
│ projected_columns │ 2 │ 0.4 │
|
|
152
|
+
└───────────────────┴───────┴──────────────┘
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Read SQL from stdin with `-`:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
cat model.sql | sqlquality complexity -
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`--json` emits a machine-readable payload (composite, per-metric contributions, and
|
|
162
|
+
the raw metrics):
|
|
163
|
+
|
|
164
|
+
```console
|
|
165
|
+
$ sqlquality complexity model.sql --json
|
|
166
|
+
{
|
|
167
|
+
"components": {
|
|
168
|
+
"case_count": 0.0,
|
|
169
|
+
"cte_count": 4.0,
|
|
170
|
+
"distinct_count": 0.0,
|
|
171
|
+
"join_count": 0.0,
|
|
172
|
+
"max_select_depth": 10.0,
|
|
173
|
+
"projected_columns": 0.4,
|
|
174
|
+
"subquery_count": 0.0,
|
|
175
|
+
"union_count": 0.0,
|
|
176
|
+
"window_count": 4.0
|
|
177
|
+
},
|
|
178
|
+
"composite": 18.4,
|
|
179
|
+
"dialect": "postgres",
|
|
180
|
+
"metrics": {
|
|
181
|
+
"case_count": 0,
|
|
182
|
+
"cte_count": 2,
|
|
183
|
+
"distinct_count": 0,
|
|
184
|
+
"join_count": 0,
|
|
185
|
+
"max_select_depth": 2,
|
|
186
|
+
"projected_columns": 2,
|
|
187
|
+
"select_count": 3,
|
|
188
|
+
"subquery_count": 0,
|
|
189
|
+
"union_count": 0,
|
|
190
|
+
"window_count": 1
|
|
191
|
+
},
|
|
192
|
+
"path": "model.sql"
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**dbt / Jinja models:** if the file contains Jinja (`{{ ... }}`, `{% ... %}`),
|
|
197
|
+
`sqlquality` first tries to parse it as-is; on failure it retries with Jinja
|
|
198
|
+
markers stripped to placeholders and prints a notice to **stderr**:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
analyzed with Jinja placeholders — results are approximate; prefer compiled SQL from target/compiled/
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
For accurate scores, point `complexity` at compiled SQL from `target/compiled/`
|
|
205
|
+
after `dbt compile`. The composite is a real, comparable score in both cases — but
|
|
206
|
+
placeholder-stripped results are approximate.
|
|
207
|
+
|
|
208
|
+
### lint
|
|
209
|
+
|
|
210
|
+
Lints SQL with sqlfluff and prints findings per file.
|
|
211
|
+
|
|
212
|
+
```console
|
|
213
|
+
$ sqlquality lint messy.sql
|
|
214
|
+
Lint —
|
|
215
|
+
messy.sql (5 findings)
|
|
216
|
+
┏━━━━━━┳━━━━━━┳━━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
217
|
+
┃ line ┃ code ┃ severity ┃ fix? ┃ message ┃
|
|
218
|
+
┡━━━━━━╇━━━━━━╇━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
|
219
|
+
│ 1 │ AM04 │ warning │ │ Query produces an unknown number of result │
|
|
220
|
+
│ │ │ │ │ columns. │
|
|
221
|
+
│ 1 │ RF02 │ warning │ │ Unqualified reference '*' found in select … │
|
|
222
|
+
│ 2 │ AL01 │ warning │ ✓ │ Implicit/explicit aliasing of table. │
|
|
223
|
+
│ 2 │ AL01 │ warning │ ✓ │ Implicit/explicit aliasing of table. │
|
|
224
|
+
│ 2 │ AL05 │ warning │ ✓ │ Alias 'o' is never used in SELECT statement. │
|
|
225
|
+
└──────┴──────┴──────────┴──────┴──────────────────────────────────────────────┘
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Exit-code semantics:** `lint` exits **1** when any `WARNING`/`ERROR` finding is
|
|
229
|
+
present, so it gates CI and pre-commit by default. `--warn-only` prints/emits
|
|
230
|
+
findings but always exits 0. Findings from unresolved Jinja are demoted to `info`
|
|
231
|
+
severity and **never** gate.
|
|
232
|
+
|
|
233
|
+
Useful flags:
|
|
234
|
+
|
|
235
|
+
| Flag | Effect |
|
|
236
|
+
|---|---|
|
|
237
|
+
| `--fix` | Rewrite the file with auto-fixes. The exit code still reflects *pre-fix* findings (a fully-fixed file still exits 1). Cannot rewrite stdin. |
|
|
238
|
+
| `--warn-only` | Always exit 0. |
|
|
239
|
+
| `--sqlfluff-config <file>` | Apply a custom sqlfluff config (e.g. `.sqlfluff`). |
|
|
240
|
+
| `--exclude-rules <codes>` | Comma-separated rule codes to skip. |
|
|
241
|
+
| `--json` | Emit machine-readable JSON. |
|
|
242
|
+
|
|
243
|
+
`lint` accepts multiple files (and `-` for stdin), which is what the pre-commit hook
|
|
244
|
+
relies on.
|
|
245
|
+
|
|
246
|
+
### perf
|
|
247
|
+
|
|
248
|
+
Detects static performance anti-patterns for a given engine, and optionally folds in
|
|
249
|
+
findings parsed from a captured `EXPLAIN` plan.
|
|
250
|
+
|
|
251
|
+
```console
|
|
252
|
+
$ sqlquality perf messy.sql
|
|
253
|
+
Perf — messy.sql (postgres, 3 findings)
|
|
254
|
+
┏━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
255
|
+
┃ code ┃ severity ┃ message ┃
|
|
256
|
+
┡━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
|
257
|
+
│ SQ001 │ warning │ SELECT * projects an unknown/wide column set; list columns │
|
|
258
|
+
│ │ │ explicitly. │
|
|
259
|
+
│ SQ002 │ warning │ Cartesian/cross join without an ON/USING condition. │
|
|
260
|
+
│ SQ003 │ warning │ Leading-wildcard LIKE ('%...') is non-sargable and cannot use an │
|
|
261
|
+
│ │ │ index. │
|
|
262
|
+
└───────┴──────────┴─────────────────────────────────────────────────────────────────────┘
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Supported engines: **`postgres`** and **`redshift`** (Redshift additionally infers
|
|
266
|
+
`DISTKEY`/`SORTKEY` advice). Any other valid sqlglot dialect is accepted for
|
|
267
|
+
`complexity`/`lint` but has no perf adapter, so `perf` exits 2 for it.
|
|
268
|
+
|
|
269
|
+
**Exit code:** `perf` exits **1 only when a finding is `ERROR` severity** — which in
|
|
270
|
+
practice means the SQL was unparseable (`SQ000`). Anti-pattern findings are `warning`
|
|
271
|
+
severity and exit **0**, so `perf` surfaces advice without blocking a build. Bad
|
|
272
|
+
input (missing file, unreadable `--explain`) exits 2.
|
|
273
|
+
|
|
274
|
+
**Captured EXPLAIN.** `--explain <file>` takes a plan you captured yourself:
|
|
275
|
+
|
|
276
|
+
- **Postgres:** `EXPLAIN (FORMAT JSON) <query>` output (JSON).
|
|
277
|
+
- **Redshift:** the plan text from `EXPLAIN <query>`.
|
|
278
|
+
|
|
279
|
+
```console
|
|
280
|
+
$ sqlquality perf messy.sql --explain plan.json
|
|
281
|
+
Perf — messy.sql (postgres, 4 findings)
|
|
282
|
+
┏━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
283
|
+
┃ code ┃ severity ┃ message ┃
|
|
284
|
+
┡━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
|
285
|
+
│ SQ001 │ warning │ SELECT * projects an unknown/wide column set; list columns … │
|
|
286
|
+
│ SQ002 │ warning │ Cartesian/cross join without an ON/USING condition. │
|
|
287
|
+
│ SQ003 │ warning │ Leading-wildcard LIKE ('%...') is non-sargable … │
|
|
288
|
+
│ PG001 │ warning │ Seq Scan on orders — consider an index if the filter is selective. │
|
|
289
|
+
└───────┴──────────┴─────────────────────────────────────────────────────────────────────┘
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`--json` emits findings and any LLM suggestions. `--suggest` enriches findings with
|
|
293
|
+
advisory LLM suggestions — see [LLM suggestions](#llm-suggestions-optional-advisory).
|
|
294
|
+
|
|
295
|
+
### advise
|
|
296
|
+
|
|
297
|
+
Reads a database's query history and catalog metadata over a **read-only** connection,
|
|
298
|
+
weights column usage by the cost of the queries that use it, and proposes concrete
|
|
299
|
+
optimizations — indexes to add, indexes to drop, partial indexes, non-sargable
|
|
300
|
+
predicates, and hot `SELECT *`. Output is an advisory report plus a DDL file for you to
|
|
301
|
+
review. **`advise` never writes to your database and never executes DDL.**
|
|
302
|
+
|
|
303
|
+
**Postgres** and **Redshift** are implemented; Snowflake is designed but not built — see
|
|
304
|
+
[Limitations](#limitations). An optional dbt manifest enriches the same analysis — see
|
|
305
|
+
[dbt enrichment](#dbt-enrichment-optional) below.
|
|
306
|
+
|
|
307
|
+
**What is proven for Redshift, and what is not — read this before pointing `--engine
|
|
308
|
+
redshift` at a production cluster.** There is no Redshift container available for
|
|
309
|
+
development, and Postgres — where every other engine's introspection SQL gets exercised
|
|
310
|
+
during tests — does not implement Redshift's `svv_*`/`sys_*` system views at all, so
|
|
311
|
+
nothing in this adapter can be run against a real Redshift cluster before release. What
|
|
312
|
+
*is* verified: the **connection path** (Redshift speaks the PostgreSQL wire protocol, so
|
|
313
|
+
the read-only session, the statement timeout and secret scrubbing are exercised live
|
|
314
|
+
against a real Postgres server); every introspection **statement's syntax**, checked with
|
|
315
|
+
sqlglot's `redshift` dialect; and every statement's **bindability** — that its parameters
|
|
316
|
+
can actually be prepared and sent over the wire — proven live against stand-in tables
|
|
317
|
+
shaped like the real views. What is **not** verified: the **column names and the
|
|
318
|
+
semantics of the resulting proposals**. Those come from AWS's published system-view
|
|
319
|
+
documentation, not from an observed row, and have never been executed against a live
|
|
320
|
+
cluster. A wrong column name degrades one capability (recorded in `degraded`, never a
|
|
321
|
+
crash — see the Redshift section below), but it can still mean thin or wrong evidence.
|
|
322
|
+
Run `sqlquality advise --engine redshift --dry-run` first: it prints every statement this
|
|
323
|
+
adapter can issue, with no connection at all, so you can review it — or hand it to a DBA
|
|
324
|
+
— before `advise` ever touches your cluster. If you run this against a real cluster,
|
|
325
|
+
please [open an issue](https://github.com/hanslemm/sqlquality/issues) with what you found;
|
|
326
|
+
the first user with a cluster is part of closing this gap, not just a consumer of it.
|
|
327
|
+
|
|
328
|
+
```console
|
|
329
|
+
$ sqlquality advise --dsn postgresql://readonly@db.internal/analytics
|
|
330
|
+
engine: postgres (credentials from --dsn)
|
|
331
|
+
window: since stats reset at 2026-07-19 03:00:00+00
|
|
332
|
+
analyzed 3 of 3 query group(s); skipped 0 unparseable, 0 filtered, 0 unresolvable
|
|
333
|
+
Advise — postgres (5 proposals, 3 query groups)
|
|
334
|
+
┏━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
335
|
+
┃ code ┃ conf ┃ cost share ┃ proposal ┃
|
|
336
|
+
┡━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
|
337
|
+
│ ADV001 │ high │ 67.0% │ Add index on orders(status) │
|
|
338
|
+
│ ADV005 │ high │ 22.7% │ Non-sargable predicate on orders.email │
|
|
339
|
+
│ ADV006 │ medium │ 22.7% │ Hot SELECT * over wide table(s): orders │
|
|
340
|
+
│ ADV005 │ medium │ 10.3% │ Leading-wildcard LIKE in a hot query group │
|
|
341
|
+
│ ADV002 │ medium │ — │ Drop unused index idx_orders_customer_ref on │
|
|
342
|
+
│ │ │ │ orders │
|
|
343
|
+
└────────┴────────┴────────────┴───────────────────────────────────────────────┘
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**Credentials**, resolved in precedence order:
|
|
347
|
+
|
|
348
|
+
1. `--dsn` — a full database URL.
|
|
349
|
+
2. `SQLQUALITY_DSN` — the same, from the environment.
|
|
350
|
+
3. `--profile` (with optional `--target` and `--profiles-dir`, default `~/.dbt`) — reads a
|
|
351
|
+
dbt `profiles.yml`. `advise` is not dbt-specific; this is a convenience for projects
|
|
352
|
+
that happen to have one, not a requirement.
|
|
353
|
+
|
|
354
|
+
The engine is inferred from the DSN scheme (`postgresql://` → `postgres`) or the resolved
|
|
355
|
+
dbt adapter type; `--engine` overrides both. The resolved source is always printed to
|
|
356
|
+
stderr — `engine: postgres (credentials from --dsn)` — the same discipline `check` uses
|
|
357
|
+
for its dialect resolution. Requires the `sqlquality[postgres]` extra (`psycopg`); a
|
|
358
|
+
missing driver degrades with an install hint instead of a traceback.
|
|
359
|
+
|
|
360
|
+
**Flags:**
|
|
361
|
+
|
|
362
|
+
| Flag | Default | Effect |
|
|
363
|
+
|---|---|---|
|
|
364
|
+
| `--engine` | inferred | `postgres` or `redshift`. See the Redshift section below for what is and is not proven on that engine. |
|
|
365
|
+
| `--dsn` | — | Database URL. Overrides `SQLQUALITY_DSN`. |
|
|
366
|
+
| `--profile` | — | dbt profile name, read from `profiles.yml`. |
|
|
367
|
+
| `--target` | — | dbt target within the profile. |
|
|
368
|
+
| `--profiles-dir` | `~/.dbt` | Directory holding `profiles.yml`. |
|
|
369
|
+
| `--project-dir` | — | dbt project dir; reads `target/manifest.json` to enrich proposals (optional). See [dbt enrichment](#dbt-enrichment-optional). |
|
|
370
|
+
| `--manifest` | — | Path to a dbt `manifest.json`. Overrides `--project-dir`. |
|
|
371
|
+
| `--schema` | `public` | Schema to introspect. Repeat for several: `--schema public --schema sales`. See Limitations for the ambiguity caveat. |
|
|
372
|
+
| `--since` | — | Window, e.g. `7d`. **Not honored on Postgres** — see Prerequisites below. |
|
|
373
|
+
| `--limit` | `500` | Max query-history rows to read. **On Redshift this counts *executions*, not query groups** — see the Redshift section below. |
|
|
374
|
+
| `--min-cost-share` | `0.01` | Suppress proposals below this share of workload cost. Applies to the **cost-weighted** rules (ADV001, ADV004, ADV005, ADV006, ADV007, ADV008, ADV301 — the last only with `--project-dir`/`--manifest`); the index-hygiene rules **ADV002 and ADV003**, and **ADV303** (its evidence is absence, not cost, so there is no share to threshold), carry no cost evidence and are reported whatever the threshold. ADV303 has its own non-threshold suppression: it emits nothing at all when no query usage could be extracted, since then every model would look untouched by definition. |
|
|
375
|
+
| `--keep-literals` | off | Do **not** redact literal values from query text. |
|
|
376
|
+
| `--timeout` | `30` | Statement timeout in seconds (rejected outside 1–3600). |
|
|
377
|
+
| `--dry-run` | off | Print every statement the adapter would issue, then exit 0 **without connecting**. |
|
|
378
|
+
| `--json` | off | Emit a machine-readable payload. |
|
|
379
|
+
| `--markdown <path>` | — | Write a markdown report. |
|
|
380
|
+
| `--ddl <path>` | — | Write proposed DDL for review — sqlquality never executes it. |
|
|
381
|
+
|
|
382
|
+
**`--dry-run`** is how you verify the read-only claim before `advise` ever touches your
|
|
383
|
+
database — it prints the complete, fixed set of statements the adapter can issue and
|
|
384
|
+
exits 0 without connecting:
|
|
385
|
+
|
|
386
|
+
```console
|
|
387
|
+
$ sqlquality advise --engine postgres --dry-run
|
|
388
|
+
-- workload: requires the pg_stat_statements extension (PostgreSQL 13+) and pg_read_all_stats or superuser; enable via shared_preload_libraries then CREATE EXTENSION. On PostgreSQL 12 and older the view lacks total_exec_time and this will fail.
|
|
389
|
+
SELECT s.query, s.calls, s.total_exec_time, s.rows
|
|
390
|
+
FROM pg_stat_statements s
|
|
391
|
+
JOIN pg_database d ON d.oid = s.dbid
|
|
392
|
+
WHERE d.datname = current_database()
|
|
393
|
+
ORDER BY s.total_exec_time DESC
|
|
394
|
+
LIMIT %s
|
|
395
|
+
|
|
396
|
+
-- stats_reset: reads pg_stat_database; world-readable unless explicitly revoked
|
|
397
|
+
SELECT stats_reset
|
|
398
|
+
FROM pg_stat_database
|
|
399
|
+
WHERE datname = current_database()
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
(truncated here; the real output also lists the `schema`, `table_facts`, `ndv` and
|
|
403
|
+
`indexes` capabilities). `sqlquality advise --engine redshift --dry-run` works exactly the
|
|
404
|
+
same way — no credentials needed, no connection made — and is the recommended way to
|
|
405
|
+
review Redshift's introspection SQL yourself (or hand it to a DBA) before trusting it with
|
|
406
|
+
a real cluster; see the note at the top of the [Redshift
|
|
407
|
+
section](#redshift---engine-redshift) for what is and is not verified about it. `--json`
|
|
408
|
+
is honored on `--dry-run` too, so the statement list can
|
|
409
|
+
be diffed or fed into review tooling.
|
|
410
|
+
|
|
411
|
+
**Data protection.** Query history routinely contains personal data inside predicates
|
|
412
|
+
(`WHERE email = 'name@example.com'`). Literal values are **redacted at ingest, by
|
|
413
|
+
default**: every literal in the parsed query is replaced with a placeholder before
|
|
414
|
+
aggregation, before any file is written, before any log line. `--keep-literals` is the
|
|
415
|
+
only way to retain them, and the report states which mode produced it. `advise` never
|
|
416
|
+
writes to your database — proposed DDL only ever goes to a file (`--ddl`) for you to
|
|
417
|
+
review and apply by hand.
|
|
418
|
+
|
|
419
|
+
One rendering quirk worth knowing before you read a report: `pg_stat_statements` replaces
|
|
420
|
+
an interval literal with its own parameter marker (`interval $2`), and sqlglot renders that
|
|
421
|
+
back as `INTERVAL '2'`. So `created_at > CURRENT_TIMESTAMP - INTERVAL '2'` in a report
|
|
422
|
+
stamped `"redacted": true` means **the interval was parameterised**, not that someone wrote
|
|
423
|
+
a two-something interval — the `2` is Postgres's parameter index. Nothing leaked, but the
|
|
424
|
+
statement is not valid SQL to copy out and run.
|
|
425
|
+
|
|
426
|
+
**Prerequisites and limits:**
|
|
427
|
+
|
|
428
|
+
- **`pg_stat_statements`** must be installed (`shared_preload_libraries` +
|
|
429
|
+
`CREATE EXTENSION`), and the connecting role needs `pg_read_all_stats` or superuser to
|
|
430
|
+
see queries run by other users.
|
|
431
|
+
- **PostgreSQL 13+.** `pg_stat_statements.total_exec_time` did not exist before version 13
|
|
432
|
+
(it was `total_time`); older servers fail the workload read outright.
|
|
433
|
+
- **`--since` cannot be honored on Postgres.** `pg_stat_statements` is cumulative since the
|
|
434
|
+
last statistics reset and carries no per-statement timestamps before PostgreSQL 17
|
|
435
|
+
(which added `stats_since`). Passing `--since` does not narrow the query — the report
|
|
436
|
+
states the real window instead: `since stats reset at <timestamp>`.
|
|
437
|
+
|
|
438
|
+
**Proposal codes** (Postgres):
|
|
439
|
+
|
|
440
|
+
| Code | Proposal | Evidence |
|
|
441
|
+
|---|---|---|
|
|
442
|
+
| ADV001 | Composite index candidate: hot equality columns, then one range/sort column, arity ≤ 3, and only columns some single query group filters on *together* | cost share, NDV, row estimate, joint co-occurring fingerprint count, absence of a covering index |
|
|
443
|
+
| ADV002 | Drop an index with zero recorded scans (excludes unique/primary-key indexes) | scans since last stats reset, size |
|
|
444
|
+
| ADV003 | Drop an index whose column list is a strict prefix of a wider index | both column lists |
|
|
445
|
+
| ADV004 | Partial index: a hot equality column guarded by a hot, co-occurring `IS [NOT] NULL` check | cost share, co-occurring fingerprint count, absence of a plain index leading with the guarded column |
|
|
446
|
+
| ADV005 | Non-sargable predicate — a cast/function on a column, or a leading-wildcard `LIKE` | cost share |
|
|
447
|
+
| ADV006 | Hot `SELECT *` on a wide table (≥15 columns) | cost share, column count |
|
|
448
|
+
| ADV007 | Add index on a hot join key with no existing index leading with it | cost share, NDV, row estimate, absence of a covering index |
|
|
449
|
+
| ADV008 | Composite index for a hot `GROUP BY`, column order inferred from cost, capped at MEDIUM | cost share, row estimate, absence of a covering index |
|
|
450
|
+
| ADV301¹ | Materialize a `view`-backed dbt model that carries a hot share of workload cost, capped at MEDIUM | cost share, dbt model |
|
|
451
|
+
| ADV303¹ | A dbt model within reach of the manifest that the analyzed workload never touched and no other model, snapshot or exposure declares as a consumer, capped at LOW | dbt model |
|
|
452
|
+
|
|
453
|
+
¹ Only fires with `--project-dir` or `--manifest` loaded — see [dbt enrichment](#dbt-enrichment-optional).
|
|
454
|
+
|
|
455
|
+
**ADV302 is not in that table, because it is not a proposal code.** It is a *rewrite*
|
|
456
|
+
applied to another rule's proposal — ADV001, ADV004, ADV007 or ADV008 keeps its own code,
|
|
457
|
+
confidence and cost share, and only its `ddl` and `rationale` change. So no proposal ever
|
|
458
|
+
carries `code: "ADV302"`, and a `--json` consumer filtering on that code sees zero rows on
|
|
459
|
+
every run; filter on `evidence.dbt_index_config == true` instead (present, and `true`, only
|
|
460
|
+
on a proposal whose DDL was replaced by a dbt config block). The terminal table shows the
|
|
461
|
+
original rule's row unchanged, so `advise` prints a line on stderr saying how many proposals
|
|
462
|
+
ADV302 rewrote. See [dbt enrichment](#dbt-enrichment-optional).
|
|
463
|
+
|
|
464
|
+
**Confidence model**, mechanical rather than judgment-based:
|
|
465
|
+
|
|
466
|
+
- **HIGH** — cost share above `--min-cost-share`, **and** supporting catalog stats present
|
|
467
|
+
(e.g. NDV), **and** confirmation that the proposed index does not already exist.
|
|
468
|
+
- **MEDIUM** — cost evidence is solid but a catalog input is missing or stale. ADV002 is
|
|
469
|
+
capped at MEDIUM unconditionally: `idx_scan` only accumulates since the last statistics
|
|
470
|
+
reset, so zero scans can never prove an index is unused across a full business cycle.
|
|
471
|
+
ADV008 is also capped at MEDIUM unconditionally, for a different reason: whether Postgres
|
|
472
|
+
uses the index for grouping depends on its choice between `GroupAggregate` and
|
|
473
|
+
`HashAggregate`, a planner decision driven by `work_mem` and group cardinality that no
|
|
474
|
+
catalog view exposes — HIGH would claim to know the planner's choice, not the catalog's
|
|
475
|
+
state, so ADV008 never reaches it.
|
|
476
|
+
- **LOW** — thin evidence, and specifically **any check that could not be run**: the row
|
|
477
|
+
count is unknown so the small-table floor could not be applied, or the existing-index
|
|
478
|
+
list was denied so "no index already covers this" could not be confirmed. Absent
|
|
479
|
+
evidence lowers confidence; it is never assumed away.
|
|
480
|
+
|
|
481
|
+
Every proposal's evidence renders inline (cost share, calls, fingerprints, row estimate,
|
|
482
|
+
NDV, existing index state) so it can be judged from the report alone.
|
|
483
|
+
|
|
484
|
+
#### Redshift (`--engine redshift`)
|
|
485
|
+
|
|
486
|
+
Redshift has no indexes at all, so none of ADV001–ADV008 apply. Its physical-design
|
|
487
|
+
levers are different, and so is the blast radius: **ADV101, ADV102 and ADV103 each
|
|
488
|
+
rewrite the entire table.** Redshift copies every row, holds a lock on the table for the
|
|
489
|
+
whole rewrite, and needs roughly the table's own size again in free disk space while it
|
|
490
|
+
runs — on a large table that is hours, not seconds. Unlike a Postgres `CREATE INDEX
|
|
491
|
+
CONCURRENTLY`, **there is no concurrent-build escape on Redshift**: none of the three can
|
|
492
|
+
be applied alongside normal traffic. Schedule them for a maintenance window; do not run
|
|
493
|
+
them ad hoc. `--ddl`'s generated script says this loudly, at the top of the file, not only
|
|
494
|
+
beside each individual statement.
|
|
495
|
+
|
|
496
|
+
| Code | Proposal | Table rewrite? | Evidence |
|
|
497
|
+
|---|---|---|---|
|
|
498
|
+
| ADV101 | `ALTER TABLE ... ALTER SORTKEY`: sort the table on its hottest range/equality predicate column, capped at MEDIUM | **Yes** | cost share, current sort key, `stats_off` staleness |
|
|
499
|
+
| ADV102 | `ALTER TABLE ... ALTER DISTKEY`: distribute the table on its hottest join predicate column, capped at MEDIUM | **Yes** | cost share, current distribution style, `skew_rows`, `stats_off` |
|
|
500
|
+
| ADV103 | `ALTER TABLE ... ALTER DISTSTYLE ALL`: replicate a small (≤1,000,000-row), frequently-joined dimension to every node, capped at MEDIUM | **Yes** | cost share, row estimate, current distribution style |
|
|
501
|
+
| ADV104 | `VACUUM` (unsorted region ≥20%) and/or `ANALYZE` (stale statistics ≥20%), each its own proposal | **No** — reclaims sort order or refreshes statistics in place; no exclusive lock for its duration | `unsorted`, `stats_off` (direct catalog measurements) |
|
|
502
|
+
| ADV105 | Amazon Redshift Advisor's own SORTKEY/DISTSTYLE recommendation, relayed verbatim | Whatever Advisor recommends — read its own `note` | attributed as Advisor's, not sqlquality's |
|
|
503
|
+
|
|
504
|
+
**ADV101, ADV102 and ADV103 can never reach HIGH confidence, by design, not merely by
|
|
505
|
+
current implementation.** Whether a SORTKEY, DISTKEY or DISTSTYLE ALL change is actually
|
|
506
|
+
worth its rewrite depends on the predicate's selectivity and the table's distribution
|
|
507
|
+
skew — and Redshift exposes no per-column distinct-value statistics (no `pg_stats
|
|
508
|
+
.n_distinct` equivalent) to measure either. Claiming HIGH would assert something about
|
|
509
|
+
data distribution this tool cannot see, while recommending a statement that rewrites the
|
|
510
|
+
whole table. ADV104 is the exception: `unsorted`/`stats_off` are direct catalog
|
|
511
|
+
measurements, not an inference about data this tool cannot see, and its remediation does
|
|
512
|
+
not rewrite anything — so it is also the only Redshift rule that can reach HIGH.
|
|
513
|
+
|
|
514
|
+
**ADV105 is Redshift Advisor's own recommendation, never sqlquality's inference — and it
|
|
515
|
+
says so everywhere a reader might look.** Its title, rationale, evidence
|
|
516
|
+
(`evidence.source == "amazon_redshift_advisor"`) and `note` all attribute it explicitly,
|
|
517
|
+
and the DDL script marks its header line `(Amazon Redshift Advisor — not sqlquality)` on
|
|
518
|
+
top of that — someone skimming only header lines, never the prose, still cannot mistake
|
|
519
|
+
an Advisor statement for one this tool generated. When ADV101/102/103 and an Advisor row
|
|
520
|
+
agree on the same table and category, the sqlquality proposal's rationale says so as an
|
|
521
|
+
added sentence; the two stay separate proposals rather than merging, so it is always
|
|
522
|
+
clear which conclusion is whose. When ADV103 (DISTSTYLE ALL) and ADV102 (DISTKEY) both
|
|
523
|
+
fire for the same table, only ADV103 survives — replicating to every node already removes
|
|
524
|
+
redistribution for every join, which strictly subsumes any single-column DISTKEY choice —
|
|
525
|
+
and the surviving proposal says so.
|
|
526
|
+
|
|
527
|
+
A relation with a hot predicate but absent from Redshift's own physical-design catalog
|
|
528
|
+
(`svv_table_info`) gets **no** ADV101/102/103 proposal at all, and the run discloses how
|
|
529
|
+
many relations this affected (`reduced coverage — physical_facts_gap: ...`) rather than
|
|
530
|
+
silently dropping them: that absence cannot, by itself, tell an external Spectrum table
|
|
531
|
+
(which cannot carry a SORTKEY/DISTKEY/DISTSTYLE) apart from a genuinely empty local one,
|
|
532
|
+
and proposing a rewrite for something that might not even support one is worse than
|
|
533
|
+
proposing nothing.
|
|
534
|
+
|
|
535
|
+
**The workload can come back silently partial — grant `SYSLOG ACCESS UNRESTRICTED` first.**
|
|
536
|
+
`advise` reads `sys_query_history`, and without that privilege Redshift does not deny the
|
|
537
|
+
read: it returns **only the connecting user's own queries**. There is no error, no denied
|
|
538
|
+
capability and nothing in `degraded` — a cluster whose whole workload is invisible to your
|
|
539
|
+
read-only role looks exactly like a quiet cluster with little traffic, and every proposal is
|
|
540
|
+
then built from one user's slice of it. Grant it before your first run:
|
|
541
|
+
|
|
542
|
+
```sql
|
|
543
|
+
ALTER USER <your_readonly_user> SYSLOG ACCESS UNRESTRICTED; -- superuser-only
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
`--dry-run` prints this same warning beside the statement it applies to, and the hint is
|
|
547
|
+
also recorded in `degraded` **if** the read is refused outright — but the failure described
|
|
548
|
+
here is precisely the one that is never refused, so the hint alone is not disclosure. This
|
|
549
|
+
is the same class of trap as Postgres's `pg_stats`, and unlike a missing grant it costs you
|
|
550
|
+
coverage rather than a capability.
|
|
551
|
+
|
|
552
|
+
**`--limit` means executions on Redshift, not query groups.** `sys_query_history` is one
|
|
553
|
+
row per *execution*, where Postgres's `pg_stat_statements` is already aggregated per
|
|
554
|
+
normalised statement — so `--limit 500` reads the 500 most expensive **executions**, and 500
|
|
555
|
+
executions of one bad query is a legal outcome that leaves every other statement unseen.
|
|
556
|
+
The `window:` line names what was actually read ("the 500 most expensive successful queries
|
|
557
|
+
…"); raise `--limit` if the coverage line shows fewer query groups than you expect.
|
|
558
|
+
|
|
559
|
+
**dbt interaction.** [ADV302](#dbt-enrichment-optional) rewrites `CREATE INDEX`
|
|
560
|
+
proposals into dbt `indexes:` config, which has no Redshift equivalent (SORTKEY/DISTKEY
|
|
561
|
+
have no comparable dbt config key modeled by this tool). Rather than leave a dbt-managed
|
|
562
|
+
Redshift model's table-rewrite proposal silently unwarned — which would be worse than the
|
|
563
|
+
Postgres case ADV302 exists to fix, since the wasted work is hours rather than seconds —
|
|
564
|
+
`enrich_proposals`'s existing generic path (built for any DDL that is not `CREATE INDEX`
|
|
565
|
+
or `DROP INDEX`) already recognises ADV101–105 and attaches a warning to both the
|
|
566
|
+
proposal's `rationale` **and** its `--ddl` `note`: that the relation is dbt-managed, that
|
|
567
|
+
the statement is not expressed as dbt config, and that it may not survive the model's next
|
|
568
|
+
rebuild. This is not the same thing as the `adapter_type` mismatch warning: a Redshift dbt
|
|
569
|
+
project correctly records `adapter_type: redshift`, so that check does not fire — this is
|
|
570
|
+
a separate, always-on warning specific to table-rewrite and maintenance statements.
|
|
571
|
+
|
|
572
|
+
**How overlapping proposals are reconciled.** The rules above are evaluated independently,
|
|
573
|
+
but their output is not shipped independently: two of them can reach the same index from
|
|
574
|
+
different evidence, and following both would mean creating a redundant pair that ADV003 then
|
|
575
|
+
advises dropping on the next run. So before anything is reported:
|
|
576
|
+
|
|
577
|
+
- **Identical DDL collapses to one proposal.** The higher confidence wins; on a tie a fixed
|
|
578
|
+
rule preference decides, never list order.
|
|
579
|
+
- **A narrower index collapses into a wider one.** If one proposal's columns are a leading
|
|
580
|
+
prefix of another's for the same table, only the wider survives — it serves every lookup
|
|
581
|
+
the narrower would. Partial (`WHERE`) proposals never participate: a partial index is a
|
|
582
|
+
different object even when its column list is a prefix.
|
|
583
|
+
- **Same columns in a different order are both kept**, each disclosing the other. `(status,
|
|
584
|
+
region)` and `(region, status)` serve different probes, so neither is redundant — but
|
|
585
|
+
creating both means indexing the same columns twice, and the report says so.
|
|
586
|
+
|
|
587
|
+
Two consequences worth knowing before you consume the output:
|
|
588
|
+
|
|
589
|
+
- **A rule can fire and still contribute no proposal.** A `--json` consumer counting
|
|
590
|
+
`ADV007` entries can legitimately see zero on a run where ADV007 did propose something
|
|
591
|
+
that was absorbed. The absorbed proposal's rule code, confidence and rationale appear in
|
|
592
|
+
the surviving proposal's `rationale`, attributed — that is where to look for it.
|
|
593
|
+
- **The absorbed proposal's `evidence` is discarded, not merged.** Its rationale (the
|
|
594
|
+
constraint an operator needs) is preserved verbatim; its numbers (`leading_ndv`,
|
|
595
|
+
`partial_indexes_skipped`, `co_occurring_fingerprints`) are not carried into the
|
|
596
|
+
survivor's evidence block.
|
|
597
|
+
|
|
598
|
+
`--ddl` writes a standalone, commented script — never executed by sqlquality:
|
|
599
|
+
|
|
600
|
+
```sql
|
|
601
|
+
-- Generated by `sqlquality advise` — REVIEW BEFORE RUNNING.
|
|
602
|
+
-- sqlquality does not execute this script and has not validated it against
|
|
603
|
+
-- your workload's write patterns. Each statement is advisory.
|
|
604
|
+
--
|
|
605
|
+
-- On a live table prefer CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY:
|
|
606
|
+
-- the plain forms below take a lock that blocks writes for the duration.
|
|
607
|
+
-- Note that CONCURRENTLY cannot run inside a transaction block, so apply those
|
|
608
|
+
-- statements individually rather than piping this whole file into one.
|
|
609
|
+
|
|
610
|
+
-- ADV001 [high, 67.0% of workload cost]
|
|
611
|
+
-- Add index on orders(status)
|
|
612
|
+
CREATE INDEX ON "public"."orders" ("status");
|
|
613
|
+
|
|
614
|
+
-- ADV002 [medium]
|
|
615
|
+
-- Drop unused index idx_orders_customer_ref on orders
|
|
616
|
+
DROP INDEX "public"."idx_orders_customer_ref";
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
`--json` emits the same evidence as a structured payload (`analyzed`, `degraded`,
|
|
620
|
+
`engine`, `proposals`, `redacted`, `skipped`, `window`, plus `dbt` when — and only when — a
|
|
621
|
+
manifest was loaded). This is the first proposal from
|
|
622
|
+
the run above — the real payload lists all five under `proposals`:
|
|
623
|
+
|
|
624
|
+
```console
|
|
625
|
+
$ sqlquality advise --dsn postgresql://readonly@db.internal/analytics --json
|
|
626
|
+
{
|
|
627
|
+
"analyzed": {
|
|
628
|
+
"query_groups": 3,
|
|
629
|
+
"query_groups_in_window": 3,
|
|
630
|
+
"tables": [
|
|
631
|
+
"orders"
|
|
632
|
+
],
|
|
633
|
+
"total_cost_ms": 925000.0
|
|
634
|
+
},
|
|
635
|
+
"degraded": [],
|
|
636
|
+
"engine": "postgres",
|
|
637
|
+
"proposals": [
|
|
638
|
+
{
|
|
639
|
+
"code": "ADV001",
|
|
640
|
+
"confidence": "high",
|
|
641
|
+
"ddl": "CREATE INDEX ON \"orders\" (\"status\");",
|
|
642
|
+
"evidence": {
|
|
643
|
+
"calls": 15000,
|
|
644
|
+
"co_occurring_fingerprints": 1,
|
|
645
|
+
"columns": [
|
|
646
|
+
"status"
|
|
647
|
+
],
|
|
648
|
+
"cost_share": 0.6702702702702703,
|
|
649
|
+
"leading_ndv": 500.0,
|
|
650
|
+
"roles": [
|
|
651
|
+
"equality"
|
|
652
|
+
],
|
|
653
|
+
"row_estimate": 5200000,
|
|
654
|
+
"table": "orders"
|
|
655
|
+
},
|
|
656
|
+
"rationale": "These columns carry the table's hottest predicates and no existing index leads with them. Equality columns come first so the range column can be scanned last.",
|
|
657
|
+
"title": "Add index on orders(status)"
|
|
658
|
+
}
|
|
659
|
+
/* … 4 more proposal objects, same shape … */
|
|
660
|
+
],
|
|
661
|
+
"redacted": true,
|
|
662
|
+
"skipped": {
|
|
663
|
+
"noise": 0,
|
|
664
|
+
"unparseable": 0,
|
|
665
|
+
"unqualifiable": 0,
|
|
666
|
+
"ambiguous": 0
|
|
667
|
+
},
|
|
668
|
+
"window": "since stats reset at 2026-07-19 03:00:00+00"
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
**Coverage is always disclosed**, not just when it is bad — the terminal, markdown and
|
|
673
|
+
JSON paths all print how many query groups were actually understood:
|
|
674
|
+
|
|
675
|
+
```console
|
|
676
|
+
analyzed 2 of 3 query group(s); skipped 0 unparseable, 0 filtered, 1 unresolvable, 0 ambiguous
|
|
677
|
+
low coverage: 33% of candidate statements could not be analyzed (0 unparseable, 1
|
|
678
|
+
unresolvable against the schema, 0 ambiguous across the introspected schemas). Cost shares
|
|
679
|
+
are computed against the whole window, so they are diluted and --min-cost-share is
|
|
680
|
+
effectively stricter — few or no proposals may reflect coverage rather than a healthy
|
|
681
|
+
workload.
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
This matters because `cost_share` is **not** a partition of the workload: a query
|
|
685
|
+
filtering two columns credits its full cost to *both* entries (proposals take the max
|
|
686
|
+
over their columns rather than the sum, since summing would double-count), and the
|
|
687
|
+
denominator always includes queries that could not be parsed or resolved against the
|
|
688
|
+
schema. Poor coverage silently dilutes every proposal's share rather than inflating it —
|
|
689
|
+
read the skip counts alongside every proposal.
|
|
690
|
+
|
|
691
|
+
Running with more than one `--schema`, a bare, unqualified table name held by two or more
|
|
692
|
+
of the introspected schemas is genuinely ambiguous — attributing it to either would be a
|
|
693
|
+
guess, so it is dropped and counted rather than guessed at:
|
|
694
|
+
|
|
695
|
+
```console
|
|
696
|
+
2 statement(s) named a table held by more than one of the introspected schemas without
|
|
697
|
+
qualifying it, so they could not be attributed and were dropped. Qualify the table in the
|
|
698
|
+
query, or run advise once per --schema.
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
A missing grant degrades one capability at a time rather than aborting the whole run:
|
|
702
|
+
|
|
703
|
+
```console
|
|
704
|
+
reduced coverage — ndv: permission denied for table pg_stats — reads pg_stats, which
|
|
705
|
+
exposes only rows for tables the current role owns or can select from — a role without
|
|
706
|
+
table access silently sees no statistics
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
#### dbt enrichment (optional)
|
|
710
|
+
|
|
711
|
+
Passing `--project-dir` (reads `<project-dir>/target/manifest.json`) or `--manifest
|
|
712
|
+
<path>` layers dbt model metadata onto the same analysis. Neither is required: every
|
|
713
|
+
`advise` invocation without one behaves exactly as documented above, and that no-manifest
|
|
714
|
+
path is proven byte-identical (stdout, markdown, DDL and stderr) to a run with no dbt
|
|
715
|
+
support at all — dbt is enrichment layered on top of an engine-agnostic core, never a
|
|
716
|
+
requirement of it.
|
|
717
|
+
|
|
718
|
+
**Why ADV302 exists.** The rules above propose DDL from query cost and catalog metadata
|
|
719
|
+
with no idea whether the table they're indexing is dbt-managed — and if it is, that
|
|
720
|
+
matters. dbt's `table` materialization drops and recreates its relation on *every*
|
|
721
|
+
`dbt run`, so a raw `CREATE INDEX` applied once is silently gone the next time dbt runs.
|
|
722
|
+
`incremental` differs only in degree: a normal run keeps the relation, but
|
|
723
|
+
`dbt run --full-refresh` rebuilds it the same way. `materialized_view` behaves like
|
|
724
|
+
`incremental` — refreshed in place on a normal run, rebuilt on `--full-refresh` or a config
|
|
725
|
+
change dbt can't apply in place. A plain `view` has no storage of its own at all, so it
|
|
726
|
+
cannot carry an index. Confidently advising DDL that a routine `dbt run` silently erases is
|
|
727
|
+
worse than advising nothing, which is what **ADV302** exists to prevent: with a manifest
|
|
728
|
+
loaded, an index-creating proposal for a `table`-, `incremental`- or
|
|
729
|
+
`materialized_view`-materialized relation is rewritten into a commented dbt `indexes:`
|
|
730
|
+
config block you paste into that model's own config instead of DDL you'd apply once and
|
|
731
|
+
lose; on a `view` the **DDL** is dropped and explained instead (there is no relation to
|
|
732
|
+
index) while the proposal itself stays, downgraded to LOW — "this index cannot apply here"
|
|
733
|
+
is the finding; on any other or absent materialization the DDL is left untouched, since
|
|
734
|
+
unrecognised is not the same as known-safe. A partial (`WHERE`-restricted) index has no
|
|
735
|
+
config-block equivalent — dbt's `indexes` config carries no predicate — so that proposal is
|
|
736
|
+
disclosed as not expressible rather than silently dropping the predicate.
|
|
737
|
+
|
|
738
|
+
**One model, one `indexes:` block.** dbt reads a single `indexes` key per model config, so
|
|
739
|
+
when a run recommends several indexes for the same model they are merged into one block,
|
|
740
|
+
carried by the highest-ranked of those proposals; each of the others points at it by code
|
|
741
|
+
instead of emitting a block of its own. Two standalone blocks pasted under one `config:` are
|
|
742
|
+
a duplicate YAML mapping key, and PyYAML — dbt's own parser — resolves that by silently
|
|
743
|
+
keeping one and discarding the other recommended index, with no error.
|
|
744
|
+
|
|
745
|
+
**Whenever a statement is left executable for a dbt-managed relation** — the partial-index,
|
|
746
|
+
unrecognised-materialization, no-column-list and non-btree paths above — the warning is
|
|
747
|
+
written into the `--ddl` script itself, as comment lines directly above the statement, not
|
|
748
|
+
only into the `rationale`. The DDL script carries no rationales, and it is the artifact a
|
|
749
|
+
human actually applies.
|
|
750
|
+
|
|
751
|
+
**A `DROP INDEX` proposal on a dbt-managed relation is the same hazard pointing the other
|
|
752
|
+
way.** ADV002 and ADV003 read the catalog, not the manifest, so they will propose dropping an
|
|
753
|
+
index that the model's `indexes:` config still declares — and the next `dbt run` puts it
|
|
754
|
+
straight back, after which the tool proposes the same drop again. Those proposals keep their
|
|
755
|
+
DDL (dropping a genuinely unused index is still right, and dbt's `indexes` config cannot
|
|
756
|
+
express a removal) and gain a warning, in the rationale *and* in the `--ddl` file, that the
|
|
757
|
+
config entry has to be removed as well or the drop will not stick.
|
|
758
|
+
|
|
759
|
+
**`advise` checks the manifest against the connection**, the same two checks `check` makes on
|
|
760
|
+
the same file: it warns when the manifest is not a v12 schema, and when its `adapter_type` is
|
|
761
|
+
neither `postgres` nor `redshift`. The second matters more than the missing `indexes:` config
|
|
762
|
+
key would suggest: a Snowflake or BigQuery manifest paired with a Postgres connection means
|
|
763
|
+
dbt is not building the relations `advise` just introspected *at all*, so every match is a
|
|
764
|
+
name coincidence and all three dbt rules are wrong — ADV302's premise that a `dbt run`
|
|
765
|
+
rebuilds the relation included. A manifest recording **no** `adapter_type` warns too, since
|
|
766
|
+
`dbt compile` always writes one and the honest statement is that the pairing could not be
|
|
767
|
+
checked. `advise` warns rather than suppressing: the mismatch is something to fix in your
|
|
768
|
+
invocation, and dropping all dbt output silently would hide it.
|
|
769
|
+
|
|
770
|
+
**The block is rebuilt from the proposal's column list, not from its DDL**, and always as
|
|
771
|
+
`type: btree`. That is faithful for every rule shipping today — each emits a plain btree over
|
|
772
|
+
a column list with no `USING`, expression, `DESC`/`NULLS` or opclass — and a proposal naming a
|
|
773
|
+
non-btree access method declines the rewrite rather than being flattened into a btree.
|
|
774
|
+
Ordering, opclasses and expression indexes are *not* detected: a future rule emitting one
|
|
775
|
+
would need this reconstruction extended alongside it.
|
|
776
|
+
|
|
777
|
+
Two more proposals only fire with a manifest loaded — see the proposal table above for
|
|
778
|
+
ADV301 and ADV303. Both are capped below HIGH, for the same reason ADV302's rewrite trusts
|
|
779
|
+
the manifest as of whenever `dbt compile` last ran: a model's materialization or its
|
|
780
|
+
consumers can change without a fresh compile, so a stale manifest degrades to a wrong (but
|
|
781
|
+
traceable — the disclosed materialization or lack of a consumer names why) recommendation
|
|
782
|
+
rather than a silent one.
|
|
783
|
+
|
|
784
|
+
**Matching is exact, deliberately.** A model's `relation_name` is dropped down to its
|
|
785
|
+
`(schema, table)` pair (dbt writes a `catalog.schema.table` name; the database part is
|
|
786
|
+
discarded, since `advise` connects to one database at a time) and matched against the
|
|
787
|
+
relation each proposal already carries — **there is no bare-table-name fallback**. A dbt
|
|
788
|
+
project's target schema (`dev`, `main`, a CI schema, ...) routinely differs from the schema
|
|
789
|
+
`advise` introspects in production, so matching on the table name alone would risk
|
|
790
|
+
attributing a production table's proposal to an unrelated development model — and ADV302
|
|
791
|
+
would then rewrite that table's DDL on the strength of a wrong guess. If two *different*
|
|
792
|
+
models both build the same `(schema, table)` pair (legitimate when a project targets more
|
|
793
|
+
than one database), `advise` cannot tell which is live: that relation is dropped from
|
|
794
|
+
matching entirely — not guessed at — and counted, both in the CLI's `dbt enrichment from
|
|
795
|
+
...` disclosure line and in the JSON payload's `dbt.dropped_collisions`.
|
|
796
|
+
|
|
797
|
+
```console
|
|
798
|
+
$ sqlquality advise --dsn postgresql://readonly@db.internal/analytics --project-dir ./my_dbt_project
|
|
799
|
+
engine: postgres (credentials from --dsn)
|
|
800
|
+
dbt enrichment from my_dbt_project/target/manifest.json (42 model(s))
|
|
801
|
+
...
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
A manifest that is missing, unreadable or malformed degrades to "no enrichment" plus a
|
|
805
|
+
line on stderr — `advise` never aborts an otherwise-successful run over an optional input,
|
|
806
|
+
since by the time the manifest loads the whole catalog analysis has already run.
|
|
807
|
+
|
|
808
|
+
### check (the CI gate)
|
|
809
|
+
|
|
810
|
+
Scores each changed model on both a candidate and a baseline dbt manifest, and gates
|
|
811
|
+
the change on the per-model **complexity delta**.
|
|
812
|
+
|
|
813
|
+
Requirements:
|
|
814
|
+
|
|
815
|
+
- **dbt >= 1.5 on `PATH`** (override the executable with `--dbt`). `check` shells out
|
|
816
|
+
to `dbt ls --select state:modified` to discover changed models.
|
|
817
|
+
- A **compiled candidate manifest** at `<project-dir>/target/manifest.json` — run
|
|
818
|
+
`dbt compile` first (the gate scores compiled SQL; uncompiled models are skipped).
|
|
819
|
+
- A **baseline artifacts directory** (`--state`) containing the prior
|
|
820
|
+
`manifest.json` to diff against.
|
|
821
|
+
|
|
822
|
+
The dialect is auto-resolved from the manifest's `adapter_type` (falling back to
|
|
823
|
+
`postgres`), and printed to stderr; pass `--dialect` to override. `--state` and
|
|
824
|
+
`--project-dir` are resolved to absolute paths, so `check` works from a monorepo root.
|
|
825
|
+
|
|
826
|
+
```console
|
|
827
|
+
$ sqlquality check --project-dir . --state prod-artifacts/
|
|
828
|
+
dialect: postgres (from manifest adapter_type)
|
|
829
|
+
sqlquality: ❌ FAIL (changed 1, neighbors 2)
|
|
830
|
+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━┳━━━━┓
|
|
831
|
+
┃ model ┃ baseline ┃ candidate ┃ delta ┃ ┃
|
|
832
|
+
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━╇━━━━┩
|
|
833
|
+
│ model.demo.customer_orders │ 11.2 │ 17.6 │ +6.4 │ ⚠️ │
|
|
834
|
+
└────────────────────────────┴──────────┴───────────┴───────┴────┘
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
Whether a regression **fails** the build depends on `gate.mode` (see
|
|
838
|
+
[Configuration](#configuration-sqlqualityyml)). In the default `warn` mode the same
|
|
839
|
+
change reports the regression but exits 0:
|
|
840
|
+
|
|
841
|
+
```console
|
|
842
|
+
$ sqlquality check --project-dir . --state prod-artifacts/
|
|
843
|
+
sqlquality: ⚠️ WARN (1 regression, gate mode: warn) (changed 1, neighbors 2)
|
|
844
|
+
...
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
`--json` emits the full gate report (verdict, per-model deltas, neighbors, skipped
|
|
848
|
+
models):
|
|
849
|
+
|
|
850
|
+
```console
|
|
851
|
+
$ sqlquality check --project-dir . --state prod-artifacts/ --json
|
|
852
|
+
{
|
|
853
|
+
"mode": "fail",
|
|
854
|
+
"models": [
|
|
855
|
+
{
|
|
856
|
+
"baseline": 11.2,
|
|
857
|
+
"candidate": 17.6,
|
|
858
|
+
"delta": 6.4,
|
|
859
|
+
"is_new": false,
|
|
860
|
+
"unique_id": "model.demo.customer_orders"
|
|
861
|
+
}
|
|
862
|
+
],
|
|
863
|
+
"neighbors": [
|
|
864
|
+
"model.demo.orders",
|
|
865
|
+
"model.demo.stg_orders"
|
|
866
|
+
],
|
|
867
|
+
"passed": false,
|
|
868
|
+
"regressions": [
|
|
869
|
+
"model.demo.customer_orders"
|
|
870
|
+
],
|
|
871
|
+
"skipped": [],
|
|
872
|
+
"warned": false
|
|
873
|
+
}
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
`--markdown <path>` writes a report suitable for a PR comment, and `--html <path>`
|
|
877
|
+
writes a self-contained HTML report. The markdown looks like:
|
|
878
|
+
|
|
879
|
+
```markdown
|
|
880
|
+
# sqlquality: ❌ FAIL
|
|
881
|
+
|
|
882
|
+
| model | baseline | candidate | delta | |
|
|
883
|
+
|---|---:|---:|---:|:--:|
|
|
884
|
+
| model.demo.customer_orders | 11.2 | 17.6 | +6.4 | ⚠️ |
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
## Configuration (`sqlquality.yml`)
|
|
888
|
+
|
|
889
|
+
`check` reads `<project-dir>/sqlquality.yml` by default, or the path given to
|
|
890
|
+
`--config`. All keys are optional; absent files use the defaults below.
|
|
891
|
+
|
|
892
|
+
| Key | Type | Default | Meaning |
|
|
893
|
+
|---|---|---|---|
|
|
894
|
+
| `gate.mode` | `warn` \| `fail` | `warn` | `warn` reports regressions but exits 0; `fail` exits 1 on any regression. **The default `warn` does not fail CI** — set `fail` to actually gate. An invalid value is rejected with exit 2. |
|
|
895
|
+
| `gate.max_complexity_increase` | float | `10.0` | A model is a regression when its delta exceeds this threshold. New models (no baseline) are never counted as regressions. |
|
|
896
|
+
| `waivers` | list of strings | `[]` | Model `unique_id`s exempt from the gate. |
|
|
897
|
+
|
|
898
|
+
A complete example:
|
|
899
|
+
|
|
900
|
+
```yaml
|
|
901
|
+
gate:
|
|
902
|
+
mode: fail
|
|
903
|
+
max_complexity_increase: 10.0
|
|
904
|
+
waivers:
|
|
905
|
+
- model.my_project.legacy_wide_fact
|
|
906
|
+
- model.my_project.known_gnarly_rollup
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
## Exit codes
|
|
910
|
+
|
|
911
|
+
Every command follows the same contract:
|
|
912
|
+
|
|
913
|
+
| Code | Meaning |
|
|
914
|
+
|---|---|
|
|
915
|
+
| `0` | Pass / no findings. |
|
|
916
|
+
| `1` | Findings present, or the gate failed. |
|
|
917
|
+
| `2` | Usage, config, or input error (bad flag, unknown dialect, unparseable SQL, unreadable file, malformed `sqlquality.yml`, dbt invocation failure). |
|
|
918
|
+
|
|
919
|
+
Per-command nuances of code `1`:
|
|
920
|
+
|
|
921
|
+
- **`complexity`** never gates — it always exits 0 unless the input errors (2).
|
|
922
|
+
- **`lint`** exits 1 on any `WARNING`/`ERROR` finding; `info`-level (unresolved-Jinja)
|
|
923
|
+
findings never gate; `--warn-only` forces 0.
|
|
924
|
+
- **`perf`** exits 1 only on an `ERROR`-severity finding (unparseable SQL). Anti-pattern
|
|
925
|
+
warnings exit 0.
|
|
926
|
+
- **`check`** exits 1 only when `gate.mode: fail` and a regression is present; `warn`
|
|
927
|
+
mode exits 0 even with regressions.
|
|
928
|
+
- **`advise`** exits 0 on any successful analysis, whether or not proposals were
|
|
929
|
+
produced — proposals are advisory and never gate. It exits 2 on a usage, config,
|
|
930
|
+
connection or input error (unresolvable credentials, connection failure, missing
|
|
931
|
+
driver, malformed `--since`, out-of-range `--timeout`). It never exits 1.
|
|
932
|
+
|
|
933
|
+
## CI recipe (a gate that actually gates)
|
|
934
|
+
|
|
935
|
+
To make CI fail on a complexity regression you must (1) set `gate.mode: fail` in
|
|
936
|
+
`sqlquality.yml`, (2) `dbt compile` so the candidate manifest exists, and (3) provide
|
|
937
|
+
a baseline (`--state`) produced from your production `dbt compile` artifacts.
|
|
938
|
+
|
|
939
|
+
```yaml
|
|
940
|
+
# sqlquality.yml (committed to the repo)
|
|
941
|
+
gate:
|
|
942
|
+
mode: fail
|
|
943
|
+
max_complexity_increase: 10.0
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
```yaml
|
|
947
|
+
# .github/workflows/sqlquality.yml
|
|
948
|
+
name: sqlquality
|
|
949
|
+
on: pull_request
|
|
950
|
+
|
|
951
|
+
jobs:
|
|
952
|
+
gate:
|
|
953
|
+
runs-on: ubuntu-latest
|
|
954
|
+
steps:
|
|
955
|
+
- uses: actions/checkout@v4
|
|
956
|
+
- uses: astral-sh/setup-uv@v5
|
|
957
|
+
|
|
958
|
+
# Fetch the baseline artifacts your production run published.
|
|
959
|
+
# These must come from `dbt compile` (a compiled manifest.json), not a bare parse.
|
|
960
|
+
- name: Download baseline artifacts
|
|
961
|
+
run: ./scripts/download-prod-artifacts.sh prod-artifacts/
|
|
962
|
+
|
|
963
|
+
# Produce the candidate manifest for the PR.
|
|
964
|
+
- name: dbt compile
|
|
965
|
+
run: uv run dbt compile
|
|
966
|
+
|
|
967
|
+
- name: sqlquality gate
|
|
968
|
+
run: >
|
|
969
|
+
uv run sqlquality check
|
|
970
|
+
--project-dir .
|
|
971
|
+
--state prod-artifacts/
|
|
972
|
+
--markdown report.md
|
|
973
|
+
|
|
974
|
+
- name: Comment report on the PR
|
|
975
|
+
uses: actions/github-script@v7
|
|
976
|
+
if: always() # post the report even when the gate fails
|
|
977
|
+
with:
|
|
978
|
+
script: |
|
|
979
|
+
const body = require('fs').readFileSync('report.md', 'utf8')
|
|
980
|
+
github.rest.issues.createComment({
|
|
981
|
+
...context.repo,
|
|
982
|
+
issue_number: context.issue.number,
|
|
983
|
+
body,
|
|
984
|
+
})
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
Baseline hygiene: the baseline `manifest.json` must be a **compiled** artifact
|
|
988
|
+
(`dbt compile` output). A parse-only manifest lacks `compiled_code`, so those models
|
|
989
|
+
are skipped rather than scored.
|
|
990
|
+
|
|
991
|
+
## Pre-commit hook
|
|
992
|
+
|
|
993
|
+
`sqlquality` ships a [pre-commit](https://pre-commit.com) hook that lints staged SQL:
|
|
994
|
+
|
|
995
|
+
```yaml
|
|
996
|
+
# .pre-commit-config.yaml
|
|
997
|
+
repos:
|
|
998
|
+
- repo: https://github.com/hanslemm/sqlquality
|
|
999
|
+
rev: v0.2.0
|
|
1000
|
+
hooks:
|
|
1001
|
+
- id: sqlquality-lint
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
The hook runs `sqlquality lint` on staged `.sql` files and excludes `target/`. It
|
|
1005
|
+
lints **raw model files** (not compiled SQL), so unresolved-Jinja findings are demoted
|
|
1006
|
+
to `info` and don't block the commit — only real `WARNING`/`ERROR` findings do.
|
|
1007
|
+
|
|
1008
|
+
To make the hook non-blocking (report only), pass `--warn-only`:
|
|
1009
|
+
|
|
1010
|
+
```yaml
|
|
1011
|
+
- id: sqlquality-lint
|
|
1012
|
+
args: [--warn-only]
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
## LLM suggestions (optional, advisory)
|
|
1016
|
+
|
|
1017
|
+
`perf --suggest` can attach a short, concrete rewrite suggestion to each finding using
|
|
1018
|
+
an LLM. It is **off by default** and **advisory only** — suggestions never change
|
|
1019
|
+
findings, severities, exit codes, or the gate.
|
|
1020
|
+
|
|
1021
|
+
Setup:
|
|
1022
|
+
|
|
1023
|
+
1. Install the extra: `pip install "sqlquality[llm]"`.
|
|
1024
|
+
2. Set `SQLQUALITY_LLM=anthropic` (also accepts `1` or `true`).
|
|
1025
|
+
3. Provide `ANTHROPIC_API_KEY` (read by the Anthropic SDK).
|
|
1026
|
+
4. Optionally set `SQLQUALITY_LLM_MODEL` to override the model (the built-in default is
|
|
1027
|
+
`claude-opus-4-8`).
|
|
1028
|
+
|
|
1029
|
+
```bash
|
|
1030
|
+
export SQLQUALITY_LLM=anthropic
|
|
1031
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
1032
|
+
sqlquality perf model.sql --suggest
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
If `--suggest` is passed without `SQLQUALITY_LLM` set, `perf` prints a note to stderr
|
|
1036
|
+
and continues without suggestions. If the extra or credentials are missing, it
|
|
1037
|
+
degrades gracefully (findings still print, exit code unchanged):
|
|
1038
|
+
|
|
1039
|
+
```
|
|
1040
|
+
LLM suggestions unavailable: The 'anthropic' package is required for AnthropicProvider. Install it with: pip install 'sqlquality[llm]'
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
> **⚠️ Data egress warning.** `perf --suggest` sends the analyzed SQL (up to 20,000
|
|
1044
|
+
> characters per finding) to the Anthropic API. Do **not** enable it on proprietary or
|
|
1045
|
+
> sensitive SQL without clearance. API cost scales with the number of findings (one
|
|
1046
|
+
> call per finding).
|
|
1047
|
+
|
|
1048
|
+
## Limitations
|
|
1049
|
+
|
|
1050
|
+
- **Complexity is structural.** The composite is an open-ended, weighted score of
|
|
1051
|
+
AST features (joins, CTEs, subqueries, windows, select depth, …); it is not capped,
|
|
1052
|
+
so a large model can exceed 100. As a rough guide, ~100 is very complex. It measures
|
|
1053
|
+
shape, not runtime cost or correctness.
|
|
1054
|
+
- **`perf` is static.** Anti-patterns and captured-`EXPLAIN` ingestion only —
|
|
1055
|
+
`sqlquality` never runs your queries in this command. Perf adapters exist for
|
|
1056
|
+
**postgres** and **redshift** only today.
|
|
1057
|
+
- **Jinja analysis is approximate.** Raw dbt models are analyzed by stripping Jinja to
|
|
1058
|
+
placeholders (with a stderr notice). Prefer compiled SQL from `target/compiled/` for
|
|
1059
|
+
accurate results.
|
|
1060
|
+
- **Neighbors are reported, not scored.** A changed model's direct upstream/downstream
|
|
1061
|
+
models are surfaced for context; the gate only evaluates the changed models
|
|
1062
|
+
themselves.
|
|
1063
|
+
- **`advise` proposals are ranked by evidence, not proven.** A HIGH-confidence proposal
|
|
1064
|
+
is well-supported, not guaranteed correct — it is still advice to review, not a
|
|
1065
|
+
decision already made.
|
|
1066
|
+
- **Index write cost is not modeled.** Proposals weigh read-side benefit (cost share,
|
|
1067
|
+
selectivity) against the fact that an index exists; they do not estimate the ongoing
|
|
1068
|
+
cost of maintaining it on every write. A hot write path with many proposed indexes
|
|
1069
|
+
needs a human judgment call `advise` does not make.
|
|
1070
|
+
- **Conclusions are only as representative as the log window.** `pg_stat_statements` is
|
|
1071
|
+
cumulative since the last statistics reset, with no per-statement timestamps before
|
|
1072
|
+
PostgreSQL 17. A reset an hour ago produces a confident-looking report over an hour of
|
|
1073
|
+
traffic; the report's `window:` line is the only way to know which you have.
|
|
1074
|
+
- **`cost_share` is not a partition.** A query filtering two columns credits its full
|
|
1075
|
+
cost to *both* — summing across columns double-counts. The denominator also includes
|
|
1076
|
+
statements that could not be parsed or resolved against the schema, so poor coverage
|
|
1077
|
+
dilutes every share and makes `--min-cost-share` effectively stricter; the CLI warns
|
|
1078
|
+
when coverage is poor, and the report always prints the skip counts.
|
|
1079
|
+
- **Join keys and grouping columns are measured and read, not just cost-weighted.**
|
|
1080
|
+
`advise` classifies eight column roles; join keys are read by ADV007 (a hot unindexed
|
|
1081
|
+
foreign-key join produces a proposal, not just an `orders.customer_id join cost_share
|
|
1082
|
+
1.0` line that goes nowhere) and `GROUP BY` columns are read by ADV008, as one composite
|
|
1083
|
+
index rather than one per column — `GROUP BY a, b` needs input sorted by `(a, b)`, and
|
|
1084
|
+
two single-column indexes cannot provide that. Any column under a `JOIN` is classified
|
|
1085
|
+
as a join key, so a predicate you placed in an `ON` clause (as `LEFT JOIN` semantics
|
|
1086
|
+
require) is not treated as a filter and drops out of ADV001's reach — it is ADV007's
|
|
1087
|
+
candidate instead. ADV008's column order within the composite is inferred from cost,
|
|
1088
|
+
not read from the query, because redaction does not preserve each column's position in
|
|
1089
|
+
the `GROUP BY` clause — check it against the actual grouping before applying.
|
|
1090
|
+
- **A declared cursor's predicates are analyzed, but its cost usually reads as zero.**
|
|
1091
|
+
`DECLARE cur CURSOR FOR SELECT ... WHERE ...` and `COPY (SELECT ... WHERE ...) TO STDOUT`
|
|
1092
|
+
are unwrapped to their inner query before the noise filter runs, so both reach
|
|
1093
|
+
`aggregate` — Django's `QuerySet.iterator()` and every psycopg2 server-side cursor emit
|
|
1094
|
+
the first form, so on a Django codebase this can be most of your hot reads. `COPY (...)
|
|
1095
|
+
TO` attributes correctly: Postgres charges the whole execution's time and rows to the
|
|
1096
|
+
`COPY` statement. A `DECLARE`, measured on PostgreSQL 16, does not — opening a cursor
|
|
1097
|
+
does no scanning, so while it is counted accurately by *call count* (one call per cursor
|
|
1098
|
+
opened), its time and rows read as near-zero; the actual work is charged to the `FETCH`
|
|
1099
|
+
statements that follow, which carry no query text and stay filtered as noise. So a
|
|
1100
|
+
cursor read's columns can still join an index candidate, but the read cannot earn a
|
|
1101
|
+
proposal on cost alone, and the default `--min-cost-share` can suppress it outright.
|
|
1102
|
+
- **A `COPY (...) TO` execution can be counted twice under `pg_stat_statements.track =
|
|
1103
|
+
all`.** That setting (not the default `track = top`) makes Postgres record both the
|
|
1104
|
+
verbatim top-level `COPY` statement and its normalised nested query as separate rows for
|
|
1105
|
+
the same execution, and `unwrap`/redaction give the pair different fingerprints (a real
|
|
1106
|
+
literal in one, `$1` in the other) — so it lands in `aggregate` as two query groups at
|
|
1107
|
+
roughly twice the execution's true cost, inflating both that group's `cost_share` and the
|
|
1108
|
+
whole-window denominator. This is accepted rather than fixed, **for a stated price rather
|
|
1109
|
+
than for want of a way to fix it**. A blanket `AND s.toplevel` is not the answer:
|
|
1110
|
+
`toplevel = false` is also the *only* way Postgres ever exposes the SQL executed inside a
|
|
1111
|
+
PL/pgSQL function body, and tried live it made a genuinely hot, function-wrapped query
|
|
1112
|
+
disappear from evidence entirely (no predicates, no cost, no disclosure that anything was
|
|
1113
|
+
dropped) while a much colder query took its place as a `high`-confidence proposal —
|
|
1114
|
+
confidently wrong, which is strictly worse than an inflated `cost_share`. A *narrow*
|
|
1115
|
+
predicate does exist, though, and was measured to work: on PostgreSQL 16 the COPY's nested
|
|
1116
|
+
row keeps its wrapper (`COPY (SELECT ... $1) TO STDOUT`) while a function body is recorded
|
|
1117
|
+
bare, so `NOT (s.toplevel = false AND s.query ~* '^\s*COPY\s*\(')` removes exactly the
|
|
1118
|
+
duplicate and leaves function bodies alone. It is declined because *naming* `s.toplevel`
|
|
1119
|
+
at all requires PostgreSQL 14 — the column does not exist on 13 — so adding it would cost
|
|
1120
|
+
every PostgreSQL 13 user the entire workload capability in exchange for removing a 2×
|
|
1121
|
+
over-count of one statement form under a non-default setting. If the supported floor ever
|
|
1122
|
+
rises to 14, that is the predicate to add.
|
|
1123
|
+
- **Every PL/pgSQL function call is counted twice under `pg_stat_statements.track = all`,
|
|
1124
|
+
and no predicate can fix it.** That setting records both the calling statement and each
|
|
1125
|
+
statement inside the function body: on one PostgreSQL 16 run, a single execution of
|
|
1126
|
+
`SELECT lc.hot()` appeared as the call at 68.21 ms *and* its body at 67.67 ms — the two
|
|
1127
|
+
durations track each other, so the absolute figures vary per machine. Both land in
|
|
1128
|
+
the whole-window denominator, so on a function-heavy workload every `cost_share` is
|
|
1129
|
+
roughly halved and `--min-cost-share` is correspondingly stricter than it looks. Unlike
|
|
1130
|
+
the `COPY` case above there is no filter that helps: the call carries the cost while the
|
|
1131
|
+
body carries the predicates a proposal is built from, so dropping either row loses
|
|
1132
|
+
something real. On the default `track = top` neither this nor the `COPY` duplicate arises,
|
|
1133
|
+
because Postgres records no nested statements at all — if you run `track = all`, read
|
|
1134
|
+
`cost_share` as a lower bound.
|
|
1135
|
+
- **Expression indexes are read but not matched.** `advise` now sees that an index on
|
|
1136
|
+
`lower(status)` exists and names it in the proposal's evidence, but it cannot tell whether
|
|
1137
|
+
that index already serves a lookup on `status` — so it proposes and says so, rather than
|
|
1138
|
+
suppressing or ignoring. Confirm before applying. True of all three index-creating rules,
|
|
1139
|
+
ADV001, ADV007 and ADV008.
|
|
1140
|
+
- **ADV003 only compares plain indexes.** A pair where either index carries a `WHERE`
|
|
1141
|
+
predicate or an indexed expression is skipped entirely rather than proposed at lower
|
|
1142
|
+
confidence: a partial index exists to serve a subset, so recommending its removal is
|
|
1143
|
+
likely wrong rather than merely uncertain. Plain pairs are reported at HIGH.
|
|
1144
|
+
- **Both `DROP INDEX` rules only look at tables the workload actually used.** ADV002 and
|
|
1145
|
+
ADV003 iterate the relations that appear in the analyzed query groups, so an index on a
|
|
1146
|
+
table no observed statement touched is never proposed for removal — including when it sits
|
|
1147
|
+
in a second `--schema` whose table happens to share a bare name with a hot one.
|
|
1148
|
+
- **A partial index does not suppress a proposal.** `idx ON orders(status) WHERE
|
|
1149
|
+
shipped_at IS NULL` does not serve `WHERE status = $1`, so it is not treated as covering
|
|
1150
|
+
a candidate index — it is named in the evidence instead. True of all three index-creating
|
|
1151
|
+
rules, ADV001, ADV007 and ADV008.
|
|
1152
|
+
- **Multiple `--schema` values are supported, with one honest caveat.** Every catalog fact
|
|
1153
|
+
(table sizes, NDV statistics, index lists, the `qualify()` schema) is keyed by
|
|
1154
|
+
`schema.table`, so `orders` in two introspected schemas no longer aliases into one
|
|
1155
|
+
another. What remains is genuine ambiguity in the *query text* itself: a statement that
|
|
1156
|
+
says `from orders` bare, when two of the introspected schemas both hold `orders`, cannot
|
|
1157
|
+
be attributed to either without guessing — it is dropped and counted rather than guessed
|
|
1158
|
+
at (see `ambiguous` in the skip counts, and the coverage warning that names the remedy).
|
|
1159
|
+
Qualify the table in the query, or run `advise` once per `--schema`, to recover it.
|
|
1160
|
+
Generated DDL is qualified with the schema it was read from, so it does not depend on the
|
|
1161
|
+
applying session's `search_path`.
|
|
1162
|
+
- **Snowflake is designed but not implemented.** `advise` supports `postgres` and
|
|
1163
|
+
`redshift` today; passing `--engine snowflake` (or anything else unrecognised) fails
|
|
1164
|
+
with a clear error rather than silently degrading.
|
|
1165
|
+
- **Redshift's catalog SQL has not been executed against a live Redshift cluster.** See
|
|
1166
|
+
the prominent note at the top of the [Redshift section](#redshift---engine-redshift):
|
|
1167
|
+
the connection path is verified live (Redshift speaks the Postgres wire protocol), every
|
|
1168
|
+
statement's syntax and bindability are verified live, but the column names and the
|
|
1169
|
+
resulting proposals' semantics come from AWS documentation, not an observed row. Run
|
|
1170
|
+
`--dry-run` first and review before connecting to a production cluster.
|
|
1171
|
+
- **Redshift declares no NDV and no index capability**, deliberately: Redshift exposes no
|
|
1172
|
+
`pg_stats.n_distinct` equivalent, and it has no indexes at all — its levers are SORTKEY,
|
|
1173
|
+
DISTKEY/DISTSTYLE and VACUUM/ANALYZE staleness. This is why ADV101/102/103 can never
|
|
1174
|
+
reach HIGH confidence (see the [Redshift section](#redshift---engine-redshift)), not a
|
|
1175
|
+
gap left for a later release.
|
|
1176
|
+
- **A relation absent from `svv_table_info` is ambiguous, not conclusive.** Redshift omits
|
|
1177
|
+
both external (Spectrum) tables and genuinely empty local tables from that view, and
|
|
1178
|
+
nothing else this adapter reads can tell the two apart — so a relation missing from it
|
|
1179
|
+
gets no ADV101/102/103 proposal at all rather than a guess either way, and the run
|
|
1180
|
+
discloses how many relations this affected.
|
|
1181
|
+
- **Redshift's workload is silently partial without `SYSLOG ACCESS UNRESTRICTED`.**
|
|
1182
|
+
`sys_query_history` returns only the connecting user's own queries to a role lacking that
|
|
1183
|
+
privilege, and it does so with **no error at all** — so a cluster whose traffic your
|
|
1184
|
+
read-only role cannot see is indistinguishable from a quiet one, and every `cost_share` is
|
|
1185
|
+
computed over one user's slice. This is the one Redshift failure mode with no signal
|
|
1186
|
+
anywhere in the run; see the [Redshift section](#redshift---engine-redshift) for the grant.
|
|
1187
|
+
- **`--limit` counts executions on Redshift and query groups on Postgres.**
|
|
1188
|
+
`sys_query_history` is per-execution; `pg_stat_statements` is pre-aggregated per normalised
|
|
1189
|
+
statement. So on Redshift `--limit 500` means "the 500 most expensive executions", and 500
|
|
1190
|
+
executions of a single bad query is a legal outcome that hides every other statement. The
|
|
1191
|
+
`window:` line always says which was read.
|
|
1192
|
+
- **Identifier case and attached comments can split one Redshift statement into several
|
|
1193
|
+
query groups.** `sys_query_history` stores the *verbatim* text the client sent, unlike
|
|
1194
|
+
`pg_stat_statements`, which Postgres has already parsed and re-serialised (identifiers
|
|
1195
|
+
folded to lowercase) before storing. So two executions of what is semantically one
|
|
1196
|
+
statement still fingerprint separately when they differ only in identifier case or in an
|
|
1197
|
+
attached comment — an ORM query tag, for instance. That inflates the number of query groups
|
|
1198
|
+
the window's total cost is spread over, which shrinks every `cost_share` and makes
|
|
1199
|
+
`--min-cost-share` correspondingly stricter, in the same way the `cost_share` and PL/pgSQL
|
|
1200
|
+
caveats above do. Not "fixed" by case-folding before fingerprinting: nothing there can tell
|
|
1201
|
+
an unquoted (case-insensitive) identifier from a deliberately quoted, case-sensitive one, so
|
|
1202
|
+
a general fold risks collapsing a real distinction instead of a spurious one.
|
|
1203
|
+
- **dbt enrichment trusts the manifest as of its last `dbt compile`.** ADV302 rewrites DDL
|
|
1204
|
+
based on a model's materialization as the manifest records it; a materialization changed
|
|
1205
|
+
without a fresh `dbt compile` produces a stale — but traceable, since the disclosed
|
|
1206
|
+
materialization names its own source — rewrite. Nothing verifies the manifest against
|
|
1207
|
+
the live relation.
|
|
1208
|
+
- **A manifest for another warehouse is warned about, not rejected.** `advise` connects to
|
|
1209
|
+
Postgres; a manifest whose `adapter_type` is something else (or absent) gets a stderr
|
|
1210
|
+
warning and enrichment still runs, so a project whose manifest and target database disagree
|
|
1211
|
+
gets dbt proposals built on `(schema, table)` name coincidences. dbt's `indexes` model
|
|
1212
|
+
config is likewise implemented by the postgres and redshift adapters only, and ADV302's
|
|
1213
|
+
rewrite is not translated per adapter.
|
|
1214
|
+
- **ADV302 reconstructs the index from the proposal's column list.** The emitted block is
|
|
1215
|
+
always `type: btree` over that column list; column *ordering* is preserved but opclasses,
|
|
1216
|
+
`DESC`/`NULLS` and expression indexes are not expressible, and a non-btree access method
|
|
1217
|
+
declines the rewrite rather than being silently flattened. That last check is textual (no
|
|
1218
|
+
rule records an access method in evidence), so a column whose *name* contains a `USING`
|
|
1219
|
+
clause declines a rewrite that would have been fine — the safe direction.
|
|
1220
|
+
- **ADV303 only looks at a model's immediate consumers.** A dead model feeding another dead
|
|
1221
|
+
model is not reported until the downstream one is gone, so a fully dead chain unwinds one
|
|
1222
|
+
model per run, from its leaf. Conservative by construction: it never flags a model that
|
|
1223
|
+
something declares a dependency on.
|