Milestone 24: DISTINCT and IN, and two NULLs that disagree - #38
Merged
Conversation
The two things a visitor types first and could not do. Unrelated in the engine, shipped together because each is a place SQL's equality is not the one a programmer brings to it, and the two disagree with each other: - DISTINCT treats two NULLs as the SAME, so one row survives, even though NULL = NULL is unknown everywhere else in the language - NOT IN treats a NULL in its list as poison, so x NOT IN (1, NULL) is never TRUE, however unrelated x is to either value `x IN (a, b)` IS `x = a OR x = b`, exactly, and every surprise follows from that rather than being coded. It stays an InList node rather than being desugared at parse time: the desugaring is correct, and the AST view is meant to show the query somebody wrote, so an error span pointing at an OR nobody typed costs more than one evaluator case. DISTINCT is a hash, not a sort that drops equal neighbours. A sort is blocking, so `SELECT DISTINCT c FROM t LIMIT 2` would read the whole table; this emits the first row immediately and the LIMIT above it really does stop the scan early. It holds a set of distinct rows instead of a buffer, which is the right way round for a LIMIT. distinct_key carries each value's type, because Python hashes True == 1 and 1 == 1.0 alike and a plain set of row tuples would fold a BOOLEAN row into an INTEGER one and lose it. -0.0 and 0.0 deliberately collide: they are equal in SQL, so emitting both would be a duplicate. Two integrations came almost free and one did not. IN inherited Milestone 20's most-common-values list, so on a column whose list is complete the estimate is exact rather than a third of the table. But Milestone 19's null-rejection analysis returns ANY for a node it does not recognise, which is safe and would have quietly stopped rewriting outer joins, so it had to be taught InList explicitly. Four guards fired, all four naming these as unimplemented. The demo catalogue's "not implemented yet" slot has now had four occupants: ORDER BY until Milestone 13, LEFT JOIN until 18, DISTINCT until this one, LIKE now. Three for three on catching its own staleness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
The two things a visitor types first and could not do:
Unrelated in the engine, shipped together because each is a place SQL's equality
is not the one a programmer brings to it — and the two disagree with each
other:
DISTINCTtreats two NULLs as the same, so one row survives, even thoughNULL = NULLis unknown everywhere else in the language.NOT INtreats a NULL in its list as poison, sox NOT IN (1, NULL)isnever TRUE, however unrelated
xis to either value.INis a union of equalities, exactlyx IN (a, b)isx = a OR x = b. Not approximately — that equivalence isthe definition, and every surprise follows from it rather than being coded:
NULL IN (1, 2)2 IN (2, NULL)TRUE OR unknownis TRUEx NOT IN (1, NULL)x <> 1 AND x <> NULLIt stays an
InListnode rather than being desugared at parse time. Thedesugaring is exact; the AST view is meant to show the query somebody wrote,
and an error span pointing at an
ORnobody typed costs a reader more than thenode costs the evaluator, which is one
case.IN (SELECT …)is refused by name — it's a semi-join, and evaluating it as alist would materialise the subquery per row.
DISTINCTstreamsThe lazy version is a sort that drops equal neighbours. Correct, needs no new
operator, and wrong here for one reason: a sort is blocking, so
SELECT DISTINCT city FROM t LIMIT 2would read the whole table. Hashinginstead emits the first row immediately and the
LIMITreally does stop thescan early. What it holds instead of a buffer is a set of distinct rows — the
right trade for a
LIMIT.distinct_keycarries each value's type, because Python hashesTrue == 1and
1 == 1.0alike and a plainsetof row tuples would fold a BOOLEAN rowinto an INTEGER one and lose it.
-0.0and0.0deliberately collide: they'reequal in SQL, so emitting both would be a duplicate.
One integration was free, one was not
INinherited Milestone 20's most-common-values list for nothing — a union ofequalities is a sum of their selectivities, so on a column whose list is
complete the estimate is a count:
city IN ('london', 'ny')age IN (7)But Milestone 19's null-rejection analysis returns "could be anything" for a node
it doesn't recognise — safe, and it would have quietly stopped rewriting
outer joins. It had to be taught
InListexplicitly, four lines and a test thatfails without them.
Verification
320,000 generated query pairs agree with SQLite, with
DISTINCTon ~20% ofplain selects and an
INlist in ~18% of predicates, a quarter of those with aNULL in the list on purpose. Nineteen hand-written cases, weighted at the NULL
rules.
Five guards fired, all naming these as unimplemented. The fifth was found by
CI rather than by looking:
examples/milestone2_parser.pykeeps a list ofdeliberately-failing statements, and its own comment says this has happened
twice before.
That demo slot has now had four occupants:
ORDER BYuntil Milestone 13,LEFT JOINuntil 18,DISTINCTuntil this one,LIKEnow. An example of whatan engine cannot do is a claim with a shelf life.
make cigreen. 1,736 Python tests, 160 frontend.🤖 Generated with Claude Code