mirror of
https://github.com/typicode/json-server.git
synced 2026-03-13 09:35:37 +08:00
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:
101
README.md
101
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
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
|
||||
130
src/app.ts
130
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<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
39
src/matches-where.test.ts
Normal 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
72
src/matches-where.ts
Normal 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
|
||||
}
|
||||
@@ -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
107
src/paginate.test.ts
Normal 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
38
src/paginate.ts
Normal 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
93
src/parse-where.test.ts
Normal 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
60
src/parse-where.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
207
src/service.ts
207
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<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
7
src/where-operators.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user