From 6a783e1383b4681ea97015254af4f6fabe26e269 Mon Sep 17 00:00:00 2001 From: HafizMMoaz Date: Tue, 28 Jul 2026 08:52:01 +0500 Subject: [PATCH] fix: match array fields in where filters Filtering on a property nested inside an array field silently ignored the condition and returned every row. matchesWhere only recursed into fields that are plain objects, so an array field fell through to `continue`, which reads as a match. An array field now matches when at least one of its items matches the predicate, and fails to match when none of them do. All conditions in the predicate must be satisfied by the same item. Fixes #1449 --- src/app.test.ts | 14 ++++++++++++++ src/matches-where.test.ts | 27 ++++++++++++++++++++++++++- src/matches-where.ts | 7 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/app.test.ts b/src/app.test.ts index bac286d98..a0658e08c 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -169,6 +169,20 @@ await test('createApp', async (t) => { assert.deepEqual(data, { error: 'Body must be a JSON object' }) }) + await t.test('GET /businesses?categories.id=... filters on array fields', async () => { + const business1 = { id: '1', categories: [{ id: 1 }, { id: 2 }] } + const business2 = { id: '2', categories: [{ id: 3 }] } + db.data = { businesses: [business1, business2] } + + const matching = await fetch(`http://localhost:${port}/businesses?categories.id=2`) + assert.equal(matching.status, 200) + assert.deepEqual(await matching.json(), [business1]) + + const empty = await fetch(`http://localhost:${port}/businesses?categories.id=10`) + assert.equal(empty.status, 200) + assert.deepEqual(await empty.json(), []) + }) + await t.test('PUT /posts/1 with null body returns 400', async () => { const response = await fetch(`http://localhost:${port}/posts/1`, { method: 'PUT', diff --git a/src/matches-where.test.ts b/src/matches-where.test.ts index 26a2be4a9..85744f866 100644 --- a/src/matches-where.test.ts +++ b/src/matches-where.test.ts @@ -6,7 +6,17 @@ import type { JsonObject } from 'type-fest' import { matchesWhere } from './matches-where.ts' await test('matchesWhere', async (t) => { - const obj: JsonObject = { a: 10, b: 20, c: 'x', nested: { a: 10, b: 20 } } + const obj: JsonObject = { + a: 10, + b: 20, + c: 'x', + nested: { a: 10, b: 20 }, + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + tags: ['red', 'blue'], + } const cases: [JsonObject, boolean][] = [ [{ a: { eq: 10 } }, true], [{ a: { eq: 11 } }, false], @@ -53,6 +63,21 @@ await test('matchesWhere', async (t) => { [{ c: { endsWith: 'z' } }, false], [{ a: { endsWith: '1' } }, false], [{ c: { endsWith: 1 } }, false], + // Array fields match when at least one item matches + [{ items: { id: { eq: 1 } } }, true], + [{ items: { id: { eq: 2 } } }, true], + [{ items: { id: { eq: 10 } } }, false], + [{ items: { id: { gt: 1 } } }, true], + [{ items: { id: { gt: 2 } } }, false], + [{ items: { name: { contains: 'BA' } } }, true], + [{ items: { name: { contains: 'baz' } } }, false], + // All conditions must be met by the same item + [{ items: { id: { eq: 1 }, name: { eq: 'foo' } } }, true], + [{ items: { id: { eq: 1 }, name: { eq: 'bar' } } }, false], + // Arrays of primitives have no properties to match + [{ tags: { id: { eq: 1 } } }, false], + [{ or: [{ items: { id: { eq: 10 } } }, { a: { eq: 10 } }] }, true], + [{ or: [{ items: { id: { eq: 10 } } }, { a: { eq: 11 } }] }, false], ] for (const [query, expected] of cases) { diff --git a/src/matches-where.ts b/src/matches-where.ts index 036b00c69..3340c90e8 100644 --- a/src/matches-where.ts +++ b/src/matches-where.ts @@ -72,6 +72,13 @@ export function matchesWhere(obj: JsonObject, where: JsonObject): boolean { continue } + // An array matches if at least one of its items matches + if (Array.isArray(field)) { + const matched = field.some((item) => isJSONObject(item) && matchesWhere(item, value)) + if (!matched) return false + continue + } + if (isJSONObject(field)) { if (!matchesWhere(field, value)) return false }