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.
Files changed (93) hide show
  1. sqlquality-0.3.0/PKG-INFO +1223 -0
  2. sqlquality-0.3.0/README.md +1191 -0
  3. {sqlquality-0.2.0 → sqlquality-0.3.0}/pyproject.toml +10 -0
  4. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/__init__.py +1 -1
  5. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/antipatterns.py +3 -2
  6. sqlquality-0.3.0/src/sqlquality/cli.py +1002 -0
  7. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/dbtproject.py +13 -0
  8. sqlquality-0.3.0/src/sqlquality/models.py +290 -0
  9. sqlquality-0.3.0/src/sqlquality/report.py +330 -0
  10. sqlquality-0.3.0/src/sqlquality/workload/__init__.py +22 -0
  11. sqlquality-0.3.0/src/sqlquality/workload/aggregate.py +201 -0
  12. sqlquality-0.3.0/src/sqlquality/workload/base.py +137 -0
  13. sqlquality-0.3.0/src/sqlquality/workload/connection.py +86 -0
  14. sqlquality-0.3.0/src/sqlquality/workload/dbt.py +1168 -0
  15. sqlquality-0.3.0/src/sqlquality/workload/extract.py +261 -0
  16. sqlquality-0.3.0/src/sqlquality/workload/fingerprint.py +229 -0
  17. sqlquality-0.3.0/src/sqlquality/workload/postgres.py +2109 -0
  18. sqlquality-0.3.0/src/sqlquality/workload/profiles.py +154 -0
  19. sqlquality-0.3.0/src/sqlquality/workload/redshift.py +1604 -0
  20. sqlquality-0.3.0/src/sqlquality/workload/secrets.py +79 -0
  21. sqlquality-0.3.0/src/sqlquality/workload/session.py +179 -0
  22. sqlquality-0.3.0/tests/integration/__init__.py +0 -0
  23. sqlquality-0.3.0/tests/integration/conftest.py +336 -0
  24. sqlquality-0.3.0/tests/integration/docker-compose.yml +34 -0
  25. sqlquality-0.3.0/tests/integration/test_advise_live.py +380 -0
  26. sqlquality-0.3.0/tests/integration/test_introspection_live.py +132 -0
  27. sqlquality-0.3.0/tests/integration/test_redshift_connect_live.py +104 -0
  28. sqlquality-0.3.0/tests/integration/test_redshift_introspection_bindable_live.py +132 -0
  29. sqlquality-0.3.0/tests/test_advise_cli.py +1616 -0
  30. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_check_gate.py +84 -0
  31. sqlquality-0.3.0/tests/test_ci_integration_job.py +282 -0
  32. sqlquality-0.3.0/tests/test_integration_fixture.py +172 -0
  33. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_lint_cli.py +26 -0
  34. sqlquality-0.3.0/tests/test_models.py +134 -0
  35. sqlquality-0.3.0/tests/test_report_markdown.py +436 -0
  36. sqlquality-0.3.0/tests/test_workload_aggregate.py +497 -0
  37. sqlquality-0.3.0/tests/test_workload_connection.py +342 -0
  38. sqlquality-0.3.0/tests/test_workload_dbt.py +1692 -0
  39. sqlquality-0.3.0/tests/test_workload_extract.py +343 -0
  40. sqlquality-0.3.0/tests/test_workload_fingerprint.py +406 -0
  41. sqlquality-0.3.0/tests/test_workload_postgres.py +1200 -0
  42. sqlquality-0.3.0/tests/test_workload_redaction.py +185 -0
  43. sqlquality-0.3.0/tests/test_workload_redshift.py +1101 -0
  44. sqlquality-0.3.0/tests/test_workload_redshift_rules.py +1609 -0
  45. sqlquality-0.3.0/tests/test_workload_rules.py +2991 -0
  46. sqlquality-0.3.0/tests/test_workload_secrets.py +57 -0
  47. sqlquality-0.3.0/tests/test_workload_session.py +226 -0
  48. sqlquality-0.2.0/PKG-INFO +0 -519
  49. sqlquality-0.2.0/README.md +0 -491
  50. sqlquality-0.2.0/src/sqlquality/cli.py +0 -540
  51. sqlquality-0.2.0/src/sqlquality/models.py +0 -56
  52. sqlquality-0.2.0/src/sqlquality/report.py +0 -124
  53. sqlquality-0.2.0/tests/test_models.py +0 -35
  54. sqlquality-0.2.0/tests/test_report_markdown.py +0 -78
  55. {sqlquality-0.2.0 → sqlquality-0.3.0}/.gitignore +0 -0
  56. {sqlquality-0.2.0 → sqlquality-0.3.0}/.pre-commit-hooks.yaml +0 -0
  57. {sqlquality-0.2.0 → sqlquality-0.3.0}/LICENSE +0 -0
  58. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/adapters/__init__.py +0 -0
  59. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/adapters/base.py +0 -0
  60. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/adapters/postgres.py +0 -0
  61. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/adapters/redshift.py +0 -0
  62. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/changeset.py +0 -0
  63. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/complexity.py +0 -0
  64. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/config.py +0 -0
  65. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/delta.py +0 -0
  66. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/dialects.py +0 -0
  67. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/gate.py +0 -0
  68. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/keys.py +0 -0
  69. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/linter.py +0 -0
  70. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/llm.py +0 -0
  71. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/py.typed +0 -0
  72. {sqlquality-0.2.0 → sqlquality-0.3.0}/src/sqlquality/sqlast.py +0 -0
  73. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/fixtures/manifest_v12.json +0 -0
  74. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_adapters_postgres.py +0 -0
  75. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_adapters_redshift.py +0 -0
  76. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_antipatterns.py +0 -0
  77. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_changeset.py +0 -0
  78. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_cli.py +0 -0
  79. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_complexity.py +0 -0
  80. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_config.py +0 -0
  81. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_dbtproject.py +0 -0
  82. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_delta.py +0 -0
  83. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_dialects.py +0 -0
  84. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_gate.py +0 -0
  85. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_keys.py +0 -0
  86. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_linter.py +0 -0
  87. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_llm.py +0 -0
  88. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_llm_anthropic.py +0 -0
  89. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_perf_cli.py +0 -0
  90. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_perf_suggest.py +0 -0
  91. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_precommit_hooks.py +0 -0
  92. {sqlquality-0.2.0 → sqlquality-0.3.0}/tests/test_report.py +0 -0
  93. {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.