feat: add _where filtering, use new op separator, drop _start, _end, _limit (#1696)

* feat: add _where filtering and new op separator

* Restore original README content above Query capabilities overview (#1697)

* test: refactor service tests to lightweight table cases

* chore: document underscore where operator compatibility

* refactor: clarify blank-string handling in where coercion

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
typicode
2026-02-14 13:34:05 +01:00
committed by GitHub
parent 221f2b8557
commit ada86ac596
13 changed files with 675 additions and 566 deletions

101
README.md
View File

@@ -110,107 +110,108 @@ Run `json-server --help` for a list of options
>
> For more information, FAQs, and the rationale behind this, visit [https://fair.io/](https://fair.io/).
## Query capabilities overview
```http
GET /posts?views:gt=100
GET /posts?_sort=-views
GET /posts?_page=1&_per_page=10
GET /posts?_embed=comments
GET /posts?_where={"or":[{"views":{"gt":100}},{"title":{"eq":"Hello"}}]}
```
## Routes
Based on the example `db.json`, you'll get the following routes:
For array resources (`posts`, `comments`):
```
```text
GET /posts
GET /posts/:id
POST /posts
PUT /posts/:id
PATCH /posts/:id
DELETE /posts/:id
# Same for comments
```
```
For object resources (`profile`):
```text
GET /profile
PUT /profile
PATCH /profile
```
## Params
## Query params
### Conditions
- ` ``==`
- `lt``<`
- `lte``<=`
- `gt``>`
- `gte``>=`
- `ne``!=`
Use `field:operator=value`.
```
GET /posts?views_gt=9000
```
Operators:
### Range
- no operator -> `eq` (equal)
- `lt` less than, `lte` less than or equal
- `gt` greater than, `gte` greater than or equal
- `eq` equal, `ne` not equal
- `start`
- `end`
- `limit`
Examples:
```
GET /posts?_start=10&_end=20
GET /posts?_start=10&_limit=10
```
### Paginate
- `page`
- `per_page` (default = 10)
```
GET /posts?_page=1&_per_page=25
```http
GET /posts?views:gt=100
GET /posts?title:eq=Hello
GET /posts?author.name:eq=typicode
```
### Sort
- `_sort=f1,f2`
```
GET /posts?_sort=id,-views
```http
GET /posts?_sort=title
GET /posts?_sort=-views
GET /posts?_sort=author.name,-views
```
### Nested and array fields
### Pagination
- `x.y.z...`
- `x.y.z[i]...`
```http
GET /posts?_page=1&_per_page=25
```
```
GET /foo?a.b=bar
GET /foo?x.y_lt=100
GET /foo?arr[0]=bar
```
- `_per_page` default is `10`
- invalid page/per_page values are normalized
### Embed
```
```http
GET /posts?_embed=comments
GET /comments?_embed=post
```
## Delete
### Complex filter with `_where`
`_where` accepts a JSON object and overrides normal query params when valid.
```http
GET /posts?_where={"or":[{"views":{"gt":100}},{"author":{"name":{"lt":"m"}}}]}
```
DELETE /posts/1
## Delete dependents
```http
DELETE /posts/1?_dependent=comments
```
## Serving static files
## Static files
If you create a `./public` directory, JSON Server will serve its content in addition to the REST API.
JSON Server serves `./public` automatically.
You can also add custom directories using `-s/--static` option.
Add more static dirs:
```sh
json-server -s ./static
json-server -s ./static -s ./node_modules
```
## Notable differences with v0.17
## Behavior notes
- `id` is always a string and will be generated for you if missing
- use `_per_page` with `_page` instead of `_limit`for pagination

View File

@@ -118,4 +118,28 @@ await test('createApp', async (t) => {
)
})
}
await t.test('GET /posts?_where=... uses JSON query', async () => {
// Reset data since previous tests may have modified it
db.data = {
posts: [{ id: '1', title: 'foo' }],
comments: [{ id: '1', postId: '1' }],
object: { f1: 'foo' },
}
const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } }))
const response = await fetch(`http://localhost:${port}/posts?_where=${where}`)
assert.equal(response.status, 200)
const data = await response.json()
assert.deepEqual(data, [{ id: '1', title: 'foo' }])
})
await t.test('GET /posts?_where=... overrides query params', async () => {
const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } }))
const response = await fetch(
`http://localhost:${port}/posts?title:eq=bar&_where=${where}`,
)
assert.equal(response.status, 200)
const data = await response.json()
assert.deepEqual(data, [{ id: '1', title: 'foo' }])
})
})

View File

