fix: handle null inside $in / $nin - #39
Merged
Merged
Conversation
A `null` in the array is now a value you can match, as it is in a Feathers/Mongo query, instead of SQL's "never equal to anything". The null is lifted out of the list and compiled into an explicit IS (NOT) NULL: $in: [null, 1] -> (age in (1) or age is null) $in: [null] -> age is null $nin: [null, 2] -> (age not in (2) and age is not null) $nin: [null] -> age is not null Before, a plain `age in (null, 1)` silently skipped every NULL row, and `age not in (null, 2)` was UNKNOWN for *every* row and matched nothing at all — the classic NOT IN trap, and the more dangerous direction when an authorization hook injects an exclusion list. An array without a null still compiles to an untouched IN / NOT IN, so NULL rows stay excluded from `$nin: [1]` (`age <> 1` is unknown for them, which is both standard SQL and what Mongo does). Empty arrays keep their boolean identity: `$in: []` matches nothing, `$nin: []` matches everything — that logic moved into the new helper alongside the null handling. The whole `$in`/`$nin` build is now one choke point (`buildIn`), so the semantics also hold inside a semi-join EXISTS, not just on a plain column. feathers-adapter-vitest 0.2.0 asserts these semantics upstream; the local tests cover what it does not own: repeated nulls, the no-null boundary, composition with $not, the relation path, and the compiled SQL shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
commit: |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
feathers-kysely | f9394f7 | Commit Preview URL Branch Preview URL |
Sep 09 2026, 08:00 AM |
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.
Problem
$in/$ninpassed the array straight into SQLIN/NOT IN, which compares with=/<>. Anullin the list is therefore never equal to anything:{ age: { $in: [null, 1] } }→age in (null, 1)— silently skipped every NULL row{ age: { $nin: [null, 2] } }→age not in (null, 2)— UNKNOWN for every row, matched nothing at allThe second one is the classic
NOT IN (NULL)trap, and it is the more dangerous direction: an authorization hook that injects an exclusion list gets an empty result set rather than a narrowed one. Same category as the$or: []→1 = 0handling already in the adapter.Feathers queries are Mongo-shaped, where a
nullin$inis a real match candidate — so this was a divergence from the query language the adapter implements, not just an SQL quirk.Fix
New
buildInhelper lifts thenullout of the list into an explicitIS (NOT) NULL:$in: [null, 1](age in (1) or age is null)$in: [null]age is null$nin: [null, 2](age not in (2) and age is not null)$nin: [null]age is not null$in: [1, 2]age in (1, 2)— untouched$in: []/$nin: []1 = 0/1 = 1— untouchedAll
$in/$ninbuilding now goes through this one choke point, so the semantics hold inside a semi-joinEXISTStoo, not just on a plain column. The empty-array boolean identities moved into the helper as well, so the three special cases sit together.Standard SQL, no dialect branch — same output on postgres, mysql and sqlite.
Queries that already pass a
nullinside$in/$ninreturn different (correct) results now. Deliberately labeledfix:rather thanfeat!:: the old results were wrong, andfeathers-adapter-vitest@0.2.0asserts the new semantics upstream as the expected adapter behavior.One boundary kept as-is: an array without a
nullstill compiles to an untouchedIN/NOT IN, so NULL rows stay excluded from{ age: { $nin: [1] } }—age <> 1is unknown for them, which is both standard SQL and what Mongo does. Add the null explicitly ($nin: [null, 1]) to include them. Documented and pinned by a test.Tests
feathers-adapter-vitest0.1.0 → 0.2.0 — its new.find + $in + null/.find + $nin + nullcases assert exactly these semantics and run over all four service variants (8 tests). Verified they fail with the fix reverted.src/utils/build-in.ts— 7 inline unit tests pinning the compiled SQL on postgres and sqlite: that[null]collapses to a bare null check rather thanin () or is null, and that repeated nulls produce no surplus parameters.test/query-operators.test.ts— what the shared suite does not own: repeated nulls, the no-null boundary above, and composition with$not($not: { age: { $in: [null, 1] } }→ only the2, i.e. the new OR group stays correctly parenthesized under negation).test/relations.test.ts— the semi-join path:'user.age': { $in: [null, 30] }, its$nincounterpart, andtodos: { $some: { assigneeId: { $in: [null] } } }.836 tests green on sqlite and postgres. MySQL not verified locally (no server on :3306) — the SQL is dialect-free and the unit tests show identical output across the two compilers they cover.
Docs: new section in
docs/api/operators.mdwith the compiled forms.Note:
pnpmadded aminimumReleaseAgeExcludeentry topnpm-workspace.yamlfor the freshly publishedfeathers-adapter-vitest@0.2.0— happy to drop it once the release-age window has passed.🤖 Generated with Claude Code