Skip to content

feat(advise): optional dbt enrichment — stop proposing DDL that dbt destroys - #14

Merged
hanslemm merged 15 commits into
mainfrom
feat/advise-dbt-enrichment
Jul 28, 2026
Merged

feat(advise): optional dbt enrichment — stop proposing DDL that dbt destroys#14
hanslemm merged 15 commits into
mainfrom
feat/advise-dbt-enrichment

Conversation

@hanslemm

Copy link
Copy Markdown
Owner

Batch 3a of 3. Optional dbt enrichment for advise — and primarily a correctness fix, not a
feature: advise currently proposes DDL that dbt destroys.

Scope note: Batch 3 was originally Redshift + Snowflake + dbt. Two of those three cannot be verified
against a live engine (Redshift's svv_*/sys_* catalogs don't exist in Postgres; Snowflake needs a
real account), while every silent-suppression bug in this feature has been found live and none by
a fixture. So dbt — fully verifiable offline — went first. Redshift is Batch 3b; Snowflake waits for
an account rather than shipping an adapter whose SQL has never executed.

The correctness fix

dbt's table materialization drops and recreates the relation on every dbt run. So the
CREATE INDEX advise emits today for a dbt-managed table is destroyed by the next build — the tool
confidently advises something that silently reverts.

ADV302 rewrites those proposals into a commented indexes: config block instead. The three
materializations genuinely differ and distinguishing them is the rule:

materialized raw CREATE INDEX what ADV302 says
table dropped every run express as indexes: config
incremental survives a run, lost on --full-refresh express as config so a full refresh keeps it
materialized_view rebuilt express as config
view cannot exist not applicable; a view has no storage to index
anything else unknown say so, leave the DDL alone

ADV004's partial index is disclosed as not expressible rather than silently rewritten — dbt's
indexes config has no predicate field, so emitting one would lose the WHERE and turn a correct
proposal into a wrong one.

DROP INDEX on a dbt-managed relation is the mirror case, and it is handled: if the index is declared
in that model's config, the next dbt run recreates it, so the DDL file says to remove the config
entry too — otherwise the drop doesn't stick and gets proposed again next run.

Two rules that need the model graph

  • ADV301 — materialize a view-backed model carrying a hot share of cost. Capped MEDIUM with no
    HIGH branch: the build-vs-read trade depends on a schedule query history cannot show. Verified
    unreachable by construction across a sweep of materializations × cost shares (including inf/nan).
  • ADV303 — a model the workload never touched and that no model, snapshot or dbt exposure
    declares as a consumer. Capped LOW because the evidence is absence; states both the window and
    --limit caveats. Emits nothing on an empty workload — you cannot conclude a model is unused from a
    workload that observed nothing.

dbt stays optional, and that is measured

No adapter imports the dbt module; enrichment is one call above the adapter layer, so it will apply to
Batch 3b's Redshift adapter unchanged. A run with no manifest is byte-identical to main
across stdout, JSON, markdown, DDL and exit code — verified three separate times against a main
worktree, and now guarded by a regression test that reddens on an injected leak into any of the four
surfaces. The "dbt" payload key is omitted rather than set to null precisely so that identity is
literal rather than true-with-an-asterisk.

Model matching is on the exact (schema, table) pair with no bare-name fallback: a dbt target
schema routinely differs from the schema being introspected, and a name-only match would attribute a
production table to an unrelated dev model — then rewrite its DDL on the strength of it. A relation two
models both claim is dropped from matching and counted.

What the reviews found

Each task was reviewed and fixed; the whole-branch review then found 15 more, as it has on all three
predecessor branches.

The headline one: the entire feature could be disconnected from the CLI with CI green. Replacing
the enrich_proposals call with pass left all 665 tests passing, because the only check was an
integration test CI never runs. Closed at all four wiring sites, not just the one it was found at.

Two that were dangerous in the way this tool cares about: two config blocks for one model silently
lost a recommended index
(duplicate YAML key — PyYAML keeps the last), proven live through the real
CLI; and the DDL file could hold a config block explaining that raw DDL is destroyed, with a bare
executable CREATE INDEX … WHERE …; on that same dbt table below it, because the warning lived only in
the rationale.

