diff --git a/README.md b/README.md index 75e0afe..a70782e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app.test.ts b/src/app.test.ts index 3d4bf49..904ece8 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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' }]) + }) }) diff --git a/src/app.ts b/src/app.ts index 38d9681..78f90e7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 - 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) => Promise) { + 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) => Promise, +) { + 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, options: AppOptions = {}) { // Create service const service = new Service(db) @@ -58,23 +118,15 @@ export function createApp(db: Low, 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, 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 diff --git a/src/matches-where.test.ts b/src/matches-where.test.ts new file mode 100644 index 0000000..66f00dc --- /dev/null +++ b/src/matches-where.test.ts @@ -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) + }) + } +}) diff --git a/src/matches-where.ts b/src/matches-where.ts new file mode 100644 index 0000000..07a7e7d --- /dev/null +++ b/src/matches-where.ts @@ -0,0 +1,72 @@ +import type { JsonObject } from 'type-fest' + +import { WHERE_OPERATORS, type WhereOperator } from './where-operators.ts' + +type OperatorObject = Partial> + +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)[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 +} diff --git a/src/observer.ts b/src/observer.ts index 0c07505..a44ef39 100644 --- a/src/observer.ts +++ b/src/observer.ts @@ -1,36 +1,36 @@ -import type { Adapter } from "lowdb"; +import type { Adapter } from 'lowdb' // Lowdb adapter to observe read/write events export class Observer { - #adapter; + #adapter: Adapter onReadStart = function () { - return; - }; + return + } onReadEnd: (data: T | null) => void = function () { - return; - }; + return + } onWriteStart = function () { - return; - }; + return + } onWriteEnd = function () { - return; - }; + return + } constructor(adapter: Adapter) { - 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() } } diff --git a/src/paginate.test.ts b/src/paginate.test.ts new file mode 100644 index 0000000..b988878 --- /dev/null +++ b/src/paginate.test.ts @@ -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) + }) + } +}) diff --git a/src/paginate.ts b/src/paginate.ts new file mode 100644 index 0000000..de42bf0 --- /dev/null +++ b/src/paginate.ts @@ -0,0 +1,38 @@ +export type PaginationResult = { + first: number + prev: number | null + next: number | null + last: number + pages: number + items: number + data: T[] +} + +export function paginate(items: T[], page: number, perPage: number): PaginationResult { + 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, + } +} diff --git a/src/parse-where.test.ts b/src/parse-where.test.ts new file mode 100644 index 0000000..2bf0f89 --- /dev/null +++ b/src/parse-where.test.ts @@ -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][] = [ + [ + '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) + }) + } +}) diff --git a/src/parse-where.ts b/src/parse-where.ts new file mode 100644 index 0000000..ffde648 --- /dev/null +++ b/src/parse-where.ts @@ -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 +} diff --git a/src/service.test.ts b/src/service.test.ts index ea9e0d1..a1c1119 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -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(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[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) + }) }) diff --git a/src/service.ts b/src/service.ts index 2a607da..2d77c1a 100644 --- a/src/service.ts +++ b/src/service.ts @@ -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 export type Data = Record 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 { +export function isData(obj: unknown): obj is Data { if (typeof obj !== 'object' || obj === null) { return false } const data = obj as Record - 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(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 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 = {}): Promise { diff --git a/src/where-operators.ts b/src/where-operators.ts new file mode 100644 index 0000000..ec31a04 --- /dev/null +++ b/src/where-operators.ts @@ -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) +}