Skip to content

Add the LINQ operators Query.jl is missing - #56

Open
davidanthoff wants to merge 9 commits into
mainfrom
add-missing-linq-operators
Open

davidanthoff wants to merge 9 commits into
mainfrom
add-missing-linq-operators

Conversation

@davidanthoff

Copy link
Copy Markdown
Member

Query.jl's operator set has been frozen roughly at the .NET Framework 3.5 System.Linq.Enumerable surface. This adds the 38 operators it is missing — both the ones .NET has added since (.NET 6 through 11) and older ones that were never ported.

This is the first of four PRs, one per package in the stack. Query.jl (macros + docs), QueryableBackend.jl (Queryable nodes) and QueryDuckDB.jl (SQL push-down) follow once this is registered.

Operators added

Commit Operators .NET origin
Outer joins left_join, right_join, full_join .NET 11
Set operations concat, union, union_by, except, except_by, intersect, intersect_by *By are .NET 6
Ordering order, order_descending, reverse, shuffle, index .NET 7 / 10 / 9
Keyed aggregation count_by, aggregate_by, chunk .NET 9 / 9 / 6
Partitioning take_while, drop_while, take_last, drop_last
Combining append, prepend, zip
Terminal min_by, max_by, any, all, contains, sequence_equal, aggregate, first, last, single, element_at MinBy/MaxBy are .NET 6
Type filtering of_type, cast

One commit per group, so the PR can be reviewed group by group. Sequence, InfiniteSequence, Range, Repeat, Empty, TryGetNonEnumeratedCount and the To* collectors are deliberately skipped — Julia ranges, Iterators.repeated and Query's sinks already cover them. DistinctBy is already unique(f).

Following the convention here, no macros were added to operators.jl — the existing ones are legacy, consumed only by Query/src/query_translation.jl's @from path and this package's own tests, and the three most recent operators (pivot_longer, pivot_wider, summarize) have bare function … end declarations. The user-facing macros will land in the Query.jl PR.

Points worth a look

Nullability is DataValue, never Missing. The unmatched side of an outer join is built by a new _default_value, a generated runtime counterpart of the existing _default_value_expr. Matched elements on a nullable side are converted to the same all-null-capable type, so a single element type covers matched and unmatched rows — the trick EnumerableDefaultIfEmpty already uses. Tests assert the result is DataValue and carries no missing.

except_by/intersect_by deviate from .NET. Enumerable.ExceptBy and IntersectBy take a bare sequence of keys as their second argument, while UnionBy takes a sequence of elements. Here the key selector is applied to both sequences in all three cases. That keeps the three consistent with each other, matches the shape of the equivalent SQL (WHERE k NOT IN (SELECT k FROM b)), and is what a table-shaped second argument makes natural. Happy to switch to strict .NET parity if you'd rather.

index and element_at are 1-based, matching Julia rather than .NET's 0-based Index()/ElementAt.

count_by/aggregate_by name their key columns through the existing _key_namedtuple, so they agree with summarize: a scalar key becomes a column called key, a NamedTuple key contributes one column per field. A test asserts count_by and groupby-plus-summarize produce identical output. summarize remains the idiomatic way to aggregate; aggregate_by is here for LINQ parity.

Defining any in this module captured the Base.any call in enumerable_pivot.jl, which is now qualified. A test covers that regression. union, intersect, reverse, zip, append, first, last, any, all, contains shadow Base, as map, filter, count, take, unique and join already do here; that one call site was the only casualty.

Random is a new dependency, for shuffle. It takes an optional RNG, which makes it reproducible.

Laziness

concat, union, take_while, drop_while, drop_last, append, prepend, zip, index, of_type and cast are lazy, with tests asserting sources are not over-read (e.g. zip pulls at most one element past the end of the shorter source, chunk only walks as far as the batches consumed). reverse, shuffle, order and the keyed aggregations have to collect first.

Testing

134 test items pass, 96 of them new. Each operator covers empty sources, empty second sequences, duplicate keys, out-of-range counts, eltype, IteratorSize/length, and a case applied downstream of @groupby so Grouping elements are exercised.

🤖 Generated with Claude Code

davidanthoff and others added 9 commits September 16, 2026 22:04
Ports Enumerable.LeftJoin, Enumerable.RightJoin and Enumerable.FullJoin,
which .NET 11 added to System.Linq.

The unmatched side of an outer join needs an element to pair with. That
element is built by the new `_default_value`, a generated runtime
counterpart of the existing `_default_value_expr`, so absent values are
DataValue and never Missing. Matched elements on a nullable side are
converted to the same all-null-capable type, which keeps a single element
type across matched and unmatched rows — the same trick
EnumerableDefaultIfEmpty already uses.

