Skip to content

Commit

Permalink
feat: add additional attributes helpers
Browse files Browse the repository at this point in the history
BREAKING CHANGE: additionalAttributes option format changed
  • Loading branch information
nicgirault committed Sep 15, 2023
1 parent 0276cde commit f7b6fd3
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 26 deletions.
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,45 @@ Additional attributes can be populated in the read views. For example one can ad

```ts
crud('/admin/categories', actions, {
additionalAttributes: async category => {
return {
postsCount: await Post.count({ categoryId: category.id })
}
additionalAttributes: {
postsCount: category => Post.count({ categoryId: category.id })
},
additionalAttributesConcurrency: 10 // 10 queries Post.count will be perform at the same time
})
```

additionalAttributes function parameters are:
- the current row
- an object: `{rows, req}` where rows are all page rows and req is the express request.

Similarly to how react-admin deals with resource references, express-crud-router provides additional field helpers:
- `populateReference`
- `populateReferenceMany`
- `populateReferenceManyCount`
- `populateReferenceOne`

Using additionalAttributes with `populateReferenceManyCount` or `populateReferenceOne` can be useful instead of using react-admin ReferenceManyCount and ReferenceOne as they are often used in list views and generate one HTTP query per row.

```ts
crud<number, { id: number }>('/users', {
get: jest.fn().mockResolvedValue({
rows: [{ id: 1 }, { id: 2 } , { id: 3 }],
count: 2
}),
}, {
additionalAttributes: {
posts: populateReferenceMany({
fetchAll: async () => [
{id: 10, authorId: 1},
{id: 11, authorId: 1},
{id: 12, authorId: 2},
],
target: 'authorId'
})
}
})
```

### Custom behavior & other ORMs

