fix(ol-dbt-cli): make composite --primary-key work in ol-dbt diff - #2601
Open
quazi-h wants to merge 6 commits into
Open
fix(ol-dbt-cli): make composite --primary-key work in ol-dbt diff#2601quazi-h wants to merge 6 commits into
ol-dbt diff#2601quazi-h wants to merge 6 commits into
Conversation
…#617789)
`ol-dbt diff` failed with a SQL parser error whenever `--primary-key` was
passed more than once:
Parser Error: syntax error at or near "["
coalesce(a_query.['user_micromasters_email', 'user_mitxonline_email', ...
audit_helper takes `primary_key` as an opaque string it interpolates straight
into SQL, never as a list, so the Jinja list literal we were building reached
the database as the text `['k1', 'k2']`. Two call sites were affected:
* `compare_column_values` (per-column mismatch rates) emits
`a_query.{{ primary_key }} = b_query.{{ primary_key }}` — a *scalar* column
name. A composite key cannot be threaded through at all, so we now
synthesize one hashed join column with `dbt_utils.generate_surrogate_key`
inside a_query/b_query and hand audit_helper that column instead. This is
where the reported error surfaced.
* `compare_relations` / `compare_queries` interpolate `primary_key` into the
`order by` of the `summarize=false` branch, which is the sample-mismatch
path. It rendered `order by ['k1', 'k2'], in_a desc` — the same latent
parser error, reached only once a diff actually had unmatched rows. Fixed
by rendering the key comma-joined (`primary_key='k1, k2'`), which is also
what the single-column case already produced.
Verified on target dev_local against the reported repro
(marts__micromasters_dedp_exam_grades vs its baseline, composite key on
user_micromasters_email / user_mitxonline_email / proctoredexamgrade_created_on):
20,908 rows both sides, every row paired (0 missing on either side), and one
real column difference isolated — `semester`, 8,891 rows. The single-column
fallback the tool previously forced on us fans that same comparison out into a
many-to-many join reporting 65k+ mismatches and 7,826 phantom missing rows on a
20,908-row table, which is the unreliable pairing this bug caused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mns, document composite keys Follow-up to the composite `--primary-key` fix in the previous commit. Cyclopts consumes exactly one token per flag occurrence, so `-k a -k b -k c` was the only way to express a composite key. The two forms people actually reach for first both failed, and two of the three failure messages never mentioned primary keys at all: * `-k a b c` -> `Cannot specify token "b" positionally for parameter dbt-dir`, or, with old/new passed positionally, `dbt project not found at <repo>/b` -- the stray token is absorbed as `dbt_dir_path` and the key list is silently truncated to one column. * `-k "a,b,c"` -> `Invalid primary key: 'a,b,c'` (correctly loud, since `_IDENTIFIER_RE` rejects the comma, but it turns away a form most CLIs take). `_split_columns()` now flattens both repeated and comma-separated arguments for `--primary-key` and `--exclude-columns`, so `-k a,b,c` and `-k a -k b -k c` are equivalent. Order is preserved, duplicates dropped (a repeated key column would otherwise be emitted twice into the surrogate-key expression), and empty segments discarded rather than surfacing as an empty-identifier error. `-k a b c` still cannot be made to work -- that is cyclopts' arity, not ours -- so the help text, the command docstring, and the docs now say so explicitly instead of leaving people to hit the `--dbt-dir` error. Docs: * src/ol_dbt/README.md -- both composite forms in the examples, plus a "Choosing the primary key" section on why the grain matters, with the measured numbers, and how to find a model's real key (its `dbt_expectations_expect_compound_columns_to_be_unique` test). * skills/data/ol-dbt-fast-validation, skills/data/ol-dbt-local-dev -- composite key guidance alongside the existing --primary-key rules. * docs/specs/DBT_WAREHOUSE_CI_QA_SPEC.md -- corrects §3.4 step 3, which asserted the key is simply "passed to compare_relations as the join key". That is the assumption that produced the original bug: audit_helper interpolates primary_key into SQL as an opaque string, and its two consumers need different forms (comma-joined for compare_queries' order by; a scalar surrogate column for compare_column_values). Verified end-to-end on dev_local against the same repro: `-k a,b,c` and `-k a -k b -k c` produce byte-identical results (20,908 rows both sides, delta 0, every row paired, one real difference -- `semester`, 8,891 rows). 12 tests added (9 unit + 3 CLI-level, including one asserting the two forms emit identical SQL); full ol_dbt_cli suite 476 passing, pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🔎 ol-dbt impact — column-level blast radius0 breaking, 0 warning, 1 info across 1 changed model(s). Details
Posted by |
Contributor
There was a problem hiding this comment.
Pull request overview
Adds composite primary-key support to ol-dbt diff.
Changes:
- Supports repeated and comma-separated column arguments.
- Generates composite join keys and valid audit-helper SQL.
- Expands tests and usage documentation.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/ol_dbt/README.md |
Documents composite keys. |
src/ol_dbt_cli/tests/test_diff.py |
Tests parsing and SQL generation. |
src/ol_dbt_cli/ol_dbt_cli/commands/diff.py |
Implements composite-key handling. |
skills/data/ol-dbt-local-dev/SKILL.md |
Updates local-development guidance. |
skills/data/ol-dbt-fast-validation/SKILL.md |
Updates validation guidance. |
docs/specs/DBT_WAREHOUSE_CI_QA_SPEC.md |
Records audit-helper integration behavior. |
Suppressed comments (1)
src/ol_dbt_cli/ol_dbt_cli/commands/diff.py:470
- This alias is not collision-proof. If the relation already has a non-key column named
ol_dbt_diff_surrogate_key, the generated query selects both the synthetic key and that real column under the same name;compare_column_valueswill then either fail with an ambiguous reference or compare the synthetic key instead of the requested real column. Derive an alias that does not match the key or current comparison column.
select_cols = f"{key_expr} as {_SURROGATE_PK}, {column}"
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…-lookup docs Addresses Copilot's review on #2601. All three findings were valid; verified each before changing anything. 1. `dbt_utils.generate_surrogate_key` is unsafe as a join key. It joins components with a literal `-` before hashing and does not encode component boundaries, so distinct keys collide. Reproduced on dbt_utils 1.3.3 / DuckDB: ('a-b', 'c') and ('a', 'b-c') both hash to 7b193b3d3318446... Two different rows would have paired as one, reintroducing exactly the many-to-many mispairing this PR exists to prevent. Replaced with a new `diff_composite_key` project macro that length-prefixes each component (`<len>:<value>`, NULL as `~`), so no character inside a value can shift a boundary. Reading digits to the first ':' gives the length and the next <len> characters are the value, which makes the encoding injective -- and it stays injective whether the adapter's length() counts bytes or characters, since both sides of a comparison are computed by the same engine. The macro lives in src/ol_dbt/macros rather than being built as a Python f-string because it needs dbt.concat/dbt.hash/dbt.length: those dispatch per adapter (Trino needs to_hex(md5(to_utf8(..))), StarRocks has no `||`), and Jinja does not re-render inside string literals, so they cannot be nested into generated SQL. Dispatchable via `open_learning` per repo convention. Verified: the colliding pair now yields distinct keys (ea478e8e... vs 77550cbf...), identical tuples still match, and NULL, '', '~' and '1:x' -- values chosen to attack the encoding itself -- all stay distinct. 2. The grain-lookup guidance named a test spelling that does not exist. Model YAML uses the dotted `dbt_expectations.expect_compound_columns_to_be_unique` (230 occurrences); the underscored form appears nowhere in the project (0 occurrences) -- it only shows up in compiled test names. Anyone following the old guidance would have grepped for a string that isn't there. Corrected in src/ol_dbt/README.md and the fast-validation skill, with a note about why the two spellings differ. 3. `unique` alone does not make a single column safe as a key. dbt's `unique` test ignores NULLs, so a nullable column can pass it while the single-column comparison path -- a plain `a.k = b.k` -- cannot pair NULL to NULL, dropping those rows into the "missing from" buckets on both sides. The docs now require both `unique` and `not_null`, and say why. End-to-end on dev_local against the original repro: unchanged results after the macro swap (20,908 rows both sides, delta 0, every row paired, one real difference -- semester, 8,891 rows), so this is behaviour-preserving on real data while removing the collision hazard. Full ol_dbt_cli suite 476 passing; pre-commit clean, including sqlfluff on the new macro. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not generate_surrogate_key Addresses Copilot's second-round review on #2601. Valid finding, and my omission: I added this paragraph in 26ba2ef, then replaced the implementation in 52b3c49 and updated diff.py, test_diff.py, README.md and the fast-validation skill -- but missed this spec, so the PR contradicted itself. It matters more than a stale reference normally would. The paragraph is headed "Correction (implementation reality)" and exists precisely to stop the next implementer repeating the original bug by recording audit_helper's real contract. Naming generate_surrogate_key there pointed that reader straight at the colliding implementation this PR removed as unsafe -- a doc steering someone back into the bug it was written to prevent. Also added a scoping note, because the obvious over-correction is worse than the error: the collision caveat applies only to using a hash as a row-pairing join key across two relations. The ~70 `*_pk` surrogates in the dimensional models legitimately use generate_surrogate_key -- both sides of those joins compute the hash identically, and a boundary collision would make two rows share a `*_pk` and fail that column's `unique` test rather than silently mispair. Without that note, a future reader doing a "fix all references" sweep would make a large, wrong change. Swept the branch: the only stale reference was this one. The six remaining mentions are all deliberate -- the diff.py docstring explaining why it is not used, the `assert "generate_surrogate_key" not in sql` regression guard, the macro's own rationale comment, and the two in this paragraph. Docs only; no code change. ol_dbt_cli suite 476 passing, pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he compared column Addresses Copilot's third-round review on #2601. Valid, and the more damaging of the two outcomes it suggested: this failed SILENTLY, it did not error. If the compared column is itself named `ol_dbt_diff_surrogate_key`, the emitted select carried that identifier twice -- once for the generated key, once for the real column -- and audit_helper then used the same name as both `primary_key` and `column_to_compare`. Confirmed against DuckDB before changing anything: it does not reject the ambiguity, it binds to the FIRST match. So the comparison compared the generated key against itself and reported ✅: perfect match (100%) for a column whose real values were 'REAL_OLD' vs 'REAL_NEW_DIFFERENT'. A silent false negative is the worst possible failure for a tool whose only job is detecting differences -- it reports agreement that isn't there. `_surrogate_alias()` now derives the alias, appending `_x` until it collides with neither the compared column nor any key column, matched case-insensitively since warehouse identifiers are. The result stays a plain identifier, so it still satisfies _IDENTIFIER_RE. Same DuckDB case now correctly reports `❌: values do not match`. Reachability is admittedly near zero -- no warehouse model has a column called `ol_dbt_diff_surrogate_key`. Fixed anyway, on the same reasoning that the delimiter collision in the previous round deserved fixing: "unlikely" is not "impossible", the failure is silent rather than loud, and the guard is six lines. The prior comment claiming the name was "collision-proof" was asserting a property it did not have, which is now what the code actually establishes. 7 tests added (6 on the alias derivation -- default, compared-column collision, case-insensitivity, key-column collision, escalation, and that the result is still a valid identifier -- plus the SQL-level regression Copilot asked for, comparing a column named `ol_dbt_diff_surrogate_key`). Happy path re-verified end-to-end on dev_local: unchanged (20,908 rows both sides, delta 0, semester 8,891). ol_dbt_cli suite 483 passing, pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
docs/specs/DBT_WAREHOUSE_CI_QA_SPEC.md:176
- This claimed distinction is incorrect: persisted
*_pkhashes are also used to pair rows, so a boundary collision can silently join distinct natural keys just as it can in the diff. Auniquetest catches the problem only when both colliding tuples coexist in that tested relation and the test runs; it does not makegenerate_surrogate_keycollision-safe. Please avoid documenting those joins as protected from mispairing.
This caveat is specific to using a hash as a **row-pairing join key across two
relations**. It is not a claim about the `*_pk` surrogates in the dimensional models,
which legitimately use `generate_surrogate_key`: there both sides of a join compute the
hash identically, and a boundary collision would make two rows share a `*_pk` and fail
that column's `unique` test rather than silently mispair.
…insensitively Addresses Copilot's fourth-round review on #2601, plus two issues of the same class found by reviewing the whole diff rather than only the reported lines. Reported (both valid, both in `_split_columns`, which this PR introduced): 1. `-k ""` / `-k ","` flattened to [] and became indistinguishable from omitting the flag, so the per-column comparison the user explicitly asked for was silently skipped. Before comma-splitting existed the empty string simply failed identifier validation, so this was a regression I introduced. `_require_non_empty()` now rejects "supplied but empty". 2. Duplicate removal was case-sensitive while everything else in this path (`_validate_identifiers`, the `pk_lower` filter) treats identifiers case-insensitively. `-k id,ID` emitted the same column twice into diff_composite_key AND needlessly took the composite path for what is one column. Now matched case-insensitively, keeping the first spelling since that is what the user wrote. My docstring already claimed duplicates were dropped -- the third time in this PR I documented a guarantee the code did not make. Found by my own sweep, same class, not reported: 3. `--exclude-columns ""` had the identical silent-degradation bug (it excluded nothing instead of erroring). Fixed together rather than only the flag Copilot happened to name. 4. A mistyped `--primary-key` is a valid identifier, so identifier validation cannot catch it, and nothing checked the key against the reconciled column set. `-k user_micromasters_emai` reached the warehouse and returned a raw "column not found" with no hint the key was at fault. Now reported by name before any warehouse round-trip, with the common column list. Guarded on the column set having actually resolved -- unresolved is "unknown", not "empty", and must not be read as "the key does not exist". Verified against the real CLI on dev_local, not just unit tests: all four messages confirmed, the case-duplicate now collapses to the single-key path (primary_key: ['user_micromasters_email']), the typo fails before any dbt call, and the happy path is unchanged (20,908 rows both sides, delta 0, semester 8,891). 17 tests added; suite 500 passing; pre-commit clean. README documents the new failure modes. Co-Authored-By: Claude Opus 5 (1M context) <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.
What are the relevant tickets?
No GitHub issue — this was tracked in the internal task graph (
tk-ol-dbt-diff-composite-primary-key-generates-inva-617789,tk-ol-dbt-diff-k-a-b-c-space-separated-truncates-th-65117e).Context, not closed by this PR: discovered while validating #2088 (migrate
marts__micromasters_dedp_exam_gradesto the dimensional layer). That migration needs a trustworthy diff on the mart's real grain, which is what this unblocks.Description (What does it do?)
Makes composite
--primary-keywork inol-dbt diff. Passing the flag more than once previously died with a SQL parser error:Root cause.
audit_helpertakesprimary_keyas an opaque string it interpolates straight into SQL, never as a list. The Jinja list literal we were building reached the database as the literal text['k1', 'k2']. Two call sites were affected, and they need different forms:primary_keyascompare_column_valuesa_query.{{ pk }} = b_query.{{ pk }}— a scalar column present in both queriesdiff_composite_keymacro inside thea_query/b_queryblocks, and pass that column's namecompare_queries(viacompare_relations)order byon thesummarize=falsebranchprimary_key='k1, k2')The second one is worth flagging for reviewers: the bug report assumed
compare_relationswas fine. It isn't.primary_keyis unused whensummarize=true(which is why row counts looked healthy), but thesummarize=falsesample path rendersorder by ['k1', 'k2'], in_a desc. Same parser error, only reached once a diff actually has unmatched rows to sample — so it would have come back as a separate bug report later.Second commit accepts a comma-separated key. Cyclopts consumes exactly one token per flag occurrence, so
-k a -k b -k cwas the only way to express a composite key, and the two forms people reach for first both failed with errors that never mentioned primary keys:-k a b c→Cannot specify token "b" positionally for parameter dbt-dir, or withold/newpositional,dbt project not found at <repo>/b— the stray token is absorbed asdbt_dir_pathand the key list silently truncates to one column.-k "a,b,c"→Invalid primary key: 'a,b,c'(correctly loud, since_IDENTIFIER_RErejects the comma, but it turns away a form most CLIs accept)._split_columns()now flattens both repeated and comma-separated arguments for--primary-keyand--exclude-columns, so-k a,b,c≡-k a -k b -k c. Order preserved, duplicates dropped (a repeated key column would otherwise be emitted twice into the surrogate-key expression), empty segments discarded rather than surfacing as an empty-identifier error.-k a b cstill cannot be made to work — that's cyclopts' arity, not ours — so the help text, docstring, and docs now say so explicitly rather than leaving people to hit the--dbt-direrror.Why this matters beyond the crash. The correct grain for many marts is composite, and the single-column fallback the tool forced isn't a lesser answer, it's a misleading one. Same comparison, same data, 20,908-row mart:
user_micromasters_emailalonesemester, 8,891 rows), nothing missing on either sideAll of that first row is join artifact from many-to-many pairing on a non-unique key.
How can this be tested?
Reproduce the original failure on
main, then confirm it's fixed. Requires a local DuckDB (dev_local) with both relations materialized:Both forms were verified to produce byte-identical output:
Note this exercises both fixed paths — the 20 rendered sample-mismatch rows mean the
summarize=falseorder bypath ran too, not just the per-column path.Automated coverage:
16 tests added/rewritten. Note that
test_compare_relations_composite_pkwas asserting the buggyprimary_key=['k1', 'k2']output, so it was replaced rather than kept. New tests include one asserting the comma and repeated forms emit identical SQL.Additional Context
Docs.
docs/specs/DBT_WAREHOUSE_CI_QA_SPEC.md§3.4 step 3 said the key is simply "passed tocompare_relationsas the join key" — that's the assumption that produced this bug, so it's corrected to record the real audit_helper contract.src/ol_dbt/README.mdgets a "Choosing the primary key" section with the numbers above and how to find a model's real grain (itsdbt_expectations_expect_compound_columns_to_be_uniquetest). Bothol-dbt-*skill docs updated.Two judgment calls worth a reviewer's opinion:
--exclude-columnsas well as--primary-key. Identical declaration, identical trap, and leaving the two inconsistent seemed worse than the small scope increase — but it is scope creep, so say if you'd rather it were dropped.--dbt-dirkeyword-only, which would stop a stray token being absorbed as a path. It changes the CLI's positional contract for a case that already errors rather than silently misbehaving, so it didn't seem worth the blast radius. Reconsider if anyone actually trips on it.Known asymmetry, filed as follow-up (
tk-ol-dbt-diff-single-column-primary-key-drops-null-1d1a40, p3):generate_surrogate_keycoalesces NULLs to a sentinel, so the composite path pairs rows with NULL key components while the single-column path (a plain equi-join, unchanged here) does not. Not a wrong answer on a well-formed key — a primary key column shouldn't be nullable — but the two paths now differ, and that's worth tidying separately rather than expanding this PR.Scope note: no dbt models are touched. The change is CLI + docs + one new dbt macro (
src/ol_dbt/macros/diff_composite_key.sql).Review round 1 — Copilot feedback addressed in 52b3c49
All three comments were valid; each was verified before changing anything.
1.
dbt_utils.generate_surrogate_keyis unsafe as a join key. It joins components with a literal-before hashing without encoding boundaries. Reproduced on the pinned dbt_utils 1.3.3 / DuckDB —('a-b', 'c')and('a', 'b-c')both hash to7b193b3d3318446..., so two distinct keys would pair as one row and reintroduce the exact mispairing this PR prevents.Replaced with a new
diff_composite_keymacro that length-prefixes each component as<len>:<value>(NULL as~). Reading digits to the first:gives the length and the next<len>characters are the value, so nothing inside a value can shift a boundary; it stays injective whether the adapter'slength()counts bytes or characters, since both sides of a comparison are computed by the same engine.It's a project macro rather than inline SQL because it needs
dbt.concat/dbt.hash/dbt.length— those dispatch per adapter (Trino needsto_hex(md5(to_utf8(..))), StarRocks has no||) and Jinja doesn't re-render inside string literals, so they can't be nested into generated SQL.Verified in DuckDB: the colliding pair now yields distinct keys (
ea478e8e...vs77550cbf...), identical tuples still match, and NULL,'','~'and'1:x'— chosen to attack the new encoding — all stay distinct. End-to-end results on the repro are unchanged, so the swap is behaviour-preserving on real data.2. The grain-lookup guidance named a test spelling that doesn't exist. Model YAML uses the dotted
dbt_expectations.expect_compound_columns_to_be_unique(230 occurrences); the underscored form has 0 occurrences — it only appears in compiled test names, which is where I picked it up. Anyone following the original guidance would have grepped for a string that isn't in the project. Corrected, with a note on why the two spellings differ.3.
uniquealone doesn't make a single column safe. dbt'suniqueignores NULLs, so a nullable column can pass it while the single-column path's plaina.k = b.knever pairs NULL to NULL — those rows inflate the "missing from" buckets on both sides. Docs now require bothuniqueandnot_null.