From 43822ad8add3d794ef0b5687d89fa7c7dbbcaa9d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:46:25 +0100 Subject: [PATCH] Add `contains`, `startsWith`, `endsWith` string query operators (#1714) * Initial plan * Add contains, startsWith, endsWith query operators for partial string matching Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> * Delete package-lock.json * docs: document contains, startsWith, endsWith operators in README Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> * test: add numeric-value tests for contains, startsWith, endsWith operators Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> Co-authored-by: typicode --- README.md | 6 ++++++ src/matches-where.test.ts | 18 ++++++++++++++++++ src/matches-where.ts | 12 ++++++++++++ src/parse-where.test.ts | 18 ++++++++++++++++++ src/where-operators.ts | 13 ++++++++++++- 5 files changed, 66 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 78e5133..6da48f7 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,9 @@ Operators: - `gt` greater than, `gte` greater than or equal - `eq` equal, `ne` not equal - `in` included in comma-separated list +- `contains` string contains (case-insensitive) +- `startsWith` string starts with (case-insensitive) +- `endsWith` string ends with (case-insensitive) Examples: @@ -175,6 +178,9 @@ GET /posts?views:gt=100 GET /posts?title:eq=Hello GET /posts?id:in=1,2,3 GET /posts?author.name:eq=typicode +GET /posts?title:contains=hello +GET /posts?title:startsWith=Hello +GET /posts?title:endsWith=world ``` ### Sort diff --git a/src/matches-where.test.ts b/src/matches-where.test.ts index c3fb9e7..26a2be4 100644 --- a/src/matches-where.test.ts +++ b/src/matches-where.test.ts @@ -35,6 +35,24 @@ await test('matchesWhere', async (t) => { [{ a: { foo: 10 } }, true], [{ a: { foo: 10, eq: 10 } }, true], [{ missing: { foo: 1 } }, true], + // contains + [{ c: { contains: 'x' } }, true], + [{ c: { contains: 'X' } }, true], + [{ c: { contains: 'z' } }, false], + [{ a: { contains: '1' } }, false], + [{ c: { contains: 1 } }, false], + // startsWith + [{ c: { startsWith: 'x' } }, true], + [{ c: { startsWith: 'X' } }, true], + [{ c: { startsWith: 'z' } }, false], + [{ a: { startsWith: '1' } }, false], + [{ c: { startsWith: 1 } }, false], + // endsWith + [{ c: { endsWith: 'x' } }, true], + [{ c: { endsWith: 'X' } }, true], + [{ c: { endsWith: 'z' } }, false], + [{ a: { endsWith: '1' } }, false], + [{ c: { endsWith: 1 } }, false], ] for (const [query, expected] of cases) { diff --git a/src/matches-where.ts b/src/matches-where.ts index 8a1da62..036b00c 100644 --- a/src/matches-where.ts +++ b/src/matches-where.ts @@ -57,6 +57,18 @@ export function matchesWhere(obj: JsonObject, where: JsonObject): boolean { const inValues = Array.isArray(op.in) ? op.in : [op.in] if (!inValues.some((v) => (field as any) === (v as any))) return false } + if (knownOps.includes('contains')) { + if (typeof field !== 'string') return false + if (!field.toLowerCase().includes(String(op.contains).toLowerCase())) return false + } + if (knownOps.includes('startsWith')) { + if (typeof field !== 'string') return false + if (!field.toLowerCase().startsWith(String(op.startsWith).toLowerCase())) return false + } + if (knownOps.includes('endsWith')) { + if (typeof field !== 'string') return false + if (!field.toLowerCase().endsWith(String(op.endsWith).toLowerCase())) return false + } continue } diff --git a/src/parse-where.test.ts b/src/parse-where.test.ts index a2d46d8..95c2018 100644 --- a/src/parse-where.test.ts +++ b/src/parse-where.test.ts @@ -95,6 +95,24 @@ await test('parseWhere', async (t) => { title: { in: ['hello', 'world'] }, }, ], + [ + 'title:contains=ello', + { + title: { contains: 'ello' }, + }, + ], + [ + 'title:startsWith=hel', + { + title: { startsWith: 'hel' }, + }, + ], + [ + 'title:endsWith=rld', + { + title: { endsWith: 'rld' }, + }, + ], ] for (const [query, expected] of cases) { diff --git a/src/where-operators.ts b/src/where-operators.ts index f522931..29d601a 100644 --- a/src/where-operators.ts +++ b/src/where-operators.ts @@ -1,4 +1,15 @@ -export const WHERE_OPERATORS = ['lt', 'lte', 'gt', 'gte', 'eq', 'ne', 'in'] as const +export const WHERE_OPERATORS = [ + 'lt', + 'lte', + 'gt', + 'gte', + 'eq', + 'ne', + 'in', + 'contains', + 'startsWith', + 'endsWith', +] as const export type WhereOperator = (typeof WHERE_OPERATORS)[number]