Support LEFT/RIGHT JOIN in the DuckDB IEJoin dialect by decomposing the outer join into INNER pairs plus unmatched rows — Closes #95 - #223
Draft
conradbzura wants to merge 4 commits into
Conversation
The dialect emitted a multi-statement script and any rewrite composing on top of it recovered the pieces with a string split on the statement separator. That is unsound: an identifier containing that sequence splits the script inside a quoted alias and the query no longer parses. The count_overlaps zero-fill path has carried this latent defect since it shipped. Hand back the setup statements and the final SELECT as separate values instead, and let the public entry point join them. Callers that compose a further rewrite now consume the parts directly, so no rendered SQL is ever re-parsed to recover its structure.
An outer join carrying a column-to-column INTERSECTS declined to the naive overlap predicate, which DuckDB plans as a hash join on a low-cardinality chromosome key with the position inequalities as a residual filter. That is quadratic and does not finish at a million intervals per side. A LEFT JOIN is exactly the INNER pairs unioned with the row-preserving side's unmatched rows, NULL-filled on the other side. Both halves already had fast paths, so emit them and union the result rather than emitting an outer join and hoping the planner chooses well. RIGHT is the mirror, reached by swapping the FROM and joined tables. The halves partition chromosomes differently on purpose: the matched half intersects both sides, while the unmatched half enumerates the preserved side alone so rows on a chromosome the other table lacks still surface. Emitting a per-chromosome LEFT JOIN instead was measured and rejected. It is faster below roughly a hundred thousand rows and then collapses, failing to finish within 300s at four million per side where the decomposition returns in two. EXPLAIN reports IE_JOIN for that plan throughout, so plan inspection does not distinguish them and only execution at scale does. Preserved-side rows whose chromosome is NULL are unioned in directly. They can never match, and neither half can surface them: both partitions come from SELECT DISTINCT over the chromosome, where a NULL renders as a NULL literal that string_agg skips, so no branch is ever emitted for it. The clause gate is a whitelist rather than a blocklist. The rewrite re-emits the query as a union of two independently transpiled halves, so any clause it does not itself read would be applied per half instead of over the union. Enumerating clauses to reject would leave the rewrite exposed to every clause the parser grows later.
A seven-agent review of the decomposition found five defects that the existing suite passed straight through, so this adds coverage for each and strengthens assertions that read stronger than they verified. New coverage locks the shapes that regressed: preserved output names colliding only by letter case, which DuckDB resolves case-insensitively and would bind to the wrong column; preserved rows with a NULL chromosome, whose loss defeats the guarantee an outer join exists to provide; top-level clauses the rewrite does not read; a sampled operand; and an identifier containing the statement separator. Each was confirmed to fail against the unfixed code before being added. Several existing assertions were weaker than their names claimed. The scale test matched every left row, so the unmatched half contributed nothing and could have been deleted wholesale without failing; its data now leaves a share of rows unmatched and it asserts distinct left keys rather than a row count any single half satisfies. The RIGHT test proved two halves fired but nothing about the swap that distinguishes RIGHT from LEFT. The four reference comparisons passed identically whether or not the optimization fired, since the naive plan returns the same rows. The property strategy excluded zero-length intervals and NULL chromosomes by construction despite the docstring claiming edge cases. Widening it exposed that the Python reference treated two NULL chromosomes as equal, where SQL says NULL never equals anything, so the reference was wrong rather than the plan. The EXPLAIN helper matched the first session variable only, silently planning the matched half and reporting nothing about the other. It now selects a half, which lets a test assert both reach IE_JOIN.
The dialect now accelerates LEFT and RIGHT outer joins, but the public transpile docstring still listed them among the shapes it declines, telling users the opposite of what the code does. The README, the spatial-operators page, and the performance guide carried the same INNER/SEMI/ANTI-only claim. Describe the two-half decomposition, why a per-chromosome outer join is not a viable alternative despite EXPLAIN reporting the fast operator for it, and which shapes still fall through. Note that FULL OUTER keeps declining. Correct two execution details the decomposition invalidates: the emitted script is no longer always two statements, and a decomposed query declares one session variable per half rather than one per call. The count_overlaps motivation said a plain LEFT JOIN would decline to the naive predicate, which contradicted the outer-join section a few lines below it.
conradbzura
force-pushed
the
95-decompose-outer-join-intersects
branch
from
August 24, 2026 13:34
a300f35 to
ee6ab6e
Compare
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.
Summary
Accelerate
LEFT/RIGHT JOINon a column-to-columnINTERSECTSby decomposing the outer join rather than emitting one. Both halves reach DuckDB'sIE_JOIN, where the shape previously declined to the naive overlap predicate — a hash join on a 24-value chromosome key with the position inequalities as a residual filter, quadratic and unable to finish at a million intervals per side. At 2^20 the decomposition returns 1,334,564 rows in 0.78s; at 2^22 it returns 7,758,528 rows in 2.0s, scaling linearly.A
LEFT JOINis exactly the INNER pairs unioned with the row-preserving side's unmatched rows, NULL-filled on the other side. Both halves already had fast paths, so this emits them and unions the result instead of hoping the planner chooses well for an outer join.RIGHTis the mirror, reached by swapping the FROM and joined tables.The issue originally prescribed emitting a per-chromosome
LEFT JOIN. That design was benchmarked and rejected: it is faster below ~1e5 rows and then collapses, failing to finish within 300s at 2^22.EXPLAINreportsIE_JOINfor it throughout, so plan inspection cannot distinguish the two designs and only execution at scale does — which is why the test suite includes a scale test rather than relying on plan assertions.The trade-off is two passes over the data, making the decomposition roughly 2x slower than a per-chromosome outer join at toy sizes. Predictable linear scaling is worth that.
Two rounds of independent review shaped what landed. The first found five defects a green suite passed straight through, each a shape that previously declined to the naive plan and answered correctly. The second found one behavioral defect — the rewrite renamed duplicate output columns — plus a regression lock that never reached the gate it was written to protect: deleting that gate left all 2131 tests passing while the dialect answered a semi join as an outer join. Roughly 1,900 randomized differential cases across seven independent fuzz runs found no row or schema divergence, so the correctness work here is in the gates and the tests rather than in the plan.
bedtools intersect -waoremains on the naive plan: itsCASEprojection is blocked by the projection gate independently of the outer join, and unblocks with #109.Closes #95
Closes #226
Proposed changes
Return the IEJoin setup and SELECT separately
The dialect emitted a multi-statement script, and any rewrite composing on top recovered the pieces by splitting the rendered string on the statement separator. An identifier containing that sequence splits the script inside a quoted alias and the query no longer parses.
_build_sqlnow returns(setup, select),transform_to_sqlis a thin joiner, and composing builders consume the parts directly. This also repairs the same latent defect in the shippedcount_overlapspath.Decompose the outer join
_match_outer_join_decompositionclaims LEFT/RIGHT shapes whose projections are side-attributable columns;_build_outer_join_partsemits the matched half, the unmatched half, and the union. The halves partition chromosomes differently by design — the matched half intersects both sides, the unmatched half enumerates the preserved side alone — which is what lets rows on a chromosome the other table lacks still surface.Dispatch sits after the
count_overlapsmatcher, which keeps its faster zero-fill path, and before the existing outer-join decline, which still catchesFULL OUTERand theWHERE-INTERSECTS shape.Preserve rows whose chromosome is NULL
Such rows can never match, and neither half surfaces them on its own: both partitions come from
SELECT DISTINCTover the chromosome, where a NULL renders as a NULL literal thatstring_aggskips, so no branch is emitted for it. They are unioned in directly. The root cause is shared with the standaloneANTIpath, which has dropped these rows since #208 and is filed separately; what this fixes isLEFT/RIGHTinheriting it instead of declining safely.Preserve duplicate output column names
The matched half is a
UNION ALLbranch directly rather than a derived table.SELECT *over a subquery makes DuckDB de-duplicate repeated output names, and that renaming becomes the union's schema, so the canonical bedtools projection —a.chrom, a.start, a.end, b.chrom, b.start, b.end— came back aschrom, start, end, chrom_1, start_1, end_1underdialect="duckdb"and unchanged under every other dialect. A flag documented as a performance opt-in must not alter the result schema.Gate on a whitelist rather than a blocklist
The rewrite re-emits the query as a union of two independently transpiled halves, so any top-level clause it does not itself read would be applied per half instead of over the union —
LIMIT 2returning four rows,QUALIFYsilently dropped. Rejecting anything outside the set the builder consumes forecloses the class rather than the instance.TABLESAMPLEis rejected separately since it rides on the table node, not the top-level SELECT.Decline case-insensitively colliding output names
DuckDB resolves identifiers case-insensitively even when quoted, so
AS xalongsideAS Xbound both positions to the first column, returning the wrong value and widening that column toVARCHARfor the matched rows too. The uniqueness gate now case-folds through_normalize_alias.Share one gate prelude between both matchers
_match_count_overlapsand_match_outer_join_decompositionopened with near-identical preludes differing only in the accepted join side._resolve_intersects_joinnow performs those checks once and returns the resolved join. The duplication had a concrete cost: the untested-gate defect below existed in both copies, so it had to be found twice.Mark the bedtools integration modules
pytestdoes not honourpytestmarkdeclared in a conftest, so the entire bedtools oracle lane was invisible to marker-based selection —pytest -m integrationcollected 250 of 333 tests and-m "not integration"ran the binary-dependent lane it exists to skip. Each module now declares the marker, matching the datafusion lane. Collection is 335 of 335.Correct stale documentation
The public
transpiledocstring listed LEFT/RIGHT among the shapes the dialect declines. The README, spatial-operators page, and performance guide carried the same INNER/SEMI/ANTI-only claim, as did the canonical grammar line on the page describing the feature, the registry entry point's decline list, and the naive-predicate module documenting when it is the fallback.The performance guide now documents the third
UNION ALLbranch, which is load-bearing correctness the previous text omitted, and completes the decline list with self-joins,TABLESAMPLE, and case-only name collisions. Both documented-lojrecipes project a star and therefore decline, so the migration guide says so rather than leaving users to conclude the flag does nothing.Two execution details also changed: the emitted script is no longer always two statements, and a decomposed query declares one session variable per half.
Test cases
TestTranspileDuckDBIEJoinOuterJoinDecompositiondialect="duckdb"and executedTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinSQLStructureTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositionQUALIFY,LIMIT,OFFSET,GROUP BY, orORDER BYTestTranspileDuckDBIEJoinOuterJoinDecompositionTABLESAMPLEon the FROM table or on the joined tableTestTranspileDuckDBIEJoinOuterJoinDecompositioncount_overlapsqueries runTestTranspileDuckDBIEJoinOuterJoinDecompositionWHERE, a star, a self-join, an ON residual, a repeated INTERSECTS, or a subquery operandTestTranspileDuckDBIEJoinOuterJoinDecompositionLEFT SEMIorLEFT ANTIjoin, which parses withside='LEFT'and reaches the kind gateTestTranspileDuckDBIEJoinOuterJoinDecompositionSEMIorANTIjoin, which parses with no side and is rejected a gate earlierTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositionIE_JOINand neither throughBLOCKWISE_NL_JOINTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositionTestTranspileDuckDBIEJoinOuterJoinDecompositiontests/integration/bedtools/test_intersect.pybedtools intersect -lojtests/integration/bedtools/test_intersect.pybedtools intersect -lojtests/integration/bedtools/test_intersect.pybedtools intersect -lojtests/integration/bedtools/test_intersect_property.pydialect="duckdb"bedtools -lojtests/integration/datafusion/test_cross_target_oracle.py