@@ -1,22 +1,20 @@
import { dirname, isAbsolute, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { App, type Request } from '@tinyhttp/app'
import { App } from '@tinyhttp/app'
import { cors } from '@tinyhttp/cors'
import { Eta } from 'eta'
import { Low } from 'lowdb'
import { json } from 'milliparsec'
import sirv from 'sirv'
import { parseWhere } from './parse-where.ts'
import type { Data } from './service.ts'
import { isItem, Service } from './service.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const isProduction = process.env['NODE_ENV'] === 'production'
type QueryValue = Request['query'][string] | number
type Query = Record<string, QueryValue>
export type AppOptions = {
logger?: boolean
static?: string[]
@@ -27,6 +25,68 @@ const eta = new Eta({
cache: isProduction,
})
const RESERVED_QUERY_KEYS = new Set(['_sort', '_page', '_per_page', '_embed', '_where'])
function parseListParams(req: any) {
const queryString = req.url.split('?')[1] ?? ''
const params = new URLSearchParams(queryString)
const filterParams = new URLSearchParams()
for (const [key, value] of params.entries()) {
if (!RESERVED_QUERY_KEYS.has(key)) {
filterParams.append(key, value)
}
}
let where = parseWhere(filterParams.toString())
const rawWhere = params.get('_where')
if (typeof rawWhere === 'string') {
try {
const parsed = JSON.parse(rawWhere)
if (typeof parsed === 'object' && parsed !== null) {
where = parsed
}
} catch {
// Ignore invalid JSON and fallback to parsed query params
}
}
const pageRaw = params.get('_page')
const perPageRaw = params.get('_per_page')
const page = pageRaw === null ? undefined : Number.parseInt(pageRaw, 10)
const perPage = perPageRaw === null ? undefined : Number.parseInt(perPageRaw, 10)
return {
where,
sort: params.get('_sort') ?? undefined,
page: Number.isNaN(page) ? undefined : page,
perPage: Number.isNaN(perPage) ? undefined : perPage,
embed: req.query['_embed'],
}
}
function withBody(action: (name: string, body: Record<string, unknown>) => Promise<unknown>) {
return async (req: any, res: any, next: any) => {
const { name = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await action(name, req.body)
}
next?.()
}
}
function withIdAndBody(
action: (name: string, id: string, body: Record<string, unknown>) => Promise<unknown>,
) {
return async (req: any, res: any, next: any) => {
const { name = '', id = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await action(name, id, req.body)
}
next?.()
}
}
export function createApp(db: Low<Data>, options: AppOptions = {}) {
// Create service
const service = new Service(db)
@@ -58,23 +118,15 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
app.get('/:name', (req, res, next) => {
const { name = '' } = req.params
const query: Query = {}
const { where, sort, page, perPage, embed } = parseListParams(req)
Object.keys(req.query).forEach((key) => {
let value: QueryValue = req.query[key]
if (
['_start', '_end', '_limit', '_page', '_per_page'].includes(key) &&
typeof value === 'string'
) {
value = parseInt(value)
}
if (!Number.isNaN(value)) {
query[key] = value
}
res.locals['data'] = service.find(name, {
where,
sort,
page,
perPage,
embed,
})
res.locals['data'] = service.find(name, query)
next?.()
})
@@ -84,45 +136,15 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
next?.()
})
app.post('/:name', async (req, res, next) => {
const { name = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await service.create(name, req.body)
}
next?.()
})
app.post('/:name', withBody(service.create.bind(service)))
app.put('/:name', async (req, res, next) => {
const { name = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await service.update(name, req.body)
}
next?.()
})
app.put('/:name', withBody(service.update.bind(service)))
app.put('/:name/:id', async (req, res, next) => {
const { name = '', id = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await service.updateById(name, id, req.body)
}
next?.()
})
app.put('/:name/:id', withIdAndBody(service.updateById.bind(service)))
app.patch('/:name', async (req, res, next) => {
const { name = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await service.patch(name, req.body)
}
next?.()
})
app.patch('/:name', withBody(service.patch.bind(service)))
app.patch('/:name/:id', async (req, res, next) => {
const { name = '', id = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await service.patchById(name, id, req.body)
}
next?.()
})
app.patch('/:name/:id', withIdAndBody(service.patchById.bind(service)))
app.delete('/:name/:id', async (req, res, next) => {
const { name = '', id = '' } = req.params

39
src/matches-where.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import test from 'node:test'
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 cases: [JsonObject, boolean][] = [
[{ a: { eq: 10 } }, true],
[{ a: { eq: 11 } }, false],
[{ c: { ne: 'y' } }, true],
[{ c: { ne: 'x' } }, false],
[{ a: { lt: 11 } }, true],
[{ a: { lt: 10 } }, false],
[{ a: { lte: 10 } }, true],
[{ a: { lte: 9 } }, false],
[{ b: { gt: 19 } }, true],
[{ b: { gt: 20 } }, false],
[{ b: { gte: 20 } }, true],
[{ b: { gte: 21 } }, false],
[{ a: { gt: 0 }, b: { lt: 30 } }, true],
[{ a: { gt: 10 }, b: { lt: 30 } }, false],
[{ or: [{ a: { lt: 0 } }, { b: { gt: 19 } }] }, true],
[{ or: [{ a: { lt: 0 } }, { b: { gt: 20 } }] }, false],
[{ nested: { a: { eq: 10 } } }, true],
[{ nested: { b: { lt: 20 } } }, false],
[{ a: { foo: 10 } }, true],
[{ a: { foo: 10, eq: 10 } }, true],
[{ missing: { foo: 1 } }, true],
]
for (const [query, expected] of cases) {
await t.test(JSON.stringify(query), () => {
assert.equal(matchesWhere(obj, query), expected)
})
}
})

72
src/matches-where.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { JsonObject } from 'type-fest'
import { WHERE_OPERATORS, type WhereOperator } from './where-operators.ts'
type OperatorObject = Partial<Record<WhereOperator, unknown>>
function isJSONObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function getKnownOperators(value: unknown): WhereOperator[] {
if (!isJSONObject(value)) return []
const ops: WhereOperator[] = []
for (const op of WHERE_OPERATORS) {
if (op in value) {
ops.push(op)
}
}
return ops
}
export function matchesWhere(obj: JsonObject, where: JsonObject): boolean {
for (const [key, value] of Object.entries(where)) {
if (key === 'or') {
if (!Array.isArray(value) || value.length === 0) return false
let matched = false
for (const subWhere of value) {
if (isJSONObject(subWhere) && matchesWhere(obj, subWhere)) {
matched = true
break
}
}
if (!matched) return false
continue
}
const field = (obj as Record<string, unknown>)[key]
if (isJSONObject(value)) {
const knownOps = getKnownOperators(value)
if (knownOps.length > 0) {
if (field === undefined) return false
const op = value as OperatorObject
if (knownOps.includes('lt') && !((field as any) < (op.lt as any))) return false
if (knownOps.includes('lte') && !((field as any) <= (op.lte as any))) return false
if (knownOps.includes('gt') && !((field as any) > (op.gt as any))) return false
if (knownOps.includes('gte') && !((field as any) >= (op.gte as any))) return false
if (knownOps.includes('eq') && !((field as any) === (op.eq as any))) return false
if (knownOps.includes('ne') && !((field as any) !== (op.ne as any))) return false
continue
}
if (isJSONObject(field)) {
if (!matchesWhere(field, value)) return false
}
continue
}
if (field === undefined) return false
return false
}
return true
}

View File

@@ -1,36 +1,36 @@
import type { Adapter } from "lowdb";
import type { Adapter } from 'lowdb'
// Lowdb adapter to observe read/write events
export class Observer<T> {
#adapter;
#adapter: Adapter<T>
onReadStart = function () {
return;
};
return
}
onReadEnd: (data: T | null) => void = function () {
return;
};
return
}
onWriteStart = function () {
return;
};
return
}
onWriteEnd = function () {
return;
};
return
}
constructor(adapter: Adapter<T>) {
this.#adapter = adapter;
this.#adapter = adapter
}
async read() {
this.onReadStart();
const data = await this.#adapter.read();
this.onReadEnd(data);
return data;
this.onReadStart()
const data = await this.#adapter.read()
this.onReadEnd(data)
return data
}
async write(arg: T) {
this.onWriteStart();
await this.#adapter.write(arg);
this.onWriteEnd();
this.onWriteStart()
await this.#adapter.write(arg)
this.onWriteEnd()
}
}

