feat: add object support

This commit is contained in:
typicode
2024-01-05 01:55:49 +01:00
parent dfc99f03cf
commit b985487afb
5 changed files with 161 additions and 60 deletions

View File

@@ -24,7 +24,10 @@ Create a `db.json` (or `db.json5`) file
"comments": [
{ "id": "1", "text": "a comment about post 1", "postId": "1" },
{ "id": "2", "text": "another comment about post 1", "postId": "1" }
]
],
"profile": {
"name": "typicode"
}
}
```
@@ -64,6 +67,12 @@ PATCH /posts/:id
DELETE /posts/:id
```
```
GET /profile
PUT /profile
PATCH /profile
```
## Params
### Conditions

View File

@@ -38,6 +38,7 @@ const db = new Low<Data>(new Memory<Data>(), {})
db.data = {
posts: [{ id: '1', title: 'foo' }],
comments: [{ id: '1', postId: '1' }],
object: { f1: 'foo' },
}
const app = createApp(db, { static: [tmpDir] })
@@ -58,6 +59,8 @@ await test('createApp', async (t) => {
const COMMENTS = '/comments'
const POST_COMMENTS = '/comments?postId=1'
const NOT_FOUND = '/not-found'
const OBJECT = '/object'
const OBJECT_1 = '/object/1'
const arr: Test[] = [
// Static
@@ -74,25 +77,35 @@ await test('createApp', async (t) => {
{ method: 'GET', url: POST_NOT_FOUND, statusCode: 404 },
{ method: 'GET', url: COMMENTS, statusCode: 200 },
{ method: 'GET', url: POST_COMMENTS, statusCode: 200 },
{ method: 'GET', url: OBJECT, statusCode: 200 },
{ method: 'GET', url: OBJECT_1, statusCode: 404 },
{ method: 'GET', url: NOT_FOUND, statusCode: 404 },
{ method: 'POST', url: POSTS, statusCode: 201 },
{ method: 'POST', url: POST_1, statusCode: 404 },
{ method: 'POST', url: POST_NOT_FOUND, statusCode: 404 },
{ method: 'POST', url: OBJECT, statusCode: 404 },
{ method: 'POST', url: OBJECT_1, statusCode: 404 },
{ method: 'POST', url: NOT_FOUND, statusCode: 404 },
{ method: 'PUT', url: POSTS, statusCode: 404 },
{ method: 'PUT', url: POST_1, statusCode: 200 },
{ method: 'PUT', url: OBJECT, statusCode: 200 },
{ method: 'PUT', url: OBJECT_1, statusCode: 404 },
{ method: 'PUT', url: POST_NOT_FOUND, statusCode: 404 },
{ method: 'PUT', url: NOT_FOUND, statusCode: 404 },
{ method: 'PATCH', url: POSTS, statusCode: 404 },
{ method: 'PATCH', url: POST_1, statusCode: 200 },
{ method: 'PATCH', url: OBJECT, statusCode: 200 },
{ method: 'PATCH', url: OBJECT_1, statusCode: 404 },
{ method: 'PATCH', url: POST_NOT_FOUND, statusCode: 404 },
{ method: 'PATCH', url: NOT_FOUND, statusCode: 404 },
{ method: 'DELETE', url: POSTS, statusCode: 404 },
{ method: 'DELETE', url: POST_1, statusCode: 200 },
{ method: 'DELETE', url: OBJECT, statusCode: 404 },
{ method: 'DELETE', url: OBJECT_1, statusCode: 404 },
{ method: 'DELETE', url: POST_NOT_FOUND, statusCode: 404 },
{ method: 'DELETE', url: NOT_FOUND, statusCode: 404 },
]

View File

@@ -66,10 +66,26 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
next()
})
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/:id', async (req, res, next) => {
const { name = '', id = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await service.update(name, id, req.body)
res.locals['data'] = await service.updateById(name, id, req.body)
}
next()
})
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()
})
@@ -77,14 +93,14 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
app.patch('/:name/:id', async (req, res, next) => {
const { name = '', id = '' } = req.params
if (isItem(req.body)) {
res.locals['data'] = await service.patch(name, id, req.body)
res.locals['data'] = await service.patchById(name, id, req.body)
}
next()
})
app.delete('/:name/:id', async (req, res, next) => {
const { name = '', id = '' } = req.params
res.locals['data'] = await service.destroy(name, id)
res.locals['data'] = await service.destroyById(name, id)
next()
})

View File

