Skip to content

feat: add $nin filter operator for exclusion queries - #12

Merged
harlan-zw merged 3 commits into
mainfrom
feat/nin-filter-operator
Mar 23, 2026
Merged

harlan-zw merged 3 commits into
mainfrom
feat/nin-filter-operator

Conversation

@oritwoen

@oritwoen oritwoen commented Mar 23, 2026

Copy link
Copy Markdown
Collaborator

Description

$in has been there from the start, but the complementary $nin (not in) was missing. Excluding a set of values is common enough that having to negate $in manually felt off, so this adds $nin as a first-class filter operator.

Covers SQL compilation for both SQLite (json_extract) and PostgreSQL (->>), in-memory matchesFilter, edge case for empty arrays ($nin: [] matches everything, mirrors $in: [] matching nothing), and an integration test against sqlite-fts.

Linked Issues

Additional context

Follows the same pattern as $in exactly. Empty $nin: [] compiles to 1 = 1 (always true), which is the logical complement of $in: [] compiling to 1 = 0.

Summary by CodeRabbit

  • New Features
    • Added a negated membership filter operator to exclude metadata values across all search modes; missing values are treated as non-matches and empty exclusion lists act as no-op (match all).
  • Tests
    • Added unit and integration tests validating SQL generation, runtime matching, empty-array behavior, numeric handling, and full-search integration for the new operator.

@coderabbitai

coderabbitai Bot commented Mar 23, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds $nin (negated membership) operator support to types, SQL compilation, and in-memory matching; empty $nin arrays compile to a tautology (1 = 1) and match everything, and missing/null metadata preserve SQL-like NULL semantics.

Changes

Cohort / File(s) Summary
Type Definition
src/types.ts
Added { $nin: (string | number)[] } to FilterOperator.
Filter Implementation
src/filter.ts
Implemented $nin in compileOp (emits NOT IN (?,...), empty array -> 1 = 1) and matchOp (returns true for non-null actual not included in $nin, empty $nin -> true).
Tests
test/filter.test.ts
Added type-level assertions, SQL compilation tests for json/jsonb (including empty-array case), in-memory matching tests (string/number, empty array, missing field semantics), and SQLite FTS integration test for $nin.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested reviewers

  • harlan-zw

Poem

🐇 I hop through fields both far and near,
I nudge out values I do not cheer,
With $nin I say "not in this bunch",
I skip the ones that make me hunch,
A carrot-coded cheer — hop, search, and munch!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding a new $nin filter operator for exclusion queries, which is the primary focus of the changeset across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/nin-filter-operator

Comment @coderabbitai help to get the list of available commands and usage tips.

@oritwoen oritwoen self-assigned this Mar 23, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/filter.ts">

<violation number="1" location="src/filter.ts:126">
P1: In-memory `$nin` returns `true` for missing/null fields, but the SQL `NOT IN` returns `NULL` (falsy). This causes Upstash/Cloudflare drivers to include records that SQLite/PG drivers would exclude when the filtered field doesn't exist.

Add a guard to align with SQL semantics, similar to the numeric operators (`$gt`, `$lt`, etc.) which check `typeof actual`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread src/filter.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/filter.test.ts (1)

352-362: Good coverage of $nin behavior.

Tests cover the essential cases. Consider adding a test for missing field behavior to document the expected semantics:

it('$nin matches when field is missing', () => {
  expect(matchesFilter({ x: { $nin: ['a', 'b'] } }, { other: 1 })).toBe(true)
})

This would clarify that missing fields pass $nin filters (since undefined is not in the exclusion list).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/filter.test.ts` around lines 352 - 362, Add a unit test in
test/filter.test.ts that asserts matchesFilter handles missing fields for $nin
correctly: create a new it block (e.g., it('$nin matches when field is
missing')) that calls matchesFilter({ x: { $nin: ['a', 'b'] } }, { other: 1 })
and expects true to document that undefined fields are not considered in the
exclusion list; place it alongside the existing $nin tests near the
matchesFilter tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/filter.ts`:
- Around line 62-67: The $nin SQL generation currently produces "ref NOT IN
(...)" which excludes rows where ref IS NULL, causing inconsistency with the
in-memory matchOp (which treats missing/undefined as included); update the
op.$nin branch in the filter generator so that when op.$nin is non-empty it
returns SQL that treats NULL as included — i.e., emit "(<ref> NOT IN (?, ...) OR
<ref> IS NULL)" with params copied from op.$nin (keep the existing empty-array
short-circuit returning "1 = 1"). Target the code handling op.$nin (the op.$nin
map/placeholder logic and returned sql/params) to make this change.

---

Nitpick comments:
In `@test/filter.test.ts`:
- Around line 352-362: Add a unit test in test/filter.test.ts that asserts
matchesFilter handles missing fields for $nin correctly: create a new it block
(e.g., it('$nin matches when field is missing')) that calls matchesFilter({ x: {
$nin: ['a', 'b'] } }, { other: 1 }) and expects true to document that undefined
fields are not considered in the exclusion list; place it alongside the existing
$nin tests near the matchesFilter tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 872ef335-acdd-4499-9a85-495fe131f68b

📥 Commits

Reviewing files that changed from the base of the PR and between 9991fdb and 9fe1414.

📒 Files selected for processing (3)
  • src/filter.ts
  • src/types.ts
  • test/filter.test.ts