107
src/paginate.test.ts Normal file
View File

@@ -0,0 +1,107 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { paginate } from './paginate.ts'
await test('paginate', async (t) => {
// Pagination: page boundaries and clamping behavior.
const cases = [
{
name: 'page=1 perPage=2 items=5 -> [1,2]',
items: [1, 2, 3, 4, 5],
page: 1,
perPage: 2,
expected: {
first: 1,
prev: null,
next: 2,
last: 3,
pages: 3,
items: 5,
data: [1, 2],
},
},
{
name: 'page=2 perPage=2 items=5 -> [3,4]',
items: [1, 2, 3, 4, 5],
page: 2,
perPage: 2,
expected: {
first: 1,
prev: 1,
next: 3,
last: 3,
pages: 3,
items: 5,
data: [3, 4],
},
},
{
name: 'page=9 perPage=2 items=5 -> clamp to last',
items: [1, 2, 3, 4, 5],
page: 9,
perPage: 2,
expected: {
first: 1,
prev: 2,
next: null,
last: 3,
pages: 3,
items: 5,
data: [5],
},
},
{
name: 'page=0 perPage=2 items=3 -> clamp to first',
items: [1, 2, 3],
page: 0,
perPage: 2,
expected: {
first: 1,
prev: null,
next: 2,
last: 2,
pages: 2,
items: 3,
data: [1, 2],
},
},
{
name: 'items=[] page=1 perPage=2 -> stable empty pagination',
items: [],
page: 1,
perPage: 2,
expected: {
first: 1,
prev: null,
next: null,
last: 1,
pages: 1,
items: 0,
data: [],
},
},
{
name: 'perPage=0 -> clamp perPage to 1',
items: [1, 2, 3],
page: 1,
perPage: 0,
expected: {
first: 1,
prev: null,
next: 2,
last: 3,
pages: 3,
items: 3,
data: [1],
},
},
]
for (const tc of cases) {
await t.test(tc.name, () => {
const res = paginate(tc.items, tc.page, tc.perPage)
assert.deepEqual(res, tc.expected)
})
}
})

