Skip to content

feat(sql-plan): port SQLPlanGenerator consumers of the production model fields - #19

Merged
dmautz1 merged 9 commits into
DocumentDrivenDX:mainfrom
dmautz1:feat/port-sql-plan-consumers
Aug 31, 2026
Merged

dmautz1 merged 9 commits into
DocumentDrivenDX:mainfrom
dmautz1:feat/port-sql-plan-consumers

Conversation

@dmautz1

@dmautz1 dmautz1 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Second PR in the series porting production-hardened capabilities from the pulseflow fork of tablespec back upstream. Stacked on #18 (feat/port-umf-model-deltas) — the diff includes #18's commits until it merges; review this PR's own change as feat/port-umf-model-deltas...feat/port-sql-plan-consumers. Draft until #18 lands.

#18 landed the model fields as declarative stubs; this PR wires every SQL-generation field through its SQLPlanGenerator / RelationshipResolver consumers, per the series convention: nothing stays a model-only no-op. As with #18, everything is adapted to this repo's structure (single-statement views, CTE-mode contract, renderer seam, engine-agnostic SQL executed verbatim on DuckDB and Spark) rather than copied — and several capabilities the fork never actually had (per-branch row_filter on union branches, per-branch window dedup, target-schema branch projection) are new design pinned by a real downstream acceptance shape.

Per-field consumer wiring

base_table_strategy: 'union_branches' (new enum value — see model note below)
The base table plus each union_base_tables entry (falling back to source_tables) becomes one UNION branch inside a single disposition_base view. Unlike the fork's union_base_tables handling (which projects the base table's schema from every branch), each branch projects the target column set through that source's own derivation candidates — required for cutover shapes where sources have different columns:

  • DerivationCandidate.row_filter → the branch WHERE clause (the single distinct value among a branch's candidates; conflicts raise ValueError). This is how generation cutovers are expressed.
  • DerivationCandidate.union_valueCAST(<literal> AS <type>) per branch (source discriminators); native str/int/float/bool typing preserved end-to-end.
  • Columns absent from a branch → CAST(NULL AS <type>), keeping the UNION column-aligned.
  • dedup_strategy: latest + candidate order_by → per-branch ROW_NUMBER() OVER (PARTITION BY <target primary_key> ORDER BY <order_by> DESC NULLS LAST) ... WHERE __rn = 1 (NULLS LAST pinned — DuckDB/Spark default NULL placement diverges).
  • union_typeUNION ALL (default) / UNION.
  • union_exclude_base → per-union-branch NOT EXISTS anti-join on the target primary key against the base branch's post-filter, post-dedup rows (deliberate improvement over the fork's raw-base anti-join). No primary key → ValueError (the fork skipped silently — a correctness foot-gun).
  • union_coalesce_base → 3-part union (base-only / COALESCE(b.c, u.c) overlap with base winning pk, meta, and union_value columns / union-only). Restricted to exactly one union table: the fork's per-table 3-part emission would double-count base rows with several union tables → ValueError.
  • Joins after a union base keep working: join-source columns are projected into every branch (typed NULL where absent), and final assembly short-circuits branch columns to bare base.<col> (the branch already applied the candidate mapping under the target name).

base_table_filter → WHERE on the plain base view and on the union base branch (closing a fork gap — it never applied the filter on union paths). Warns and no-ops under unpivot/union_sources, which don't consume it.

base_join_column → overrides the inferred base join key in both resolver and base view and overwrites source_column on relationships declared outgoing from the base table (fork parity; the field exists precisely when the auto key is wrong and declared rels carry that same wrong key — documented contract).

final_filter / final_dedup → final assembly wraps as SELECT [DISTINCT *] FROM (<assembly>) _final WHERE <filter> so the filter can reference derived aliases; applies to the synthetic (no-base) path too; still one statement → CTE-mode safe.

ForeignKey.join_filter → new resolver pass fills JoinInfo.join_filter from FK metadata only where no candidate-level filter exists (candidate filters are (table, table_instance)-keyed and can disambiguate multi-instance joins, so they win). Emission machinery already existed and was conformance-tested.