Comment thread src/filter.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/filter.test.ts (1)

476-487: LGTM — Integration test validates end-to-end $nin behavior.

Consider adding a test with a document that has a missing type field to verify SQL NULL behavior at the integration level (optional).

📋 Optional test enhancement
 it('filters by $nin', async () => {
   const db = await sqliteFts({ path: ':memory:' })
   await db.index([
     { id: '1', content: 'hello world', metadata: { type: 'markdown' } },
     { id: '2', content: 'hello earth', metadata: { type: 'code' } },
     { id: '3', content: 'hello mars', metadata: { type: 'docs' } },
+    { id: '4', content: 'hello void', metadata: { other: 'field' } }, // missing type
   ])
   const results = await db.search('hello', { filter: { type: { $nin: ['code', 'docs'] } }, returnMetadata: true })
   expect(results).toHaveLength(1)
   expect(results[0]!.id).toBe('1')
+  // id: '4' excluded due to NULL NOT IN (...) → NULL (falsy)
   await db.close?.()
 })

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/filter.test.ts` around lines 476 - 487, Add an integration assertion
that verifies SQL NULL handling for $nin: modify the 'filters by $nin' test
using sqliteFts() and db.search to include an additional document without
metadata.type (e.g., { id: '4', content: 'hello void', metadata: {} } or no
metadata) and assert whether it is returned by the $nin filter; update the
expect checks to explicitly assert the presence/absence of that document (so the
test documents SQL NULL behavior for db.search with filter: { type: { $nin:
[...] } }).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/filter.test.ts`:
- Around line 476-487: Add an integration assertion that verifies SQL NULL
handling for $nin: modify the 'filters by $nin' test using sqliteFts() and
db.search to include an additional document without metadata.type (e.g., { id:
'4', content: 'hello void', metadata: {} } or no metadata) and assert whether it
is returned by the $nin filter; update the expect checks to explicitly assert
the presence/absence of that document (so the test documents SQL NULL behavior
for db.search with filter: { type: { $nin: [...] } }).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e1a36001-a0c4-4e76-ab2f-f639c524429e

📥 Commits

Reviewing files that changed from the base of the PR and between 9fe1414 and 385a2a0.

📒 Files selected for processing (2)
  • src/filter.ts
  • test/filter.test.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/filter.ts">

<violation number="1" location="src/filter.ts:126">
P2: The null guard breaks the `$nin: []` edge case for missing fields. SQL compiles `$nin: []` to `1 = 1` (matches everything including NULL fields), but the in-memory path now returns `false` when the field is absent. Short-circuit the empty-array case before the null check.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread src/filter.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/filter.test.ts (1)

478-489: Prefer try/finally for DB cleanup in async integration test.

If an assertion fails, db.close won’t run, which can leak handles and make test runs flaky.

Suggested change
 it('filters by $nin', async () => {
   const db = await sqliteFts({ path: ':memory:' })
-  await db.index([
-    { id: '1', content: 'hello world', metadata: { type: 'markdown' } },
-    { id: '2', content: 'hello earth', metadata: { type: 'code' } },
-    { id: '3', content: 'hello mars', metadata: { type: 'docs' } },
-  ])
-  const results = await db.search('hello', { filter: { type: { $nin: ['code', 'docs'] } }, returnMetadata: true })
-  expect(results).toHaveLength(1)
-  expect(results[0]!.id).toBe('1')
-  await db.close?.()
+  try {
+    await db.index([
+      { id: '1', content: 'hello world', metadata: { type: 'markdown' } },
+      { id: '2', content: 'hello earth', metadata: { type: 'code' } },
+      { id: '3', content: 'hello mars', metadata: { type: 'docs' } },
+    ])
+    const results = await db.search('hello', { filter: { type: { $nin: ['code', 'docs'] } }, returnMetadata: true })
+    expect(results).toHaveLength(1)
+    expect(results[0]!.id).toBe('1')
+  }
+  finally {
+    await db.close?.()
+  }
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/filter.test.ts` around lines 478 - 489, The test for $nin should ensure
the in-memory DB is always closed: wrap the async test body that creates
sqliteFts() (the db variable used with db.index, db.search) in a try/finally so
db.close is invoked in the finally block (call db.close?.() if present); keep
the assertions inside the try and move the existing await db.close?.() into
finally to guarantee cleanup even on assertion failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/filter.test.ts`:
- Around line 478-489: The test for $nin should ensure the in-memory DB is
always closed: wrap the async test body that creates sqliteFts() (the db
variable used with db.index, db.search) in a try/finally so db.close is invoked
in the finally block (call db.close?.() if present); keep the assertions inside
the try and move the existing await db.close?.() into finally to guarantee
cleanup even on assertion failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e83a9577-96e5-4ca9-a90e-c25f8bf4fb45

📥 Commits

Reviewing files that changed from the base of the PR and between 385a2a0 and 1d6b69c.

📒 Files selected for processing (2)
  • src/filter.ts
  • test/filter.test.ts
✅ Files skipped from review due to trivial changes (1)
  • src/filter.ts

@oritwoen
oritwoen requested a review from harlan-zw March 23, 2026 00:24
@harlan-zw
harlan-zw merged commit b3d357d into main Mar 23, 2026
3 checks passed
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.

2 participants