38
src/paginate.ts Normal file
View File

@@ -0,0 +1,38 @@
export type PaginationResult<T> = {
first: number
prev: number | null
next: number | null
last: number
pages: number
items: number
data: T[]
}
export function paginate<T>(items: T[], page: number, perPage: number): PaginationResult<T> {
const totalItems = items.length
const safePerPage = Number.isFinite(perPage) && perPage > 0 ? Math.floor(perPage) : 1
const pages = Math.max(1, Math.ceil(totalItems / safePerPage))
// Ensure page is within the valid range
const safePage = Number.isFinite(page) ? Math.floor(page) : 1
const currentPage = Math.max(1, Math.min(safePage, pages))
const first = 1
const prev = currentPage > 1 ? currentPage - 1 : null
const next = currentPage < pages ? currentPage + 1 : null
const last = pages
const start = (currentPage - 1) * safePerPage
const end = start + safePerPage
const data = items.slice(start, end)
return {
first,
prev,
next,
last,
pages,
items: totalItems,
data,
}
}

93
src/parse-where.test.ts Normal file
View File

@@ -0,0 +1,93 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseWhere } from './parse-where.ts'
await test('parseWhere', async (t) => {
const cases: [string, Record<string, unknown>][] = [
[
'views:gt=100&title:eq=a',
{
views: { gt: 100 },
title: { eq: 'a' },
},
],
[
'title=hello',
{
title: { eq: 'hello' },
},
],
[
'author.name:lt=c&author.id:ne=2',
{
author: {
name: { lt: 'c' },
id: { ne: 2 },
},
},
],
[
'views:gt=100&views:lt=300',
{
views: { gt: 100, lt: 300 },
},
],
[
'name:eq=Alice',
{
name: { eq: 'Alice' },
},
],
[
'views:gt=100&published:eq=true&ratio:lt=0.5&deleted:eq=null',
{
views: { gt: 100 },
published: { eq: true },
ratio: { lt: 0.5 },
deleted: { eq: null },
},
],
[
'views:foo=100&title:eq=a',
{
title: { eq: 'a' },
},
],
['views:foo=100', {}],
[
'views_gt=100&title_eq=a',
{
views: { gt: 100 },
title: { eq: 'a' },
},
],
[
'first_name_eq=Alice&author.first_name_ne=Bob',
{
first_name: { eq: 'Alice' },
author: {
first_name: { ne: 'Bob' },
},
},
],
[
'first_name=Alice',
{
first_name: { eq: 'Alice' },
},
],
[
'views_gt=100&views:lt=300',
{
views: { gt: 100, lt: 300 },
},
],
]
for (const [query, expected] of cases) {
await t.test(query, () => {
assert.deepEqual(parseWhere(query), expected)
})
}
})

60
src/parse-where.ts Normal file
View File