The relation parser was rewritten from a regex to a hand-written scanner after diagnosing that
re.findall silently skips characters it doesn't match — which is how every malformed name became a
confident wrong answer rather than a rejection. It is now total over str: verified across 16,105
adversarial inputs, never raising, always Relation | None.

Also fixed: load_dbt_context raised on wrong-typed JSON (a manifest that is a list, or has
nodes: null) and exited 1 with a traceback after all the catalog work; 42 of 92 mutations survived
the first fix wave, including five outright vacuous tests; and the _is_fully_commented guard on "no
emitted DDL line is executable-looking" had no test at all — allany passed 665 tests while
emitting a bare DROP TABLE users;.

One mutation was disputed and the dispute upheld: .match().search() is an equivalent mutant,
since the regex is ^-anchored without re.MULTILINE. Confirmed across 1,483 inputs, including the
counterfactual that adding MULTILINE would distinguish them.

Limitation this branch acquired, and documents

advise now checks the manifest's dbt_schema_version and adapter_type, as check already did. A
foreign adapter_type means dbt is not building the introspected relations at all, so every match is a
name coincidence and all three dbt rules are wrong — it warns rather than silently advising. An absent
adapter_type warns too: dbt compile always writes one, so absence means a hand-written or truncated
manifest, and staying silent would make silence mean either "consistent" or "unchecked".

Verification

  • 725 passed, 15 deselected — zero skips; no extras, no Docker
  • 15 passed integration against live postgres:16
  • ruff check, ruff format --check, mypy src/sqlquality clean

Follow-ups, not in this PR

  • CI still never runs the 15 integration tests (no Postgres service in ci.yml). No longer
    load-bearing for ADV302 after the wiring guards, but worth a service container.
  • ADV004 never discloses that it skipped coverage checks — carried from Batch 2.
  • The integration compose binds host port 55432, which collides with another project's container on at
    least one dev machine; when that happens docker compose up doesn't bind and the suite talks to
    whatever else is listening.

🤖 Generated with Claude Code

hanslemm and others added 15 commits July 27, 2026 23:30
Adds sqlquality.workload.dbt: parse_relation_name qualifies a dbt relation_name
down to (schema, table) without guessing a database; DbtContext indexes model
nodes by the relation they build, matching schema+table exactly (no bare-name
fallback, since dbt's main/dev target schema routinely differs from advise's
introspected schema); load_dbt_context never raises, degrading a missing or
malformed manifest to a disclosure line instead of aborting a run that already
did the catalog work.

Wires --project-dir and --manifest onto `advise`; the CLI now loads the
context and echoes the disclosure to stderr, but applies no enrichment yet
(Tasks 2-5). No workload adapter imports the new module, so every existing
advise invocation behaves identically without a manifest.
Widen load_dbt_context's exception handling: DbtProject.from_path and
DbtContext.from_project both ran outside any effective safety net for a
structurally-valid-but-wrong-shaped manifest ({"nodes": null}, a non-dict node,
a non-string relation_name, ...), each raising a different AttributeError/
TypeError that escaped all the way out of `advise --manifest`, exit 1, after
the whole catalog analysis had already run — precisely what the module's own
docstring promised would not happen. Both calls are now in one try, with the
expected DbtProjectError path kept separate (its own message already names the
path, so nothing is prepended) from a deliberately wide `except Exception`
whose comment states why: a manifest is a file some other tool wrote, and the
cost of a miss is an aborted run that already did the real work.

Replace parse_relation_name's regex `findall` with a hand-written scanner: the
regex silently skipped characters it couldn't match, so an empty quoted
segment, an unterminated quote, or a trailing dot each vanished a part instead
of being rejected, shifting the rest onto the wrong slot rather than declining.
The scanner also fixes a genuine bug the regex form had going into review
(`quoted if quoted is not None else bare` — findall represents a
non-participating group as "", not None, so unquoted input always parsed to
None).

DbtContext.from_project now declines a cross-database relation collision
(two databases each building a `main.orders`, `advise` connects to one at a
time) instead of letting dict insertion order silently pick a winner; dropped
relations are counted in `dropped_collisions` and surfaced in the CLI
disclosure. The resource_type guard is deleted (model_ids() already guarantees
it; the guard was unreachable), and the relation_name guard's reachability
(ephemeral models) is now documented and pinned.