@@ -13,6 +13,8 @@ const service = new Service(db)
const POSTS = 'posts'
const COMMENTS = 'comments'
const OBJECT = 'object'
const UNKNOWN_RESOURCE = 'xxx'
const UNKNOWN_ID = 'xxx'
@@ -40,10 +42,15 @@ const post3 = {
const comment1 = { id: '1', title: 'a', postId: '1' }
const items = 3
const obj = {
f1: 'foo',
}
function reset() {
db.data = structuredClone({
posts: [post1, post2, post3],
comments: [comment1],
object: obj,
})
}
@@ -57,6 +64,8 @@ type Test = {
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'] }), {
@@ -236,6 +245,10 @@ await test('find', async (t) => {
name: UNKNOWN_RESOURCE,
res: undefined,
},
{
name: OBJECT,
res: obj,
},
]
for (const tc of arr) {
await t.test(`${tc.name} ${JSON.stringify(tc.params)}`, () => {
@@ -261,44 +274,65 @@ await test('create', async () => {
})
await test('update', async () => {
reset()
const obj = { f1: 'bar' }
const res = await service.update(OBJECT, obj)
assert.equal(res, obj)
assert.equal(
await service.update(UNKNOWN_RESOURCE, obj),
undefined,
'should ignore unknown resources',
)
assert.equal(
await service.update(POSTS, {}),
undefined,
'should ignore arrays',
)
})
await test('updateById', async () => {
reset()
const post = { id: 'xxx', title: 'updated post' }
const res = await service.update(POSTS, post1.id, post)
const res = await service.updateById(POSTS, post1.id, post)
assert.equal(res?.['id'], post1.id, 'id should not change')
assert.equal(res?.['title'], post.title)
assert.equal(
await service.update(UNKNOWN_RESOURCE, post1.id, post),
await service.updateById(UNKNOWN_RESOURCE, post1.id, post),
undefined,
)
assert.equal(await service.update(POSTS, UNKNOWN_ID, post), undefined)
assert.equal(await service.updateById(POSTS, UNKNOWN_ID, post), undefined)
})
await test('patch', async () => {
await test('patchById', async () => {
reset()
const post = { id: 'xxx', title: 'updated post' }
const res = await service.patch(POSTS, post1.id, post)
const res = await service.patchById(POSTS, post1.id, post)
assert.notEqual(res, undefined)
assert.equal(res?.['id'], post1.id)
assert.equal(res?.['title'], post.title)
assert.equal(await service.patch(UNKNOWN_RESOURCE, post1.id, post), undefined)
assert.equal(await service.patch(POSTS, UNKNOWN_ID, post), undefined)
assert.equal(
await service.patchById(UNKNOWN_RESOURCE, post1.id, post),
undefined,
)
assert.equal(await service.patchById(POSTS, UNKNOWN_ID, post), undefined)
})
await test('destroy', async () => {
reset()
let prevLength = db.data?.[POSTS]?.length || 0
await service.destroy(POSTS, post1.id)
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 }])
reset()
prevLength = db.data?.[POSTS]?.length || 0
await service.destroy(POSTS, post1.id, [COMMENTS])
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.destroy(UNKNOWN_RESOURCE, post1.id), undefined)
assert.equal(await service.destroy(POSTS, UNKNOWN_ID), undefined)
assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined)
assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined)
})

View File

@@ -7,7 +7,7 @@ import sortOn from 'sort-on'
export type Item = Record<string, unknown>
export type Data = Record<string, Item[]>
export type Data = Record<string, Item[] | Item>
export function isItem(obj: unknown): obj is Item {
return typeof obj === 'object' && obj !== null
@@ -33,6 +33,10 @@ enum Condition {
default = '',
}
function isCondition(value: string): value is Condition {
return Object.values<string>(Condition).includes(value)
}
export type PaginatedItems = {
first: number
prev: number | null
@@ -43,10 +47,6 @@ export type PaginatedItems = {
data: Item[]
}
function isCondition(value: string): value is Condition {
return Object.values<string>(Condition).includes(value)
}
function embed(db: Low<Data>, name: string, item: Item, related: string): Item {
if (inflection.singularize(related) === related) {
const relatedData = db.data[inflection.pluralize(related)] as Item[]
@@ -81,11 +81,13 @@ function nullifyForeignKey(db: Low<Data>, name: string, id: string) {
if (key === name) return
// Nullify
items.forEach((item) => {
if (item[foreignKey] === id) {
item[foreignKey] = null
}
})
if (Array.isArray(items)) {
items.forEach((item) => {
if (item[foreignKey] === id) {
item[foreignKey] = null
}
})
}
})
}
@@ -97,7 +99,9 @@ function deleteDependents(db: Low<Data>, name: string, dependents: string[]) {
if (key === name || !dependents.includes(key)) return
// Delete if foreign key is null
db.data[key] = items.filter((item) => item[foreignKey] !== null)
if (Array.isArray(items)) {
db.data[key] = items.filter((item) => item[foreignKey] !== null)
}
})
}
@@ -108,14 +112,10 @@ export class Service {
this.#db = db
}
#get(name: string): Item[] | undefined {
#get(name: string): Item[] | Item | undefined {
return this.#db.data[name]
}
list(): string[] {
return Object.keys(this.#db?.data || {})
}
has(name: string): boolean {
return Object.prototype.hasOwnProperty.call(this.#db?.data, name)
}
@@ -125,11 +125,17 @@ export class Service {
id: string,
query: { _embed?: string[] },
): Item | undefined {
let item = this.#get(name)?.find((item) => item['id'] === id)
query._embed?.forEach((related) => {
if (item !== undefined) item = embed(this.#db, name, item, related)
})
return item
const value = this.#get(name)
if (Array.isArray(value)) {
let item = value.find((item) => item['id'] === id)
query._embed?.forEach((related) => {
if (item !== undefined) item = embed(this.#db, name, item, related)
})
return item
}
return
}
find(
@@ -145,15 +151,16 @@ export class Service {
_page?: number
_per_page?: number
} = {},
): Item[] | PaginatedItems | undefined {
): Item[] | PaginatedItems | Item | undefined {
let items = this.#get(name)
// Not found
if (items === undefined) return
if (!Array.isArray(items)) {
return items
}
// Include
query._embed?.forEach((related) => {
if (items !== undefined)
if (items !== undefined && Array.isArray(items))
items = items.map((item) => embed(this.#db, name, item, related))
})
@@ -168,7 +175,7 @@ export class Service {
if (value === undefined || typeof value !== 'string') {
continue
}
const re = /_(lt|lte|gt|gte|ne|includes)$/
const re = /_(lt|lte|gt|gte|ne)$/
const reArr = re.exec(key)
const op = reArr?.at(1)
if (op && isCondition(op)) {
@@ -308,7 +315,7 @@ export class Service {
data: Omit<Item, 'id'> = {},
): Promise<Item | undefined> {
const items = this.#get(name)
if (items === undefined) return
if (items === undefined || !Array.isArray(items)) return
const item = { id: randomBytes(2).toString('hex'), ...data }
items.push(item)
@@ -318,13 +325,27 @@ export class Service {
}
async #updateOrPatch(
name: string,
body: Item = {},
isPatch: boolean,
): Promise<Item | undefined> {
const item = this.#get(name)
if (item === undefined || Array.isArray(item)) return
const nextItem = (this.#db.data[name] = isPatch ? { item, ...body } : body)
await this.#db.write()
return nextItem
}
async #updateOrPatchById(
name: string,
id: string,
body: Omit<Item, 'id'> = {},
body: Item = {},
isPatch: boolean,
): Promise<Item | undefined> {
const items = this.#get(name)
if (items === undefined) return
if (items === undefined || !Array.isArray(items)) return
const item = items.find((item) => item['id'] === id)
if (!item) return
@@ -337,32 +358,40 @@ export class Service {
return nextItem
}
async update(
name: string,
id: string,
body: Omit<Item, 'id'> = {},
): Promise<Item | undefined> {
return this.#updateOrPatch(name, id, body, false)
async update(name: string, body: Item = {}): Promise<Item | undefined> {
return this.#updateOrPatch(name, body, false)
}
async patch(
name: string,
id: string,
body: Omit<Item, 'id'> = {},
): Promise<Item | undefined> {
return this.#updateOrPatch(name, id, body, true)
async patch(name: string, body: Item = {}): Promise<Item | undefined> {
return this.#updateOrPatch(name, body, true)
}
async destroy(
async updateById(
name: string,
id: string,
body: Item = {},
): Promise<Item | undefined> {
return this.#updateOrPatchById(name, id, body, false)
}
async patchById(
name: string,
id: string,
body: Item = {},
): Promise<Item | undefined> {
return this.#updateOrPatchById(name, id, body, true)
}
async destroyById(
name: string,
id: string,
dependents: string[] = [],
): Promise<Item | undefined> {
const items = this.#get(name)
if (items === undefined) return
if (items === undefined || !Array.isArray(items)) return
const item = items.find((item) => item['id'] === id)
if (!item) return
if (item === undefined) return
const index = items.indexOf(item)
items.splice(index, 1)[0]