@@ -0,0 +1,60 @@
import { setProperty } from 'dot-prop'
import type { JsonObject } from 'type-fest'
import { isWhereOperator, type WhereOperator } from './where-operators.ts'
function splitKey(key: string): { path: string; op: WhereOperator | null } {
const colonIdx = key.lastIndexOf(':')
if (colonIdx !== -1) {
const path = key.slice(0, colonIdx)
const op = key.slice(colonIdx + 1)
if (!op) {
return { path: key, op: 'eq' }
}
return isWhereOperator(op) ? { path, op } : { path, op: null }
}
// Compatibility with v0.17 operator style (e.g. _lt, _gt)
const underscoreMatch = key.match(/^(.*)_([a-z]+)$/)
if (underscoreMatch) {
const path = underscoreMatch[1]
const op = underscoreMatch[2]
if (path && isWhereOperator(op)) {
return { path, op }
}
}
return { path: key, op: 'eq' }
}
function setPathOp(root: JsonObject, path: string, op: WhereOperator, value: string): void {
const fullPath = `${path}.${op}`
setProperty(root, fullPath, coerceValue(value))
}
function coerceValue(value: string): string | number | boolean | null {
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
if (value.trim() === '') return value
const num = Number(value)
if (Number.isFinite(num)) return num
return value
}
export function parseWhere(query: string): JsonObject {
const out: JsonObject = {}
const params = new URLSearchParams(query)
for (const [rawKey, rawValue] of params.entries()) {
const { path, op } = splitKey(rawKey)
if (op === null) continue
setPathOp(out, path, op, rawValue)
}
return out
}

View File

