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 <typicode@gmail.com>
This commit is contained in:
Copilot
2026-02-26 23:46:25 +01:00
committed by GitHub
parent 9df3716a96
commit 43822ad8ad
5 changed files with 66 additions and 1 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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]