```ts
Expand Down
129 changes: 129 additions & 0 deletions src/additionalAttributeHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Request } from "express"

type RaRecord = {id: string | number}

export const populateReference = <R extends RaRecord, T extends RaRecord>({fetchAll,source, target = 'id'}: {
fetchAll: (rows: R[]) => Promise<T[]>,
source: keyof R
target?: keyof T
}) => {
const cache = new Cache()

return async (record: R, {rows, req}: {rows: R[], req: Request}) => {
let referencesByTarget = cache.get(req)

if (!referencesByTarget) {
const references = await fetchAll(rows)

referencesByTarget = references.reduce((referencesByTarget, reference) => {
referencesByTarget[reference[target ?? 'id'] as unknown as string] = reference
return referencesByTarget
}, {} as Record<string, T>)

cache.set(req, referencesByTarget)
}

return referencesByTarget[record[source] as unknown as string]
}
}

export const populateReferenceMany = <R extends RaRecord, T extends RaRecord>({fetchAll,source= 'id', target }: {
fetchAll: (rows: R[]) => Promise<T[]>,
source?: keyof R
target: keyof T
}) => {
const cache = new Cache()

return async (record: R, {rows, req}: {rows: R[], req: Request}) => {
let referencesByTarget = cache.get(req)

if (!referencesByTarget) {
const references = await fetchAll(rows)

referencesByTarget = references.reduce((referencesByTarget, reference) => {
if (!referencesByTarget[reference[target] as unknown as string]) {
referencesByTarget[reference[target] as unknown as string] = []
}
referencesByTarget[reference[target] as unknown as string].push(reference)
return referencesByTarget
}, {} as Record<string, T[]>)

cache.set(req, referencesByTarget)
}
return referencesByTarget[record[source ?? 'id'] as unknown as string] ?? []
}
}


export const populateReferenceManyCount = <R extends RaRecord, T extends RaRecord>({fetchAll,source= 'id', target }: {
fetchAll: (rows: R[]) => Promise<T[]>,
source?: keyof R
target: keyof T
}) => {
const cache = new Cache()

return async (record: R, {rows, req}: {rows: R[], req: Request}) => {
let referencesByTarget = cache.get(req)

if (!referencesByTarget) {
const references = await fetchAll(rows)

referencesByTarget = references.reduce((referencesByTarget, reference) => {
if (!referencesByTarget[reference[target] as unknown as string]) {
referencesByTarget[reference[target] as unknown as string] = []
}
referencesByTarget[reference[target] as unknown as string].push(reference)
return referencesByTarget
}, {} as Record<string, T[]>)

cache.set(req, referencesByTarget)
}

return referencesByTarget[record[source ?? 'id'] as unknown as string]?.length ?? 0
}
}

export const populateReferenceOne = <R extends RaRecord, T extends RaRecord>({fetchAll,source= 'id', target }: {
fetchAll: (rows: R[]) => Promise<T[]>,
source?: keyof R
target: keyof T,
}) => {
const getAllReferences = populateReferenceMany({fetchAll,source, target})

return async (record: R, {rows, req}: {rows: R[], req: Request}) => {
const references = await getAllReferences(record, {rows,req})
return references[0]
}
}

let id = 1

class Cache {
private id: number

constructor() {
this.id = id
id += 1
}

get = (req: any) =>{
if (!req.locals?.expressCrudRouter){
return undefined
}

return req.locals.expressCrudRouter[this.id]
}

set = (req: any, value: any) => {
req = req

if (!req.locals) {
req.locals = {}
}
if (!req.locals.expressCrudRouter) {
req.locals.expressCrudRouter = {}
}

req.locals.expressCrudRouter[this.id] = value
}
}
16 changes: 11 additions & 5 deletions src/getList/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type Get<R> = (conf: {

export interface GetListOptions<R> {
filters: FiltersOption
additionalAttributes: (record: R) => object | Promise<object>
additionalAttributes: Record<string, (record: R, context: {rows: R[], req: Request}) => any>
additionalAttributesConcurrency: number
}

Expand All @@ -40,7 +40,7 @@ export const getMany = <R>(
setGetListHeaders(res, offset, count, rows.length)
res.json(
options?.additionalAttributes
? await computeAdditionalAttributes(options.additionalAttributes, options.additionalAttributesConcurrency ?? 1)(rows)
? await computeAdditionalAttributes(options.additionalAttributes, options.additionalAttributesConcurrency ?? 1, req)(rows)
: rows
)

Expand Down Expand Up @@ -84,10 +84,16 @@ const getFilter = async (


const computeAdditionalAttributes =
<R>(additionalAttributes: GetListOptions<R>["additionalAttributes"], concurrency: number) => {
<R>(additionalAttributes: GetListOptions<R>["additionalAttributes"], concurrency: number, req: Request) => {
const limit = pLimit(concurrency)

return (records: R[]) => Promise.all(records.map(record =>
limit(async () => ({ ...record, ...await additionalAttributes(record) }))
return (records: R[]) => Promise.all(records.map(async record => {
const populatedRecord: any = {...record}
for (const [key, mapper] of Object.entries(additionalAttributes)) {
populatedRecord[key] = await limit(() => mapper(record, {rows: records, req}))
}

return populatedRecord
}
))
}
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getOne } from './getOne'
import { create, Create } from './create'
import { update, Update } from './update'
import { destroy, Destroy } from './delete'
import { populateReference, populateReferenceMany, populateReferenceManyCount, populateReferenceOne } from './additionalAttributeHelpers'

export interface Actions<I extends string | number, R> {
get: Get<R> | null
Expand All @@ -14,8 +15,7 @@ export interface Actions<I extends string | number, R> {
}



export { Create, Destroy, Update, Get }
export { Create, Destroy, Update, Get, populateReference, populateReferenceMany, populateReferenceManyCount, populateReferenceOne }

export const crud = <I extends string | number, R>(
path: string,
Expand Down
73 changes: 70 additions & 3 deletions tests/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Server } from 'http'
import { crud } from '../src'
import { crud, populateReferenceMany, populateReferenceManyCount } from '../src'
import { setupApp } from './app'

describe('crud', () => {
Expand Down Expand Up @@ -62,8 +62,8 @@ describe('crud', () => {
crud<number, { id: number }>('/users', {
get: jest.fn().mockResolvedValue({ rows: [{ id: 1 }], count: 1 }),
}, {
additionalAttributes: async (record) => {
return { additionalProperty: await new Promise(resolve => resolve(`value ${record.id}`)) }
additionalAttributes: {
additionalProperty: async (record) => new Promise(resolve => resolve(`value ${record.id}`))
}
}),
ctx
Expand All @@ -76,6 +76,73 @@ describe('crud', () => {
})
expect(response.data[0]).toEqual({ id: 1, additionalProperty: 'value 1' })
})

describe('populateReferenceMany', () => {
it('populate references', async () => {
const dataProvider = await setupApp(
crud<number, { id: number }>('/users', {
get: jest.fn().mockResolvedValue({ rows: [{ id: 1 }, { id: 2 } , { id: 3 }], count: 2 }),
}, {
additionalAttributes: {
posts: populateReferenceMany({
fetchAll: async () => [
{id: 10, authorId: 1},
{id: 11, authorId: 1},
{id: 12, authorId: 2},
],
target: 'authorId'
})
}
}),
ctx
)

const response = await dataProvider.getList('users', {
pagination: { page: 0, perPage: 25 },
sort: { field: 'id', order: 'DESC' },
filter: {},
})
expect(response.data[0]).toEqual({ id: 1, posts: [
{id: 10, authorId: 1},
{id: 11, authorId: 1}
] })
expect(response.data[1]).toEqual({ id: 2, posts: [
{id: 12, authorId: 2},
] })
expect(response.data[2]).toEqual({ id: 3, posts: [] })
})
})

describe('populateReferenceManyCount', () => {
it('populate reference counts', async () => {
const dataProvider = await setupApp(
crud<number, { id: number }>('/users', {
get: jest.fn().mockResolvedValue({ rows: [{ id: 1 }, { id: 2 } , { id: 3 }], count: 2 }),
}, {
additionalAttributes: {
postsCount: populateReferenceManyCount({
fetchAll: async () => [
{id: 10, authorId: 1},
{id: 11, authorId: 1},
{id: 12, authorId: 2},
],
target: 'authorId'
})
}
}),
ctx
)

const response = await dataProvider.getList('users', {
pagination: { page: 0, perPage: 25 },
sort: { field: 'id', order: 'DESC' },
filter: {},
})
expect(response.data[0]).toEqual({ id: 1, postsCount: 2 })
expect(response.data[1]).toEqual({ id: 2, postsCount: 1 })
expect(response.data[2]).toEqual({ id: 3, postsCount: 0 })
})
})
})

describe('DELETE', () => {
Expand Down
12 changes: 0 additions & 12 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5304,13 +5304,6 @@ p-is-promise@^3.0.0:
resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-3.0.0.tgz#58e78c7dfe2e163cf2a04ff869e7c1dba64a5971"
integrity sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==

p-limit@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
dependencies:
yocto-queue "^0.1.0"

p-limit@^1.1.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
Expand Down Expand Up @@ -6932,8 +6925,3 @@ yargs@^16.2.0:
string-width "^4.2.0"
y18n "^5.0.5"
yargs-parser "^20.2.2"

yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==

0 comments on commit f7b6fd3

Please sign in to comment.