Skip to content

fix!: negated operators, $not and $every match NULL rows - #46

Merged
fratzinger merged 2 commits into
mainfrom
fix/null-negation
Oct 22, 2026
Merged

fratzinger merged 2 commits into
mainfrom
fix/null-negation

Conversation

@fratzinger

Copy link
Copy Markdown
Owner

Summary

A NULL is not 25, yet SQL evaluates age != 25 to UNKNOWN and drops the row. Feathers queries are Mongo-shaped, where every negation is the exact complement of its positive twin, and feathers-adapter-vitest 0.4.1 now tests $ne / $nin against NULL rows.

 # $ne, $nin, $notLike, $notILike, $notBetween, $notRegex
-"age" != $1
+("age" != $1 or "age" is null)
-"age" not in ($1, $2)
+("age" not in ($1, $2) or "age" is null)
 # $not, and the inner negation of $every
-not ("age" > $1)
+("age" > $1) is not true
 # $not over a relation path: EXISTS is never UNKNOWN, stays an anti-join
 not exists (select 1 from …)

Unchanged: $ne: null → is not null, $nin: [null, 2] → (not in (2) and is not null).

Also a dependency update (feathers-adapter-vitest 0.4.1, kysely 0.29.7, vite 8.3.5, …; TypeScript stays on 6), and minimumReleaseAgeExclude keeps only the one version still inside the release-age window.

Evidence

  • Before: feathers-adapter-vitest 0.4.1, .find + $ne matches null / .find + $nin matches null fail on all 4 service variants (2 !== 3).
    After: pass.
  • Before: { age: { $gt: 1 } } and { $not: { age: { $gt: 1 } } } both miss the NULL row; $every: { assigneeId: { $gt: 0 } } accepts a user whose only todo has a NULL assignee, while $some with the same filter rejects it.
    After: new tests: $not partitions the rows for 10 conditions, negated operators match NULL rows (query-operators.test.ts), $some / $none with $ne / $nin and $every vs $some on a NULL child (relations.test.ts). All four fail with the fix reverted. 1 sql-shape snapshot updated (hasMany $every).
  • PostgreSQL 952 ✓, SQLite 801 ✓. MySQL not run locally, so this relies on CI.

Performance, local PostgreSQL 18, 1M users / 3M todos, 10% NULL, median of 7:

query before after
$ne / $nin / $notLike, count 30 ms, seq scan 30 ms, seq scan
$ne + indexed group_id = 7 0.3 ms, bitmap index 0.3 ms, bitmap index
$notBetween, NOT NULL column 4.8 ms, BitmapOr 4.7 ms, BitmapOr (IS NULL arm is indexable)
$every, count hash anti-join hash anti-join, within noise
$not: { x: { $gt: 3 } }, NOT NULL indexed column, same rows 2.3 ms, index-only scan 39.8 ms, seq scan

Elsewhere a query only gets slower by the NULL rows it now returns ($notBetween on a nullable column: 3.8 → 10.1 ms for 10k → 110k rows).

Known regression: (cond) IS NOT TRUE hides cond from indexes, so $not over a selective indexed range loses its index. Pushing the negation down to each comparison (not (x > 3) or x is null) would stay indexable; that would be a follow-up.

Merge Danger

Door: two-way

A revert restores the old SQL, but consumers will start to rely on the new results once released.

Blast Radius: query results

$ne, $nin, $notLike, $notILike, $notBetween, $notRegex, $not and $every now return rows whose column is NULL. A hook that excludes rows with $not / $ne gets more rows back. Breaking, so it needs a 0.15.0 and a changelog note.

Conflicts with #45 in src/utils/build-in.ts (its Postgres <> all($1) path), docs/api/operators.md and pnpm-lock.yaml.

🤖 Generated with Claude Code

fratzinger and others added 2 commits October 22, 2026 11:52
feathers-adapter-vitest 0.4.1 adds the `$ne` / `$nin` "matches null" cases,
which fail until the next commit. TypeScript stays on 6 (7 is a major).
minimumReleaseAgeExclude keeps only the one version still younger than the
release-age window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A NULL is not 25, yet SQL evaluates `age != 25` to UNKNOWN and drops the
row. Feathers queries are Mongo-shaped, where every negation is the exact
complement of its positive twin, so:

- `$ne`, `$nin`, `$notLike`, `$notILike`, `$notBetween` and `$notRegex`
  OR an `IS NULL` next to the comparison. `$ne: null` and a `$nin` that
  lists `null` keep excluding NULL rows.
- `$not` and the inner negation of `$every` use `(cond) IS NOT TRUE`
  instead of `NOT (cond)`, so a row the condition is UNKNOWN for matches
  the negation. An EXISTS is never UNKNOWN, so `$not` over a relation path
  stays a plain `NOT EXISTS`, which the planner can turn into an anti-join.

BREAKING CHANGE: these operators now return rows whose column is NULL.
Add `$ne: null` (or `null` to a `$nin` list) to exclude them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Oct 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ab3ef80

@pkg-pr-new

pkg-pr-new Bot commented Oct 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ab3ef80

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 22, 2026 •

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 ab3ef80 Commit Preview URL

Branch Preview URL
Oct 22 2026, 09:56 AM

@fratzinger
fratzinger merged commit 488a773 into main Oct 22, 2026
37 checks passed
@fratzinger
fratzinger deleted the fix/null-negation branch October 22, 2026 09:58
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