Skip to content

fix: handle null inside $in / $nin - #39

Merged
fratzinger merged 1 commit into
mainfrom
fix/in-nin-null
Sep 9, 2026
Merged

fratzinger merged 1 commit into
mainfrom
fix/in-nin-null

Conversation

@fratzinger

Copy link
Copy Markdown
Owner

Problem

$in / $nin passed the array straight into SQL IN / NOT IN, which compares with = / <>. A null in 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 all

The 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 = 0 handling already in the adapter.

Feathers queries are Mongo-shaped, where a null in $in is a real match candidate — so this was a divergence from the query language the adapter implements, not just an SQL quirk.

Fix

New buildIn helper lifts the null out of the list into an explicit IS (NOT) NULL:

Query Compiled SQL
$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 — untouched

All $in/$nin building now goes through this one choke point, so the semantics hold inside a semi-join EXISTS too, 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.

⚠️ Behavior change

Queries that already pass a null inside $in / $nin return different (correct) results now. Deliberately labeled fix: rather than feat!:: the old results were wrong, and feathers-adapter-vitest@0.2.0 asserts the new semantics upstream as the expected adapter behavior.

One boundary kept as-is: an array without a null still compiles to an untouched IN / NOT IN, so NULL rows stay excluded from { age: { $nin: [1] } } — age <> 1 is 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-vitest 0.1.0 → 0.2.0 — its new .find + $in + null / .find + $nin + null cases 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 than in () 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 the 2, 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 $nin counterpart, and todos: { $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.md with the compiled forms.

Note: pnpm added a minimumReleaseAgeExclude entry to pnpm-workspace.yaml for the freshly published feathers-adapter-vitest@0.2.0 — happy to drop it once the release-age window has passed.

🤖 Generated with Claude Code

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>
@pkg-pr-new

pkg-pr-new Bot commented Sep 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@fratzinger/feathers-kysely@39

commit: f9394f7

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@fratzinger
fratzinger merged commit b327f5d into main Sep 9, 2026
36 checks passed
@fratzinger
fratzinger deleted the fix/in-nin-null branch September 9, 2026 08:03
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