OutgoingRelationship.alternative_joins → emitted as a UNION-of-joins, not ON (a = b OR c = d): Spark plans OR-joins as BroadcastNestedLoopJoin. One inner-join branch per path over the distinct base keys (primary = priority 1, alternatives in declared order), UNION + ROW_NUMBER by __branch_priority keeps one match per key, joined back null-safely via the portable (a = b OR (a IS NULL AND b IS NULL)) expansion (no <=>, no * EXCEPT, no engine hints). base_keys scans disposition_base when every key is base-sourced (the fork's documented lazy-view fan-out guard), else the previous step view with a warning. Resolver validates each entry's columns exist (ValueError otherwise). Non-direct strategies warn and use the primary path only.

Model change

UMFMetadata.base_table_strategy narrows from str | None to Literal["union_sources", "unpivot", "union_branches"] | None. Repo-wide only the two existing values were in use. union_base_tables present without the strategy logs a warning and no-ops (not warnings.warn — the repo's filterwarnings = error would hard-fail legacy-shaped fixtures; not a validation error — fork-authored UMFs must still load). Migration for fork specs: add base_table_strategy: union_branches.

Also in this PR

  • Latent join bug fix: base views now project join-source columns (incl. alternative/join_via keys) that survive no derivation candidate — previously the emitted ON base.<key> = ... referenced columns disposition_base never selected.
  • Excel round-trip: Union Value column on the Derivations sheet (native typing, appended header so older workbooks import unchanged); Metadata sheet now JSON-encodes list/dict values (str(list) previously broke union_base_tables — and source_tables — on re-import). F009-DERIV-02 losslessness test extended: a union_branches spec's CTE plan is byte-identical across an Excel round-trip.
  • umf.schema.json synced for exactly the fields this PR consumes (the 8 UMFMetadata fields incl. the new enum, union_value, ForeignKey.join_filter, alternative_joins); the full drift regeneration remains AR-2026-03-16's follow-up.
  • Docs: new docs/guide/sql-plans.md (strategies, filters, dedup, join controls, error philosophy), linked from happy-path §5; excel.md Union Value row; docs/api/generators.md gains generate_sql_plan/SQLPlanGenerator.

Deliberately NOT ported

  • Fork's _rewrite_join_filter hardcoded client-specific rewrites (client_mbr_id → ClientMemberId)
  • Fork's derived-column materialization CTE for alternative joins (no upstream driver yet)
  • The naive OR-in-ON alternative-joins emission (perf hazard the fork itself warns about)
  • Base-schema union projection (superseded by target-schema branch projection)
  • Union-vs-union coalesce overlap semantics (pairwise base-vs-union only, matching the fork)
  • Fork's silent skips on missing primary keys (replaced by plan-time ValueErrors)
  • Next batch (unchanged from feat(models): port production UMF model extensions from pulseflow #18's roadmap): ingestion/upsert consumers — merge_strategy, update_mode, pre/post-upsert rules, effective_primary_key

Acceptance shape

The unit suite pins the real downstream cutover this port unblocks (synaptiq-northstar-idr's silver_fact_inventory_line): two feeds unioned at a DATE '2026-07-20' cutover via complementary row_filters, a source_generation discriminator via union_value, one-sided columns NULL-cast, per-branch ROW_NUMBER PARTITION BY arbit_id, cpt, dos, snapshot_date ORDER BY meta_load_dt DESC — compared via sqlglot normalization, not exact text. The conformance twin (gold_union_branches) executes the same shape end-to-end with a portable DATE filter and matches the committed Spark-oracle golden on both DuckDB and Spark (+ pairwise agreement).

Testing

  • 50 new unit tests (tests/unit/test_sql_plan_consumers.py) covering every field's consumer wiring, every ValueError path, CTE-mode single-statement + both-dialect parseability, and the acceptance fixture; model Literal tests; 2 new Excel round-trip tests
  • New conformance case gold_union_branches (corpus + Spark-generated golden), green on both engine legs
  • Full local run post-change: 3,835 passed, 120 skipped under filterwarnings = error; conformance 226 passed; zero existing goldens changed (everything is opt-in); ruff clean; pyright 0 errors on touched modules

🤖 Generated with Claude Code

dmautz1 and others added 9 commits August 1, 2026 12:52
…el fields

Wire every SQL-generation field PR DocumentDrivenDX#18 landed as a declarative stub through
its generator/resolver consumers: the new base_table_strategy 'union_branches'
(per-source UNION branches projecting the target column set, with per-branch
row_filter, union_value literals, CAST(NULL) alignment, and dedup-latest
windows), base_table_filter / base_join_column / final_filter / final_dedup,
ForeignKey.join_filter (candidate-level wins), and
OutgoingRelationship.alternative_joins as a portable UNION-of-joins.

Also: base views now project join-source columns (latent bug - emitted joins
referenced columns disposition_base never selected), Excel metadata sheet
JSON-encodes list values, umf.schema.json synced for the consumed fields,
gold_union_branches conformance case executed on DuckDB + Spark, and a new
docs/guide/sql-plans.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The final assembly and union_branches column set previously projected
alphabetically by column name. The final projection defines the physical
column order of a CREATE TABLE ... AS target, so emitted order must follow
the spec's declared positions — alphabetical output produces a semantically
different table schema than the spec declares.

_output_ordered_columns(): position order first; columns without a
parseable position sort after all positioned columns, alphabetically among
themselves (deterministic for specs that never set positions — the previous
behavior is unchanged for them since name order was already the contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
source_expression/target_expression on OutgoingRelationship replace the
plain column equality in direct-join ON clauses (e.g. TRIM-keyed registry
joins: ON TRIM(base.npi) = TRIM(target.npi)). Bare column tokens are
qualified via the quote-span rewriter — the base side against the
accumulated base-view columns (new explicit-columns param), the target
side against the joined table's columns. Direct joins only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LookupJoin on OutgoingRelationship (source_key / bridge_table /
bridge_source_key / bridge_target_key) makes a direct join reach its
target THROUGH a bridge when base and target share no direct key
(base.IncidentID -> bronze_incident.FacilityID -> dim_facility.facility_id).
The generator emits base JOIN bridge ON base.source_key =
bridge.bridge_source_key, then JOIN target ON target.<col> =
bridge.bridge_target_key. Direct joins only. Model exported, JSON schema
updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_extract_columns_from_expression parsed with a keyword-filtered regex that
dropped all-caps tokens as keywords — an all-caps COLUMN name (QPA) was
silently omitted from the base view, so a downstream expression referencing
it failed qualification. Now parses the expression with sqlglot and collects
real Column nodes (bare name, alias__ prefix stripped); the regex remains
only as a parse-failure fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_rewrite_expression_for_alias ran the bare-identifier regex over the whole
expression, so a backtick-quoted column (TRIM(`Service`)) got the base
alias inserted INSIDE the backticks — `base.Service`, a literal column that
does not exist. Now splits into code / backtick / string spans (like
_rewrite_join_filter) and qualifies a quoted column as base.`Service`;
string literals are never rewritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pre-aggregation views previously fired only under base_table_strategy=
union_sources. A base-table dim with an aggregate candidate (MAX/MIN/SUM/
COUNT over a source, grouped per key) now also materializes a GROUP-BY view
joined back on the base key. Three parts:
- invoke _generate_pre_aggregation_views in the base_table branch;
- exclude fully-aggregated sources from the regular join sequence
  (_aggregated_source_tables — else the source is joined twice, once
  fanned-out and once aggregated); a mixed agg+plain source stays a join;
- add the candidate row_filter as a WHERE on the plain GROUP-BY view
  (dim_payer's ref_elig is WHERE is_current), and resolve the join-back to
  the base's real key column when the target PK is renamed (base bronze_ins_plan.ID
  vs target ins_plan_id: ON base.ID = agg.ins_plan_id).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dmautz1
dmautz1 force-pushed the feat/port-sql-plan-consumers branch from a142af2 to 000da41 Compare August 31, 2026 17:50
@dmautz1

dmautz1 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto post-#18 main and deferred the SCD2 staged-recompute feature (previously commits 1500e05 + ed0fb1e): no current consumer uses metadata.scd, so it comes back later as its own framed PR when there's a driving use case. Everything else is unchanged in content: expression join keys, two-hop lookup_join, expression-referenced/backtick-qualified column fixes, GROUP-BY aggregate joins, plus the ruff 0.13.3 format pass. Full local gate (format-check, lint, type-check, 3817 tests incl. DuckDB/Spark conformance) passes.

@dmautz1
dmautz1 marked this pull request as ready for review August 31, 2026 18:15
@dmautz1
dmautz1 merged commit b2e1094 into DocumentDrivenDX:main Aug 31, 2026
2 checks passed
dmautz1 added a commit that referenced this pull request Aug 31, 2026
Records the sql-plan capabilities ported from production (PRs #18/#19 and
the composite stack) as source-backed scope evidence on FEAT-019, and
refreshes the registry row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dmautz1
dmautz1 deleted the feat/port-sql-plan-consumers branch August 31, 2026 20:20
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