full_join emits every outer row in source order first, then the inner rows
whose key never appeared on the outer side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports Enumerable.Concat/Union/UnionBy/Except/ExceptBy/Intersect/IntersectBy.
union, except and intersect shadow their Base counterparts, as map, filter,
count, take, unique and join already do here.

concat is fully lazy and reports a length when both sources have one. union
is lazy over both sources behind a growing set of seen keys; except and
intersect materialise the second source's keys on first iterate and then walk
the first source lazily. All three de-duplicate their output, matching .NET.

One deliberate deviation: Enumerable.ExceptBy and IntersectBy take a bare
sequence of keys as their second argument, while UnionBy takes a sequence of
elements. Here the key selector is applied to both sequences in all three
cases. That keeps the operators consistent with each other, matches the shape
of the equivalent SQL, and is what a table-shaped second argument makes
natural.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports Enumerable.Order and OrderDescending (.NET 7), Enumerable.Shuffle
(.NET 10) and Enumerable.Index (.NET 9), plus Enumerable.Reverse.

order and order_descending are expressed in terms of orderby with an identity
key selector, so they produce an EnumerableOrderby and thenby can still follow
them.

index yields (index, item) NamedTuples with 1-based indices, matching Julia
rather than .NET's 0-based Index(). It is lazy; reverse and shuffle have to
collect their source first.

shuffle takes an optional RNG, which makes it reproducible; without one it
uses the default RNG and, as in .NET, is not cryptographically secure. This
adds Random to the dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports Enumerable.CountBy and AggregateBy (.NET 9) and Enumerable.Chunk
(.NET 6).

count_by and aggregate_by name their key columns through the existing
_key_namedtuple, so they agree with summarize: a scalar key becomes a column
called `key`, a NamedTuple key contributes one column per field. A test
asserts count_by and groupby-plus-summarize produce identical output.

summarize remains the more general and more idiomatic way to aggregate;
aggregate_by is here for LINQ parity, and its accumulator takes
(accumulated, element) to match .NET's argument order.

chunk pulls at most n elements per call, so a chunked source is only walked
as far as the batches actually consumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports Enumerable.TakeWhile, SkipWhile, TakeLast and SkipLast. The Skip
operators are named drop_* to match the existing `drop` rather than LINQ's
`Skip`.

take_while and drop_while are lazy: take_while stops at the first element
that fails the predicate and never examines the rest, and drop_while passes
everything through untested once the leading run is gone.

take_last holds at most n elements in a CircularBuffer while walking the
source. drop_last stays n elements behind the source, emitting an element
only once n further ones have been read.

A count of zero or less yields nothing for take_last and leaves the source
unchanged for drop_last, matching .NET.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports Enumerable.Append, Prepend and Zip. append and zip shadow their Base
counterparts.

append and prepend convert the extra element to the source's element type, so
appending an Int to a sequence of Float64 works rather than erroring.

zip stops at the shorter of its two sources, matching both Base.zip and .NET.
Nothing is padded, so zip manufactures no null values — a test asserts the
result contains neither missing nor DataValue. Without a result selector it
yields Tuples, as Base.zip and Enumerable.Zip both do.

All three are lazy, and zip pulls at most one element past the end of the
shorter source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports Enumerable.MinBy and MaxBy (.NET 6), Any, All, Contains,
SequenceEqual, Aggregate, First, Last, Single and ElementAt. These return a
value rather than another Enumerable, so a query ending in one is executed
immediately — `count` was already the sole member of that family here.

min_by and max_by return the element, not the key, and keep the first element
on ties as .NET does. contains and sequence_equal compare with isequal, so
DataValue nulls match each other, consistent with unique and the set
operators. element_at is 1-based, matching the new `index` operator and the
rest of Julia rather than .NET's 0-based ElementAt.

summarize remains the idiomatic way to aggregate a table; aggregate is here
for LINQ parity.

Defining `any` in this module captured the Base.any call in
enumerable_pivot.jl, which is now qualified. A test covers that regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports Enumerable.OfType and Cast.

of_type keeps the elements that are instances of T and narrows the element
type to T, which is useful for sources whose element type is Any or a Union
and a no-op filter on a homogeneously typed table.

.NET's Cast is a type assertion; the Julia counterpart is a conversion, so
cast runs every element through convert and fails the way convert would on an
element that cannot be represented as T.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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