Push the new LINQ operators down into SQL - #5
Open
davidanthoff wants to merge 1 commit into
Open
davidanthoff wants to merge 1 commit into
davidanthoff wants to merge 1 commit into
Conversation
Teaches the DuckDB backend about the operators added in QueryOperators 1.2,
and fixes two things the new operators made untenable.
Nullability. DuckDB hands back columns typed Union{Missing,T}, but Query's
operators use DataValue for an absent value and never Missing, so the two
backends disagreed the moment a null appeared — which every outer join
guarantees. Rows are now converted on the way out, so piping a DuckDB result
back through query operators, or comparing it against the in-memory backend,
sees the same thing either way. The column interfaces still speak Missing,
because that is what the TableTraits _using_missing protocol is defined in
terms of and what table sinks expect, so DataFrame output is unchanged.
Unsupported operations. build_sql used to fail with a bare "Unsupported query
operation: <type>", which told the user nothing. Each node with no SQL
equivalent now explains the obstacle and suggests a way forward, usually
materializing the query first. This also covers @groupjoin, @mapmany,
@summarize and the pivots, which were already unsupported.
Terminal operators. count, any, all, first, element_at, min_by and max_by
become part of the SQL rather than pulling every row into Julia. Anything not
handled falls through to QueryableBackend's default, which materializes and
runs the in-memory implementation, so correctness never depends on that list
being complete. This is also what makes @count work at all against DuckDB: it
previously had no Queryable method and failed outright.
New SQL: LEFT/RIGHT/FULL OUTER JOIN, UNION ALL/UNION/EXCEPT/INTERSECT and
their DISTINCT ON key-based forms, ORDER BY ALL for @order, ORDER BY random()
for @Shuffle, GROUP BY with COUNT(*) for @count_by, and QUALIFY over
ROW_NUMBER() for @take_last and @drop_last. The last of these takes its row
count from COUNT(*) OVER () rather than a repeated subquery, so the inner
query's positional parameters stay in order.
Two-input nodes now name their right-hand source after the node's position in
the walked tree instead of a hardcoded source_tbl_2, so a query containing
more than one join or set operation no longer has them collide.
Requires QueryableBackend 1.1, which is not registered yet, so CI here stays
red until it is.
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.
Last of four PRs adding the LINQ operators Query.jl was missing. Depends on QueryableBackend.jl#15, which depends on QueryOperators.jl#56; the front-end macros are Query.jl#360.
CI will stay red until QueryableBackend 1.1.0 is registered. The
[compat]bound is an ordinaryQueryableBackend = "1.1"; no feature guards. Locally all 147 test items pass — 24 new, and all 123 pre-existing ones still green.Four separable concerns
1. Nullability — please review this one first
DuckDB hands back columns typed
Union{Missing,T}, but Query's operators useDataValuefor an absent value and neverMissing. The two backends therefore disagreed the moment a null appeared, which every outer join guarantees.src/result.jlnow converts rows on the way out, so piping a DuckDB result back through query operators — or comparing it element-wise against the in-memory backend — sees the same thing either way.The column interfaces still speak
Missing, because that is what the TableTraits_using_missingprotocol is defined in terms of and what sinks likeDataFrameexpect. So|> DataFrameoutput is completely unchanged, which all 123 pre-existing tests confirm.This was the option-(b)-shaped change from the plan, but scoped to the row boundary rather than converting column storage to
DataValueArray— smaller, and it leaves the sink path untouched. Tests cover both directions: rows carryDataValue, DataFrames carrymissing.2. Errors that explain themselves
build_sqlused to fail with a bareUnsupported query operation: <type>. Each node with no SQL equivalent now gets a message naming the operator, the obstacle, and a way forward:This also covers
@groupjoin,@mapmany,@summarizeand the pivots, which were already unsupported and previously gave the bare message.3. Terminal operators
@count,@any,@all,@first,@element_at,@min_byand@max_bybecome part of the SQL instead of pulling every row into Julia. Anything else —@last,@single,@contains,@aggregate,@sequence_equal— falls through to QueryableBackend's default, which materializes and runs the in-memory implementation. Correctness never depends on that list being complete, so the list can grow later without risk.This is also what makes
@countwork against DuckDB at all:QueryOperators.counthad noQueryablemethod before, sodf |> @duckdb() |> @count()failed outright. There's a regression test.4. The SQL itself
LEFT/RIGHT/FULL OUTER JOIN;UNION ALL/UNION/EXCEPT/INTERSECTplusDISTINCT ONforms for the_byvariants;ORDER BY ALLfor@order(detected via the existingis_identity_lambda, which is why@orderneeded no new node type);ORDER BY random()for@shuffle;GROUP BYwithCOUNT(*)for@count_by; andQUALIFY ROW_NUMBER() OVER () … COUNT(*) OVER ()for@take_last/@drop_last.That last one takes its row count from a window function rather than repeating the subquery — repeating it would have desynced the inner query's positional parameters.
Bug fixed along the way: two-input nodes hardcoded
source_tbl_2for their right-hand source, so two joins in one query would have collided. The name is now derived from the node's position in the walked tree, andregister_query_sourceswalks it the same way. A single join still resolves tosource_tbl_2, so nothing changes for existing queries.Known gaps, documented in the README
@shuffle(rng=...)cannot be pushed down — DuckDB has its own generator — and says so.@indexis unsupported: it yields(index, item)pairs whoseitemis a whole row, and SQL has no nested row values.@duckdb()source, as was already true for@join.🤖 Generated with Claude Code