@@ -1,9 +1,10 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import test, { beforeEach } from 'node:test'
import { Low, Memory } from 'lowdb'
import type { JsonObject } from 'type-fest'
import type { Data, Item, PaginatedItems } from './service.ts'
import type { Data } from './service.ts'
import { Service } from './service.ts'
const defaultData = { posts: [], comments: [], object: {} }
@@ -43,282 +44,80 @@ const post3 = {
tags: ['foo'],
}
const comment1 = { id: '1', title: 'a', postId: '1' }
const items = 3
const obj = {
f1: 'foo',
}
function reset() {
beforeEach(() => {
db.data = structuredClone({
posts: [post1, post2, post3],
comments: [comment1],
object: obj,
})
}
})
await test('constructor', () => {
const defaultData = { posts: [{ id: '1' }, {}], object: {} } satisfies Data
const db = new Low<Data>(adapter, defaultData)
new Service(db)
if (Array.isArray(db.data['posts'])) {
const id0 = db.data['posts']?.at(0)?.['id']
const id1 = db.data['posts']?.at(1)?.['id']
assert.ok(
typeof id1 === 'string' && id1.length > 0,
`id should be a non empty string but was: ${String(id1)}`,
)
const id0 = db.data['posts'][0]['id']
const id1 = db.data['posts'][1]['id']
assert.ok(
typeof id0 === 'string' && id0 === '1',
`id should not change if already set but was: ${String(id0)}`,
`id should not change if already set but was: ${id0}`,
)
assert.ok(
typeof id1 === 'string' && id1.length > 0,
`id should be a non empty string but was: ${id1}`,
)
}
})
await test('findById', () => {
reset()
if (!Array.isArray(db.data?.[POSTS])) throw new Error('posts should be an array')
assert.deepEqual(service.findById(POSTS, '1', {}), db.data?.[POSTS]?.[0])
assert.equal(service.findById(POSTS, UNKNOWN_ID, {}), undefined)
assert.deepEqual(service.findById(POSTS, '1', { _embed: ['comments'] }), {
...post1,
comments: [comment1],
})
assert.deepEqual(service.findById(COMMENTS, '1', { _embed: ['post'] }), {
...comment1,
post: post1,
})
assert.equal(service.findById(UNKNOWN_RESOURCE, '1', {}), undefined)
const cases: [[string, string, { _embed?: string[] | string }], unknown][] = [
[[POSTS, '1', {}], db.data?.[POSTS]?.[0]],
[[POSTS, UNKNOWN_ID, {}], undefined],
[[POSTS, '1', { _embed: ['comments'] }], { ...post1, comments: [comment1] }],
[[COMMENTS, '1', { _embed: ['post'] }], { ...comment1, post: post1 }],
[[UNKNOWN_RESOURCE, '1', {}], undefined],
]
for (const [[name, id, query], expected] of cases) {
assert.deepEqual(service.findById(name, id, query), expected)
}
})
await test('find', async (t) => {
const arr: {
data?: Data
name: string
params?: Parameters<Service['find']>[1]
res: Item | Item[] | PaginatedItems | undefined
error?: Error
}[] = [
{
name: POSTS,
res: [post1, post2, post3],
},
{
name: POSTS,
params: { id: post1.id },
res: [post1],
},
{
name: POSTS,
params: { id: UNKNOWN_ID },
res: [],
},
{
name: POSTS,
params: { views: post1.views.toString() },
res: [post1],
},
{
name: POSTS,
params: { 'author.name': post1.author.name },
res: [post1],
},
{
name: POSTS,
params: { 'tags[0]': 'foo' },
res: [post1, post3],
},
{
name: POSTS,
params: { id: UNKNOWN_ID, views: post1.views.toString() },
res: [],
},
{
name: POSTS,
params: { views_ne: post1.views.toString() },
res: [post2, post3],
},
{
name: POSTS,
params: { views_lt: (post1.views + 1).toString() },
res: [post1],
},
{
name: POSTS,
params: { views_lt: post1.views.toString() },
res: [],
},
{
name: POSTS,
params: { views_lte: post1.views.toString() },
res: [post1],
},
{
name: POSTS,
params: { views_gt: post1.views.toString() },
res: [post2, post3],
},
{
name: POSTS,
params: { views_gt: (post1.views - 1).toString() },
res: [post1, post2, post3],
},
{
name: POSTS,
params: { views_gte: post1.views.toString() },
res: [post1, post2, post3],
},
{
name: POSTS,
params: {
views_gt: post1.views.toString(),
views_lt: post3.views.toString(),
},
res: [post2],
},
{
data: { posts: [post3, post1, post2] },
name: POSTS,
params: { _sort: 'views' },
res: [post1, post2, post3],
},
{
data: { posts: [post3, post1, post2] },
name: POSTS,
params: { _sort: '-views' },
res: [post3, post2, post1],
},
{
data: { posts: [post3, post1, post2] },
name: POSTS,
params: { _sort: '-views,id' },
res: [post3, post2, post1],
},
{
name: POSTS,
params: { published: 'true' },
res: [post1],
},
{
name: POSTS,
params: { published: 'false' },
res: [post2, post3],
},
{
name: POSTS,
params: { views_lt: post3.views.toString(), published: 'false' },
res: [post2],
},
{
name: POSTS,
params: { _start: 0, _end: 2 },
res: [post1, post2],
},
{
name: POSTS,
params: { _start: 1, _end: 3 },
res: [post2, post3],
},
{
name: POSTS,
params: { _start: 0, _limit: 2 },
res: [post1, post2],
},
{
name: POSTS,
params: { _start: 1, _limit: 2 },
res: [post2, post3],
},
{
name: POSTS,
params: { _page: 1, _per_page: 2 },
res: {
first: 1,
last: 2,
prev: null,
next: 2,
pages: 2,
items,
data: [post1, post2],
},
},
{
name: POSTS,
params: { _page: 2, _per_page: 2 },
res: {
first: 1,
last: 2,
prev: 1,
next: null,
pages: 2,
items,
data: [post3],
},
},
{
name: POSTS,
params: { _page: 3, _per_page: 2 },
res: {
first: 1,
last: 2,
prev: 1,
next: null,
pages: 2,
items,
data: [post3],
},
},
{
name: POSTS,
params: { _page: 2, _per_page: 1 },
res: {
first: 1,
last: 3,
prev: 1,
next: 3,
pages: 3,
items,
data: [post2],
},
},
{
name: POSTS,
params: { _embed: ['comments'] },
res: [
{ ...post1, comments: [comment1] },
{ ...post2, comments: [] },
{ ...post3, comments: [] },
],
},
{
name: COMMENTS,
params: { _embed: ['post'] },
res: [{ ...comment1, post: post1 }],
},
{
name: UNKNOWN_RESOURCE,
res: undefined,
},
{
name: OBJECT,
res: obj,
},
]
for (const tc of arr) {
await t.test(`${tc.name} ${JSON.stringify(tc.params)}`, () => {
if (tc.data) {
db.data = tc.data
} else {
reset()
}
const whereFromPayload = JSON.parse('{"author":{"name":{"eq":"bar"}}}') as JsonObject
assert.deepEqual(service.find(tc.name, tc.params), tc.res)
const cases: [{ where: JsonObject; sort?: string; page?: number; perPage?: number }, unknown][] =
[
[{ where: { title: { eq: 'b' } } }, [post2]],
[{ where: whereFromPayload }, [post2]],
[{ where: {}, sort: '-views' }, [post3, post2, post1]],
[
{ where: {}, page: 2, perPage: 2 },
{
first: 1,
prev: 1,
next: null,
last: 2,
pages: 2,
items: 3,
data: [post3],
},
],
]
for (const [opts, expected] of cases) {
await t.test(JSON.stringify(opts), () => {
assert.deepEqual(service.find(POSTS, opts), expected)
})
}
})
await test('create', async () => {
reset()
const post = { title: 'new post' }
const res = await service.create(POSTS, post)
assert.equal(res?.['title'], post.title)
@@ -328,7 +127,6 @@ await test('create', async () => {
})
await test('update', async () => {
reset()
const obj = { f1: 'bar' }
const res = await service.update(OBJECT, obj)
assert.equal(res, obj)
@@ -342,7 +140,6 @@ await test('update', async () => {
})
await test('patch', async () => {
reset()
const obj = { f2: 'bar' }
const res = await service.patch(OBJECT, obj)
assert.deepEqual(res, { f1: 'foo', ...obj })
@@ -356,7 +153,6 @@ await test('patch', async () => {
})
await test('updateById', async () => {
reset()
const post = { id: 'xxx', title: 'updated post' }
const res = await service.updateById(POSTS, post1.id, post)
assert.equal(res?.['id'], post1.id, 'id should not change')
@@ -367,7 +163,6 @@ await test('updateById', async () => {
})
await test('patchById', async () => {
reset()
const post = { id: 'xxx', title: 'updated post' }
const res = await service.patchById(POSTS, post1.id, post)
assert.notEqual(res, undefined)
@@ -378,19 +173,23 @@ await test('patchById', async () => {
assert.equal(await service.patchById(POSTS, UNKNOWN_ID, post), undefined)
})
await test('destroy', async () => {
reset()
let prevLength = Number(db.data?.[POSTS]?.length) || 0
await service.destroyById(POSTS, post1.id)
assert.equal(db.data?.[POSTS]?.length, prevLength - 1)
assert.deepEqual(db.data?.[COMMENTS], [{ ...comment1, postId: null }])
await test('destroy', async (t) => {
await t.test('nullifies foreign keys', async () => {
const prevLength = Number(db.data?.[POSTS]?.length) || 0
await service.destroyById(POSTS, post1.id)
assert.equal(db.data?.[POSTS]?.length, prevLength - 1)
assert.deepEqual(db.data?.[COMMENTS], [{ ...comment1, postId: null }])
})
reset()
prevLength = db.data?.[POSTS]?.length || 0
await service.destroyById(POSTS, post1.id, [COMMENTS])
assert.equal(db.data[POSTS].length, prevLength - 1)
assert.equal(db.data[COMMENTS].length, 0)
await t.test('deletes dependent resources', async () => {
const prevLength = Number(db.data?.[POSTS]?.length) || 0
await service.destroyById(POSTS, post1.id, [COMMENTS])
assert.equal(db.data[POSTS].length, prevLength - 1)
assert.equal(db.data[COMMENTS].length, 0)
})
assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined)
assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined)
await t.test('ignores unknown resources', async () => {
assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined)
assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined)
})
})

View File

@@ -1,51 +1,32 @@
import { randomBytes } from 'node:crypto'
import { getProperty } from 'dot-prop'
import inflection from 'inflection'
import { Low } from 'lowdb'
import sortOn from 'sort-on'
import type { JsonObject } from 'type-fest'
import { matchesWhere } from './matches-where.ts'
import { paginate, type PaginationResult } from './paginate.ts'
export type Item = Record<string, unknown>
export type Data = Record<string, Item[] | Item>
export function isItem(obj: unknown): obj is Item {
return typeof obj === 'object' && obj !== null
return typeof obj === 'object' && obj !== null && !Array.isArray(obj)
}
export function isData(obj: unknown): obj is Record<string, Item[]> {
export function isData(obj: unknown): obj is Data {
if (typeof obj !== 'object' || obj === null) {
return false
}
const data = obj as Record<string, unknown>
return Object.values(data).every((value) => Array.isArray(value) && value.every(isItem))
return Object.values(data).every((value) =>
Array.isArray(value) ? value.every(isItem) : isItem(value),
)
}
const Condition = {
lt: 'lt',
lte: 'lte',
gt: 'gt',
gte: 'gte',
ne: 'ne',
default: '',
} as const
type Condition = (typeof Condition)[keyof typeof Condition]
function isCondition(value: string): value is Condition {
return Object.values<string>(Condition).includes(value)
}
export type PaginatedItems = {
first: number
prev: number | null
next: number | null
last: number
pages: number
items: number
data: Item[]
}
export type PaginatedItems = PaginationResult<Item>
function ensureArray(arg: string | string[] = []): string[] {
return Array.isArray(arg) ? arg : [arg]
@@ -165,171 +146,37 @@ export class Service {
find(
name: string,
query: {
[key: string]: unknown
_embed?: string | string[]
_sort?: string
_start?: number
_end?: number
_limit?: number
_page?: number
_per_page?: number
} = {},
opts: {
where: JsonObject
sort?: string
page?: number
perPage?: number
embed?: string | string[]
},
): Item[] | PaginatedItems | Item | undefined {
let items = this.#get(name)
const items = this.#get(name)
if (!Array.isArray(items)) {
return items
}
let results = items
// Include
ensureArray(query._embed).forEach((related) => {
if (items !== undefined && Array.isArray(items)) {
items = items.map((item) => embed(this.#db, name, item, related))
}
ensureArray(opts.embed).forEach((related) => {
results = results.map((item) => embed(this.#db, name, item, related))
})
// Return list if no query params
if (Object.keys(query).length === 0) {
return items
results = results.filter((item) => matchesWhere(item as JsonObject, opts.where))
if (opts.sort) {
results = sortOn(results, opts.sort.split(','))
}
// Convert query params to conditions
const conds: [string, Condition, string | string[]][] = []
for (const [key, value] of Object.entries(query)) {
if (value === undefined || typeof value !== 'string') {
continue
}
const re = /_(lt|lte|gt|gte|ne)$/
const reArr = re.exec(key)
const op = reArr?.at(1)
if (op && isCondition(op)) {
const field = key.replace(re, '')
conds.push([field, op, value])
continue
}
if (['_embed', '_sort', '_start', '_end', '_limit', '_page', '_per_page'].includes(key)) {
continue
}
conds.push([key, Condition.default, value])
if (opts.page !== undefined) {
return paginate(results, opts.page, opts.perPage ?? 10)
}
// Loop through conditions and filter items
let filtered = items
for (const [key, op, paramValue] of conds) {
filtered = filtered.filter((item: Item) => {
if (paramValue && !Array.isArray(paramValue)) {
// https://github.com/sindresorhus/dot-prop/issues/95
const itemValue: unknown = getProperty(item, key)
switch (op) {
// item_gt=value
case Condition.gt: {
if (!(typeof itemValue === 'number' && itemValue > parseInt(paramValue))) {
return false
}
break
}
// item_gte=value
case Condition.gte: {
if (!(typeof itemValue === 'number' && itemValue >= parseInt(paramValue))) {
return false
}
break
}
// item_lt=value
case Condition.lt: {
if (!(typeof itemValue === 'number' && itemValue < parseInt(paramValue))) {
return false
}
break
}
// item_lte=value
case Condition.lte: {
if (!(typeof itemValue === 'number' && itemValue <= parseInt(paramValue))) {
return false
}
break
}
// item_ne=value
case Condition.ne: {
switch (typeof itemValue) {
case 'number':
return itemValue !== parseInt(paramValue)
case 'string':
return itemValue !== paramValue
case 'boolean':
return itemValue !== (paramValue === 'true')
}
break
}
// item=value
case Condition.default: {
switch (typeof itemValue) {
case 'number':
return itemValue === parseInt(paramValue)
case 'string':
return itemValue === paramValue
case 'boolean':
return itemValue === (paramValue === 'true')
case 'undefined':
return false
}
}
}
}
return true
})
}
// Sort
const sort = query._sort || ''
const sorted = sortOn(filtered, sort.split(','))
// Slice
const start = query._start
const end = query._end
const limit = query._limit
if (start !== undefined) {
if (end !== undefined) {
return sorted.slice(start, end)
}
return sorted.slice(start, start + (limit || 0))
}
if (limit !== undefined) {
return sorted.slice(0, limit)
}
// Paginate
let page = query._page
const perPage = query._per_page || 10
if (page) {
const items = sorted.length
const pages = Math.ceil(items / perPage)
// Ensure page is within the valid range
page = Math.max(1, Math.min(page, pages))
const first = 1
const prev = page > 1 ? page - 1 : null
const next = page < pages ? page + 1 : null
const last = pages
const start = (page - 1) * perPage
const end = start + perPage
const data = sorted.slice(start, end)
return {
first,
prev,
next,
last,
pages,
items,
data,
}
}
return sorted.slice(start, end)
return results
}
async create(name: string, data: Omit<Item, 'id'> = {}): Promise<Item | undefined> {

7
src/where-operators.ts Normal file
View File

@@ -0,0 +1,7 @@
export const WHERE_OPERATORS = ['lt', 'lte', 'gt', 'gte', 'eq', 'ne'] as const
export type WhereOperator = (typeof WHERE_OPERATORS)[number]
export function isWhereOperator(value: string): value is WhereOperator {
return (WHERE_OPERATORS as readonly string[]).includes(value)
}