Adds CLI-level coverage for --project-dir (previously untested — swapping
target/manifest.json for garbage left the suite green), pins the disclosure to
stderr only with stdout staying valid JSON under --json, and pins the
no-manifest invariant that Task 5's byte-identical-diff-against-main will rely
on.

Every new/changed assertion was verified red against the mutation it pins,
including each parametrized case independently.
…s through pre-commented DDL

- render_ddl no longer double-comments a ddl value that is already commented on every
  line (the shape enrich_proposals' config-block rewrite produces): it emits it verbatim
  with its usual code/confidence header instead of falsely reporting an identifier line
  break. The check is engine-agnostic and names nothing about dbt.
- Partial-index detection now keys on ADV004's own guard_column/guard_predicate evidence
  instead of a "WHERE" substring search, which a column literally named WHERE could spoof
  and a lowercase where could evade.
- CREATE UNIQUE INDEX (and CREATE INDEX CONCURRENTLY) are now recognised as
  index-creating, and a unique index's config block sets unique: true instead of silently
  passing the DDL through untouched.
- materialized_view is now rebuilt like incremental instead of reported "unrecognised".
- Removed the decorative `!r` on the interpolated column list (list formatting already
  falls back to repr()); pinned the two independent newline defenses (list-repr escaping,
  _comment_block's resplitting) and the DROP INDEX guard individually.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, pins the 1-child boundary

Review findings on ADV303: model_children's model-only filter made a snapshot or exposure
child invisible, so a mart with a declared exposure was proposed for deletion -- exactly
what the rule exists not to do. DbtProject gains child_ids (raw child_map, no resource-type
filter); DbtContext.consumer_count uses it, excluding only test.* ids (a test asserts about
a model, it does not consume it).

Also: an Aggregation with no usage at all (an empty or fully-unparseable workload) no longer
reads as evidence every childless model is unused -- refused outright rather than treated as
a signal. And the >0 children-gate boundary, previously only exercised by a two-child
fixture, is now pinned by a model with exactly one model child, the commonest real shape.

Each fix's mutation was confirmed to redden its own test and nothing else, then restored.
… its own ordering

Two review minors: propose_materialization excluded materialized_view correctly but never
said why -- one sentence added, contrasting with ADV302's explicit branch for the same
materialization since the two rules are answering different questions. Also pinned canonical
output ordering (by relation, not usage-supply order) with a fixture where relation order and
dbt unique_id order deliberately disagree, so the sorted() call is exercised for real rather
than passing by coincidence.
Loads the optional dbt context, applies enrich_proposals (ADV302) and appends
ADV301/ADV303 when a manifest loaded, then re-sorts with the adapter's own
ranking key so the terminal table, markdown and DDL file stay in agreement.
advise_payload and render_advise_markdown gain a "dbt" key/section (manifest
path, model count, dropped_collisions), defaulting to None/absent so every
existing caller is unaffected. --min-cost-share's help text now names ADV301
(cost-weighted) and ADV303 (not, since its evidence is absence).

Proved the no-manifest path against main (pre-branch) two ways: a live-Postgres
round trip (numeric cost_share/total_cost_ms drift confirmed environmental by
reproducing it between two runs of identical branch code) and a deterministic
stubbed-adapter run, where stdout, markdown, DDL and stderr are all
byte-identical except for the single expected "dbt": null key the interface
spec requires.
advise_payload emitted "dbt": null on the no-manifest path, which is a schema
addition relative to main and broke literal byte-identity of the --json
output. Omitting the key outright when dbt is None satisfies both the
byte-identity requirement and "the key is present with content when a
manifest loaded" -- a consumer wanting the value unconditionally still has
payload.get("dbt").

Re-verified with a deterministic (stubbed-adapter) main-vs-branch run: stdout,
markdown, DDL and stderr are now all byte-identical (empty diff), not
"identical apart from one known line." Added a direct test that the payload
survives json.dumps with a real --manifest run (the manifest path is a Path,
which cli.py already str()s before handing it to advise_payload).
…gres

Every silent-suppression bug in the dbt enrichment feature so far was found by running
against a live database, never a fixture. Add a live test that builds a manifest whose
relation_name schema matches this project's seeded public/staging schemas (the shipped
tests/fixtures/manifest_v12.json uses "main", which the seeded database never has, and
relation matching has no bare-name fallback -- so reusing it would match nothing and the
test would pass while proving nothing).

Non-vacuity guard first: assert the un-enriched run really emits CREATE INDEX for
public.orders (ADV001, on the hot status predicate). Only then assert the --manifest run
turns that same proposal into a dbt config block (dbt_index_config in evidence, ddl no
longer starts with CREATE INDEX).

Ran against a standalone postgres:16 container on host port 55433, not the compose file's
55432: an unrelated container (dp-pg-test, from another project) already holds that port
on this machine, so docker compose up would silently talk to it instead. Left dp-pg-test
untouched; pointed the suite at the new container via SQLQUALITY_TEST_DSN.

679 passed / 15 deselected (pytest -q); 15 passed (pytest -m integration). All four gates
green; no production code changed.
…viations

README: a new "dbt enrichment (optional)" section leading with why ADV302 exists (raw DDL
on a dbt-managed table does not survive dbt run, and the three managed materializations
differ in how), ADV301/ADV302/ADV303 added to the proposal table, --project-dir/--manifest
added to the flags table, and --min-cost-share's help text corrected to name ADV301
(cost-weighted) and ADV303 (not -- its evidence is absence). The stale "Redshift, Snowflake
and dbt enrichment are designed but not implemented" limitations bullet is split: dbt
enrichment is implemented, Redshift/Snowflake are not.

CHANGELOG: an Unreleased/Added entry for the dbt enrichment feature, naming all three rule
codes and the matching rule accurately.

Spec: records that the shipped code reassigns ADV302 to the DDL-correctness rewrite (not
the dead-model rule the spec originally gave it) because it is the one dbt-enrichment
behavior that is corrective rather than additive -- the dead-model rule shipped as ADV303
instead, and the originally-specified join-path/mart rule is out of scope for this batch.
Also records: dbt is imported from cli.py only, never from an adapter (verified by grep);
matching is on the qualified (schema, table) pair with no bare-name fallback, and why (a
dbt project's target schema routinely differs from the schema advise introspects in
production, so a name-only match risks rewriting a production table's DDL on the strength
of an unrelated model); a relation two different models both claim is dropped from the
index and counted rather than resolved by insertion order; and that the no-manifest path's
byte-identity to main is proven by measurement (stub-adapter and live-Postgres diffs, both
empty on stdout/markdown/DDL/stderr), not merely asserted.
F0 The CLI's ADV302 wiring had no guard in the suite CI runs: replacing the
   `enrich_proposals` call with `pass` left all 665 tests green, because the only
   coverage was an integration test CI never executes. Pinned with a default-suite
   test — no Docker, no extras.

F1 Two index proposals for one dbt model each emitted a complete `indexes:` block.
   Pasted under one model's config that is a duplicate YAML mapping key, and dbt's
   parser silently keeps one, discarding the other recommended index. One model now
   yields one merged block, carried by the highest-ranked proposal; the others point
   at it by code. The emitted block is validated by parsing it as YAML.

F2 The `--ddl` file could hold a config block explaining that raw DDL does not survive
   `dbt run` and, below it, a bare `CREATE INDEX` on that same dbt-managed table:
   `render_ddl` never emits `rationale`, where the disclosure lived. `Proposal` grows
   an optional `note` that `render_ddl` writes as comment lines above the statement —
   engine-agnostic, and `ddl` stays a pure statement. Set on every ADV302 decline path.
   Deliberately absent from the JSON payload and markdown, which carry `rationale`.

F2b `_is_fully_commented`, the guard on "no emitted DDL line is executable-looking",
   had no test: `all` -> `any` passed the suite and emitted a bare `DROP TABLE users;`.
   Pinned, along with three further leniency mutations and the `\r` half of the
   line-break guard.

F2c Killed the review's listed mutation survivors: `_is_unique_index` -> True, a
   non-index `CREATE` being rewritten, `_split_relation_parts` mis-parsing after a
   closing quote, `_is_partial_index`'s disjunction (each alternative alone), the whole
   `columns` validation, output order, evidence mutation, and the YAML validity of the
   block. Repaired five vacuous tests. `--manifest`/`--project-dir` precedence is now
   one function with one test that passes both flags.

F3 ADV302's no-columns decline was completely silent. It now amends the rationale and
   carries a DDL note; kept rather than deleted, since `_is_index_creating` matches by
   DDL prefix precisely so future rules reach this path.

F4 ADV302 is not a proposal code — the docs said it was. README/CHANGELOG/spec now say
   it is a rewrite and how to filter for it, and `advise` prints a stderr line naming
   how many proposals it rewrote, since the terminal row is otherwise unchanged.

F5 "On a view the proposal is dropped" was false in all three docs: only the DDL is.

F6 `advise` now makes the same two manifest checks `check` makes — schema version, and
   an `adapter_type` whose dbt has no `indexes` config — as stderr warnings plus a
   documented limitation. It warns rather than suppressing the rewrite: the alternative
   is raw DDL the same rebuild destroys.

F7 The engine-agnostic CLI reached into `PostgresWorkloadAdapter._ranking_key`.
   `ranking_key` is now a public hook on the `WorkloadAdapter` ABC, resolved off the
   adapter instance, and `cli.py` imports no adapter at all.

F8-F15 The block names its model, so two relations no longer render identical text; no
   surface claims the block is "above" anything; `dbt_index_config` is a flag rather
   than a duplicate of `ddl`; ADV303's threshold scoping and empty-workload suppression,
   the stale Evidence cells and the `--json` key list are corrected; the no-manifest
   path's four artifacts have a regression guard; the btree/column-list reconstruction
   is documented and a non-btree access method declines the rewrite; ADV303's transitive
   caveat reaches the rationale; an absent materialization is stated once, in words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADV002 and ADV003 read the catalog, not the manifest, so they put a bare
`DROP INDEX "public"."idx_orders_cold";` into the same `--ddl` file that elsewhere
declares `public.orders` dbt-managed. The branch that let them through justified it
with "dbt never created this index, so dropping it is ordinary" — false in exactly
the case ADV302 exists for: if the index is declared in that model's `indexes:`
config, the next `dbt run` recreates it. The operator drops it, dbt puts it back,
and the tool proposes the same drop again next run. That is the silently-reverting
advice ADV302 was built to eliminate, pointing the other way, and reachable through
ordinary rules rather than only in principle.

Both halves of the instruction are now given, and the proposal is not suppressed —
dropping a genuinely unused index is still right. The rationale and a `note` beside
the statement say the config entry has to go too. Any other statement kept for a
relation dbt owns gets a note as well, so the file-level property holds for a
statement shape rather than for today's rule codes.

The test that first checked this property filtered DDL blocks on the *table* name,
which silently skipped every drop: `DROP INDEX` names an index, not a table. It is
now keyed on the statement itself, matched against the JSON payload, and the shared
CLI scenario grows an unused index so ADV002 fires through the real rules.

Two corrections from the re-review:

- The foreign-`adapter_type` warning claimed the wrong thing. A Snowflake or BigQuery
  manifest does not merely risk emitting a config key that adapter lacks — it means
  dbt is not building the Postgres relations `advise` just introspected at all, so
  every match is a name coincidence and ADV301/ADV302/ADV303 are all wrong. Reworded
  to say that. A manifest recording *no* `adapter_type` now warns too: `dbt compile`
  always writes one, and warning on "different" while staying silent on "unknown"
  would make silence mean either consistent or unchecked.
- `_names_a_non_btree_method`'s docstring named an over-trigger that cannot happen: a
  column named `USING` does not match, because quoting puts a `"` where `\s+` needs
  whitespace. The real case is a name containing the whole clause, like `"USING gin"`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hanslemm
hanslemm merged commit 9621a76 into main Jul 28, 2026
6 checks passed
@hanslemm
hanslemm deleted the feat/advise-dbt-enrichment branch July 28, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant