Skip to content

Push the new LINQ operators down into SQL - #5

Open
davidanthoff wants to merge 1 commit into
mainfrom
add-missing-linq-operators
Open

davidanthoff wants to merge 1 commit into
mainfrom
add-missing-linq-operators

Conversation

@davidanthoff

Copy link
Copy Markdown
Member

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 ordinary QueryableBackend = "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 use DataValue for an absent value and never Missing. The two backends therefore disagreed the moment a null appeared, which every outer join guarantees. src/result.jl now 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_missing protocol is defined in terms of and what sinks like DataFrame expect. So |> DataFrame output 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 carry DataValue, DataFrames carry missing.

2. Errors that explain themselves

build_sql used to fail with a bare Unsupported query operation: <type>. Each node with no SQL equivalent now gets a message naming the operator, the obstacle, and a way forward:

@chunk has no SQL equivalent — SQL has no batching construct. Materialize the query first, e.g. … |> DataFrame |> @chunk(3).

This also covers @groupjoin, @mapmany, @summarize and the pivots, which were already unsupported and previously gave the bare message.

3. Terminal operators

@count, @any, @all, @first, @element_at, @min_by and @max_by become 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 @count work against DuckDB at all: QueryOperators.count had no Queryable method before, so df |> @duckdb() |> @count() failed outright. There's a regression test.

4. The SQL itself

LEFT/RIGHT/FULL OUTER JOIN; UNION ALL/UNION/EXCEPT/INTERSECT plus DISTINCT ON forms for the _by variants; ORDER BY ALL for @order (detected via the existing is_identity_lambda, which is why @order needed no new node type); ORDER BY random() for @shuffle; GROUP BY with COUNT(*) for @count_by; and QUALIFY 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_2 for 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, and register_query_sources walks it the same way. A single join still resolves to source_tbl_2, so nothing changes for existing queries.

Known gaps, documented in the README

  • Set operations return rows in whatever order DuckDB produces; the in-memory implementation preserves first-seen order. Tests compare sorted.
  • @shuffle(rng=...) cannot be pushed down — DuckDB has its own generator — and says so.
  • @index is unsupported: it yields (index, item) pairs whose item is a whole row, and SQL has no nested row values.
  • The second operand of a join or set operation must itself be a @duckdb() source, as was already true for @join.

🤖 Generated with Claude Code

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