Skip to content

Commit

Permalink
Merge pull request #93 from nicgirault/async-filters
Browse files Browse the repository at this point in the history
async filters + getList, search, getOne => get + additionalAttributes
  • Loading branch information
nicgirault authored Aug 19, 2023
2 parents ae62244 + ccf5ca0 commit 14afa9a
Show file tree
Hide file tree
Showing 9 changed files with 221 additions and 140 deletions.
109 changes: 89 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# express-crud-router

[![codecov](https://codecov.io/gh/lalalilo/express-crud-router/branch/master/graph/badge.svg)](https://codecov.io/gh/lalalilo/express-crud-router) [![CircleCI](https://circleci.com/gh/lalalilo/express-crud-router.svg?style=svg)](https://circleci.com/gh/lalalilo/express-crud-router)
[![codecov](https://codecov.io/gh/nicgirault/express-crud-router/branch/master/graph/badge.svg)](https://codecov.io/gh/nicgirault/express-crud-router) [![CircleCI](https://circleci.com/gh/nicgirault/express-crud-router.svg?style=svg)](https://circleci.com/gh/nicgirault/express-crud-router)

Expose resource CRUD (Create Read Update Delete) routes in your Express app. Compatible with [React Admin Simple Rest Data Provider](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest). The lib is ORM agnostic. [List of existing ORM connectors](https://www.npmjs.com/search?q=keywords:express-crud-router-connector).

Expand All @@ -9,9 +9,8 @@ import crud from 'express-crud-router'

app.use(
crud('/admin/users', {
getList: ({ filter, limit, offset, order }) =>
get: ({ filter, limit, offset, order }) =>
User.findAndCountAll({ limit, offset, order, where: filter }),
getOne: id => User.findByPk(id),
create: body => User.create(body),
update: (id, body) => User.update(body, { where: { id } }),
destroy: id => User.destroy({ where: { id } }),
Expand Down Expand Up @@ -78,13 +77,64 @@ app.use(
crud('/admin/users', sequelizeCrud(User), {
filters: {
email: value => ({
[Op.iLike]: value,
email: {
[Op.iLike]: value,
},
}),
},
})
)
```

Custom filter handlers can be asynchronous. It makes it possible to filter based on properties of a related record. For example if we consider a blog database schema where posts are related to categories, one can filter posts by category name thanks to the following filter:

```ts
crud('/admin/posts', actions, {
filters: {
categoryName: async value => {
const category = await Category.findOne({ name: value }).orFail()

return {
categoryId: category.id,
}
},
},
})
```

Notes:

- the filter key (here categoryName) won't be passed to the underlying action handler.
- there is no support of conflicting attributes. In the following code, one filter will override the effect of the other filter. There is no garantee on which filter will be prefered.

```ts
crud('/admin/posts', actions, {
filters: {
key1: async value => ({
conflictingKey: 'hello',
}),
key2: async value => ({
conflictingKey: 'world',
}),
},
})
```

### Additional attributes

Additional attributes can be populated in the read views. For example one can add a count of related records like this:

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

### Custom behavior & other ORMs

```ts
Expand All @@ -95,9 +145,8 @@ import { User } from './models'
const app = new express()
app.use(
crud('/admin/users', {
getList: ({ filter, limit, offset, order }, { req, res }) =>
get: ({ filter, limit, offset, order }, { req, res }) =>
User.findAndCountAll({ limit, offset, order, where: filter }),
getOne: (id, { req, res }) => User.findByPk(id),
create: (body, { req, res }) => User.create(body),
update: (id, body, { req, res }) => User.update(body, { where: { id } }),
destroy: (id, { req, res }) => User.destroy({ where: { id } }),
Expand All @@ -109,16 +158,15 @@ An ORM connector is a lib exposing an object of following shape:

```typescript
interface Actions<R> {
getOne: (identifier: string) => Promise<R | null>
create: (body: R) => Promise<R & { id: number | string }>
destroy: (id: string) => Promise<any>
update: (id: string, data: R) => Promise<any>
getList: GetList<R> = (conf: {
get: GetList<R> = (conf: {
filter: Record<string, any>
limit: number
offset: number
order: Array<[string, string]>
}) => Promise<{ rows: R[]; count: number }>
create: (body: R) => Promise<R & { id: number | string }>
destroy: (id: string) => Promise<any>
update: (id: string, data: R) => Promise<any>
}
```

Expand All @@ -130,27 +178,44 @@ When using react-admin autocomplete reference field, a request is done to the AP

```ts
app.use(
crud('/admin/users', {
search: async (q, limit) => {
const { rows, count } = await User.findAndCountAll({
limit,
where: {
crud('/admin/users', , sequelizeCrud(User), {
filters: {
q: q => ({
[Op.or]: [
{ address: { [Op.iLike]: `${q}%` } },
{ zipCode: { [Op.iLike]: `${q}%` } },
{ city: { [Op.iLike]: `${q}%` } },
],
},
})

return { rows, count }
}),
},
})
)
```

express-crud-router ORM connectors might expose some search behaviors.

### Recipies

#### Generic filter on related record attributes

```ts
crud('/admin/posts', actions, {
filters: {
category: async categoryFilters => {
const categories = await Category.find(categoryFilters)

return {
categoryId: categories.map(category => category.id),
}
},
},
})
```

This code allows to perform queries such as:

`/admin/posts?filter={"category": {"name": "recipies"}}`

## Contribute

This lib uses [semantic-release](https://github.com/semantic-release/semantic-release). You need to write your commits following this nomenclature:
Expand All @@ -171,3 +236,7 @@ feat: my commit
BREAKING CHANGE: detail here
```

## Thanks

Thank you to [Lalilo](https://www.welcometothejungle.com/fr/companies/lalilo) who made this library live.
9 changes: 4 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"types": "./lib/index.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/lalalilo/express-crud-router.git"
"url": "git+https://github.com/nicgirault/express-crud-router.git"
},
"author": "nicgirault <nic.girault@gmail.com>",
"keywords": [
Expand All @@ -20,9 +20,9 @@
"express"
],
"bugs": {
"url": "https://github.com/lalalilo/express-crud-router/issues"
"url": "https://github.com/nicgirault/express-crud-router/issues"
},
"homepage": "https://github.com/lalalilo/express-crud-router#readme",
"homepage": "https://github.com/nicgirault/express-crud-router#readme",
"license": "MIT",
"scripts": {
"build": "rimraf lib && babel src -d lib --extensions '.ts' && tsc",
Expand Down Expand Up @@ -62,8 +62,7 @@
]
},
"dependencies": {
"body-parser": "^1.19.0",
"lodash": "^4.17.15"
"body-parser": "^1.19.0"
},
"release": {
"branches": [
Expand Down
92 changes: 50 additions & 42 deletions src/getList/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { RequestHandler, Request, Response } from 'express'
import mapValues from 'lodash/mapValues'
import pLimit from 'p-limit';

import { setGetListHeaders } from './headers'

export type GetList<R> = (conf: {
export type Get<R> = (conf: {
filter: Record<string, any>
limit: number
offset: number
Expand All @@ -13,49 +13,43 @@ export type GetList<R> = (conf: {
res: Response
}) => Promise<{ rows: R[]; count: number }>

export type Search<R> = (
q: string,
limit: number,
filter: Record<string, any>,
opts?: { req: Request, res: Response }
) => Promise<{ rows: R[]; count: number }>
export interface GetListOptions<R> {
filters: FiltersOption
additionalAttributes: (record: R) => object | Promise<object>
additionalAttributesConcurrency: number
}

type FiltersOption = Record<string, (value: any) => any>

export const getMany = <R>(
doGetFilteredList: GetList<R>,
doGetSearchList?: Search<R>,
filtersOption?: FiltersOption
doGetFilteredList: Get<R>,
options?: Partial<GetListOptions<R>>
): RequestHandler => async (req, res, next) => {
try {
const { q, limit, offset, filter, order } = parseQuery(
const { limit, offset, filter, order } = await parseQuery(
req.query,
filtersOption
options?.filters ?? {}
)

const { rows, count } = await doGetFilteredList({
filter,
limit,
offset,
order,
}, { req, res })
setGetListHeaders(res, offset, count, rows.length)
res.json(
options?.additionalAttributes
? await computeAdditionalAttributes(options.additionalAttributes, options.additionalAttributesConcurrency ?? 1)(rows)
: rows
)

if (!q) {
const { rows, count } = await doGetFilteredList({
filter,
limit,
offset,
order,
}, {req, res})
setGetListHeaders(res, offset, count, rows.length)
res.json(rows)
} else {
if (!doGetSearchList) {
return res.status(400).json({
error: 'Search has not been implemented yet for this resource',
})
}
const { rows, count } = await doGetSearchList(q, limit, filter, {req, res})
setGetListHeaders(res, offset, count, rows.length)
res.json(rows)
}
} catch (error) {
next(error)
}
}

export const parseQuery = (query: any, filtersOption?: FiltersOption) => {
export const parseQuery = async (query: any, filtersOption: FiltersOption) => {
const { range, sort, filter } = query

const [from, to] = range ? JSON.parse(range) : [0, 10000]
Expand All @@ -65,21 +59,35 @@ export const parseQuery = (query: any, filtersOption?: FiltersOption) => {
return {
offset: from,
limit: to - from + 1,
filter: getFilter(filters, filtersOption),
filter: await getFilter(filters, filtersOption),
order: [sort ? JSON.parse(sort) : ['id', 'ASC']] as [[string, string]],
q,
}
}

const getFilter = (
const getFilter = async (
filter: Record<string, any>,
filtersOption?: FiltersOption
) =>
mapValues(filter, (value, key) => {
filtersOption: FiltersOption
) => {
const result: Record<string, any> = {}

for (const [key, value] of Object.entries(filter)) {
if (filtersOption && filtersOption[key]) {
return filtersOption[key](value)
Object.assign(result, await filtersOption[key]!(value))
} else {
result[key] = value
}
return value
})
}

return result
}

export type FiltersOption = Record<string, (value: any) => any>

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

return (records: R[]) => Promise.all(records.map(record =>
limit(async () => ({ ...record, ...await additionalAttributes(record) }))
))
}
19 changes: 12 additions & 7 deletions src/getOne.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { RequestHandler, Request, Response } from 'express'
import { Get } from './getList'

export type GetOne<R> = (identifier: string, opts?: { req: Request, res: Response }) => Promise<R | null>

export const getOne = <R>(doGetOne: GetOne<R>): RequestHandler => async (
export const getOne = <R>(doGetList: Get<R>): RequestHandler => async (
req,
res,
next
) => {
try {
const record = await doGetOne(req.params.id, { req, res })

if (!record) {
const { rows } = await doGetList({
filter: {
id: req.params.id
},
limit: 1,
offset: 0,
order: []
}, { req, res })
if (rows.length === 0) {
return res.status(404).json({ error: 'Record not found' })
}
res.json(record)
res.json(rows[0])
} catch (error) {
next(error)
}
Expand Down
Loading

0 comments on commit 14afa9a

Please sign in to comment.