Skip to content

fix(ol-dbt-cli): make composite --primary-key work in ol-dbt diff - #2601

Open
quazi-h wants to merge 6 commits into
mainfrom
fix/ol-dbt-diff-composite-primary-key
Open

fix(ol-dbt-cli): make composite --primary-key work in ol-dbt diff#2601
quazi-h wants to merge 6 commits into
mainfrom
fix/ol-dbt-diff-composite-primary-key

Conversation

@quazi-h

@quazi-h quazi-h commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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_grades to 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-key work in ol-dbt diff. Passing the flag more than once previously died with a SQL parser error:

Parser Error: syntax error at or near "["
LINE 14: coalesce(a_query.['user_micromasters_email', 'user_mitxonline_email', 'proct...

Root cause. audit_helper takes primary_key as 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:

Consumer Uses primary_key as Fix
compare_column_values a_query.{{ pk }} = b_query.{{ pk }} — a scalar column present in both queries Collapse the composite key into one hashed join column via the new diff_composite_key macro inside the a_query/b_query blocks, and pass that column's name
compare_queries (via compare_relations) interpolated into order by on the summarize=false branch Render comma-joined (primary_key='k1, k2')

The second one is worth flagging for reviewers: the bug report assumed compare_relations was fine. It isn't. primary_key is unused when summarize=true (which is why row counts looked healthy), but the summarize=false sample path renders order 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 c was 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 cCannot specify token "b" positionally for parameter dbt-dir, or with old/new positional, dbt project not found at <repo>/b — the stray token is absorbed as dbt_dir_path and the key list silently truncates 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 accept).

_split_columns() now flattens both repeated and comma-separated arguments for --primary-key and --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 c still 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-dir error.

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:

Key Result
user_micromasters_email alone 65,000+ mismatches across 8 columns, 7,826 rows reported "missing from old"
the real 3-column grain 1 differing column (semester, 8,891 rows), nothing missing on either side

All 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:

# On main: fails with `Parser Error: syntax error at or near "["`
ol-dbt diff --target dev_local \
  --old marts__micromasters_dedp_exam_grades_baseline --old-raw \
  --new marts__micromasters_dedp_exam_grades \
  --primary-key user_micromasters_email \
  --primary-key user_mitxonline_email \
  --primary-key proctoredexamgrade_created_on

# On this branch: completes. The comma form is equivalent.
ol-dbt diff --target dev_local \
  --old marts__micromasters_dedp_exam_grades_baseline --old-raw \
  --new marts__micromasters_dedp_exam_grades \
  -k user_micromasters_email,user_mitxonline_email,proctoredexamgrade_created_on

Both forms were verified to produce byte-identical output:

primary_key      : ['user_micromasters_email', 'user_mitxonline_email', 'proctoredexamgrade_created_on']
row_counts       : {'old': 20908, 'new': 20908, 'delta': 0}
column_mismatches: [('semester', 8891, 0, 0)]     # (column, mismatched, missing_in_old, missing_in_new)
verdict          : mismatch

Note this exercises both fixed paths — the 20 rendered sample-mismatch rows mean the summarize=false order by path ran too, not just the per-column path.

Automated coverage:

cd src/ol_dbt_cli && uv run pytest -q          # 476 passed
uv run pre-commit run --files <changed files>  # ruff format, ruff check, mypy, detect-secrets

16 tests added/rewritten. Note that test_compare_relations_composite_pk was asserting the buggy primary_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 to compare_relations as 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.md gets a "Choosing the primary key" section with the numbers above and how to find a model's real grain (its dbt_expectations_expect_compound_columns_to_be_unique test). Both ol-dbt-* skill docs updated.

Two judgment calls worth a reviewer's opinion:

  1. Comma-splitting was applied to --exclude-columns as 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.
  2. I did not make --dbt-dir keyword-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_key coalesces 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_key is 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 to 7b193b3d3318446..., so two distinct keys would pair as one row and reintroduce the exact mispairing this PR prevents.

Replaced with a new diff_composite_key macro 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's length() 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 needs to_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... vs 77550cbf...), 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. unique alone doesn't make a single column safe. dbt's unique ignores NULLs, so a nullable column can pass it while the single-column path's plain a.k = b.k never pairs NULL to NULL — those rows inflate the "missing from" buckets on both sides. Docs now require both unique and not_null.

quazi-h and others added 2 commits August 25, 2026 13:38
…#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>
Copilot AI balanced review requested due to automatic review settings August 25, 2026 17:48
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

🔎 ol-dbt impact — column-level blast radius

0 breaking, 0 warning, 1 info across 1 changed model(s).

Details
  • ℹ️ macro: diff_composite_key.sql → 0 downstream model(s)

Posted by ol-dbt impact (annotate-only — does not block merge).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_values will 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.

Comment thread src/ol_dbt_cli/ol_dbt_cli/commands/diff.py Outdated
Comment thread src/ol_dbt/README.md Outdated
Comment thread skills/data/ol-dbt-fast-validation/SKILL.md Outdated
…-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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread docs/specs/DBT_WAREHOUSE_CI_QA_SPEC.md Outdated
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread src/ol_dbt_cli/ol_dbt_cli/commands/diff.py Outdated
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 *_pk hashes are also used to pair rows, so a boundary collision can silently join distinct natural keys just as it can in the diff. A unique test catches the problem only when both colliding tuples coexist in that tested relation and the test runs; it does not make generate_surrogate_key collision-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.

Comment thread src/ol_dbt_cli/ol_dbt_cli/commands/diff.py
Comment thread src/ol_dbt_cli/ol_dbt_cli/commands/diff.py
…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>
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.

2 participants