From 2eefb138accf42fa15abfb7a8075ea7b933c905a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jannosch=20Mu=CC=88ller?= Date: Wed, 3 Jun 2020 22:14:53 +0200 Subject: [PATCH] feat: improve search customizability, efficiency --- README.md | 18 ++++++++++--- src/getList/searchList.spec.ts | 44 +++++++++++++++++++++++++++++++ src/getList/searchList.ts | 47 +++++++++++++++++++++------------- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 235c11e..2c61e74 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,9 @@ app.use( ...findOptions, where: { [Op.or]: [ - { address: { [Op.ilike]: `${q}%` } }, - { zipCode: { [Op.ilike]: `${q}%` } }, - { city: { [Op.ilike]: `${q}%` } }, + { address: { [Op.iLike]: `${q}%` } }, + { zipCode: { [Op.iLike]: `${q}%` } }, + { city: { [Op.iLike]: `${q}%` } }, ], }, }) @@ -107,7 +107,17 @@ When searching `some stuff`, the following records will be returned in this orde 2. records that have searchable fields that contain both `some` and `stuff` 3. records that have searchable fields that contain one of `some` or `stuff` -The search is case insensitive. +The search is case insensitive by default. You can customize the search to make it case sensitive or use a scope: + +```ts +import { Op } from 'sequelize' + +const search = searchFields(User, ['address', 'zipCode', 'city'], Op.like) + +crud('/admin/users', User, { + search: (q, limit) => search(q, limit, { ownerId: req.user.id }), +}) +``` #### Filters diff --git a/src/getList/searchList.spec.ts b/src/getList/searchList.spec.ts index 20f3fe0..4127a58 100644 --- a/src/getList/searchList.spec.ts +++ b/src/getList/searchList.spec.ts @@ -48,4 +48,48 @@ describe('crud', () => { }, ]) }) + + it('supports alternate comparators', () => { + expect(prepareQueries(['field1'])('some mustach', Op.like)).toEqual([ + { + [Op.or]: [ + { + field1: { [Op.like]: '%some mustach%' }, + }, + ], + }, + { + [Op.and]: [ + { + [Op.or]: [{ field1: { [Op.like]: '%some%' } }], + }, + { + [Op.or]: [{ field1: { [Op.like]: '%mustach%' } }], + }, + ], + }, + { + [Op.or]: [ + { + [Op.or]: [{ field1: { [Op.like]: '%some%' } }], + }, + { + [Op.or]: [{ field1: { [Op.like]: '%mustach%' } }], + }, + ], + }, + ]) + }) + + it('does only one lookup for single tokens', () => { + expect(prepareQueries(['field1'])('mustach')).toEqual([ + { + [Op.or]: [ + { + field1: { [Op.iLike]: '%mustach%' }, + }, + ], + }, + ]) + }) }) diff --git a/src/getList/searchList.ts b/src/getList/searchList.ts index 4113074..84b6bc6 100644 --- a/src/getList/searchList.ts +++ b/src/getList/searchList.ts @@ -8,13 +8,14 @@ export type GetSearchList = ( export const searchFields = ( model: { findAll: (findOptions: FindOptions) => Promise }, - searchableFields: string[] -) => async (q: string, limit: number) => { + searchableFields: string[], + comparator: symbol = Op.iLike +) => async (q: string, limit: number, scope: FindOptions = {}) => { const resultChunks = await Promise.all( - prepareQueries(searchableFields)(q).map(filters => + prepareQueries(searchableFields)(q, comparator).map(query => model.findAll({ limit, - where: filters, + where: { ...query, ...scope }, raw: true, }) ) @@ -25,7 +26,10 @@ export const searchFields = ( return { rows, count: rows.length } } -export const prepareQueries = (searchableFields: string[]) => (q: string) => { +export const prepareQueries = (searchableFields: string[]) => ( + q: string, + comparator: symbol = Op.iLike +) => { if (!searchableFields) { // TODO: we could propose a default behavior based on model rawAttributes // or (maybe better) based on existing indexes. This can be complexe @@ -34,33 +38,40 @@ export const prepareQueries = (searchableFields: string[]) => (q: string) => { 'You must provide searchableFields option to use the "q" filter in express-sequelize-crud' ) } - const splittedQuery = q.split(' ') + const defaultQuery = { + [Op.or]: searchableFields.map(field => ({ + [field]: { + [comparator]: `%${q}%`, + }, + })), + } + + const tokens = q.split(/\s+/).filter(token => token !== '') + if (tokens.length < 2) return [defaultQuery] + + // query consists of multiple tokens => do multiple searches return [ // priority to unsplit match - { - [Op.or]: searchableFields.map(field => ({ - [field]: { - [Op.iLike]: `%${q}%`, - }, - })), - }, + defaultQuery, + // then search records with all tokens { - [Op.and]: splittedQuery.map(token => ({ + [Op.and]: tokens.map(token => ({ [Op.or]: searchableFields.map(field => ({ [field]: { - [Op.iLike]: `%${token}%`, + [comparator]: `%${token}%`, }, })), })), }, - // // then search records with at least one token + + // then search records with at least one token { - [Op.or]: splittedQuery.map(token => ({ + [Op.or]: tokens.map(token => ({ [Op.or]: searchableFields.map(field => ({ [field]: { - [Op.iLike]: `%${token}%`, + [comparator]: `%${token}%`, }, })), })),