feat(advise): optional dbt enrichment — stop proposing DDL that dbt destroys - #14
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Batch 3a of 3. Optional dbt enrichment for
advise— and primarily a correctness fix, not afeature:
advisecurrently 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 areal 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
tablematerialization drops and recreates the relation on everydbt run. So theCREATE INDEXadviseemits today for a dbt-managed table is destroyed by the next build — the toolconfidently advises something that silently reverts.
ADV302 rewrites those proposals into a commented
indexes:config block instead. The threematerializations genuinely differ and distinguishing them is the rule:
CREATE INDEXtableindexes:configincremental--full-refreshmaterialized_viewviewADV004's partial index is disclosed as not expressible rather than silently rewritten — dbt's
indexesconfig has no predicate field, so emitting one would lose theWHEREand turn a correctproposal into a wrong one.
DROP INDEXon a dbt-managed relation is the mirror case, and it is handled: if the index is declaredin that model's config, the next
dbt runrecreates it, so the DDL file says to remove the configentry too — otherwise the drop doesn't stick and gets proposed again next run.
Two rules that need the model graph
view-backed model carrying a hot share of cost. Capped MEDIUM with noHIGH 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).declares as a consumer. Capped LOW because the evidence is absence; states both the window and
--limitcaveats. Emits nothing on an empty workload — you cannot conclude a model is unused from aworkload 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
mainacross stdout, JSON, markdown, DDL and exit code — verified three separate times against a
mainworktree, 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 tonullprecisely so that identity isliteral rather than true-with-an-asterisk.
Model matching is on the exact
(schema, table)pair with no bare-name fallback: a dbt targetschema 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_proposalscall withpassleft all 665 tests passing, because the only check was anintegration 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 inthe rationale.
The relation parser was rewritten from a regex to a hand-written scanner after diagnosing that
re.findallsilently skips characters it doesn't match — which is how every malformed name became aconfident wrong answer rather than a rejection. It is now total over
str: verified across 16,105adversarial inputs, never raising, always
Relation | None.Also fixed:
load_dbt_contextraised on wrong-typed JSON (a manifest that is a list, or hasnodes: null) and exited 1 with a traceback after all the catalog work; 42 of 92 mutations survivedthe first fix wave, including five outright vacuous tests; and the
_is_fully_commentedguard on "noemitted DDL line is executable-looking" had no test at all —
all→anypassed 665 tests whileemitting a bare
DROP TABLE users;.One mutation was disputed and the dispute upheld:
.match()→.search()is an equivalent mutant,since the regex is
^-anchored withoutre.MULTILINE. Confirmed across 1,483 inputs, including thecounterfactual that adding
MULTILINEwould distinguish them.Limitation this branch acquired, and documents
advisenow checks the manifest'sdbt_schema_versionandadapter_type, ascheckalready did. Aforeign
adapter_typemeans dbt is not building the introspected relations at all, so every match is aname coincidence and all three dbt rules are wrong — it warns rather than silently advising. An absent
adapter_typewarns too:dbt compilealways writes one, so absence means a hand-written or truncatedmanifest, and staying silent would make silence mean either "consistent" or "unchecked".
Verification
725 passed, 15 deselected— zero skips; no extras, no Docker15 passedintegration against livepostgres:16ruff check,ruff format --check,mypy src/sqlqualitycleanFollow-ups, not in this PR
ci.yml). No longerload-bearing for ADV302 after the wiring guards, but worth a service container.
least one dev machine; when that happens
docker compose updoesn't bind and the suite talks towhatever else is listening.
🤖 Generated with Claude Code