From 97f6ca2e99c529903fea011b772eb9362a8c0120 Mon Sep 17 00:00:00 2001 From: Romain Gilliotte Date: Mon, 23 May 2022 15:20:37 +0200 Subject: [PATCH 1/7] feat(search): add support for case sensitive/insensitive search --- docs/agent-customization/search.md | 38 ++++++++++--- .../src/utils/query-converter.ts | 10 ++-- .../src/utils/type-converter.ts | 4 +- .../test/utils/query-converter.unit.test.ts | 6 +- .../test/utils/type-converter.unit.test.ts | 4 +- .../src/decorators/search/collection.ts | 56 +++++++++---------- .../query/condition-tree/nodes/leaf.ts | 8 ++- .../query/condition-tree/nodes/operators.ts | 4 ++ .../condition-tree/transforms/pattern.ts | 17 ++++-- .../src/validation/rules.ts | 4 ++ .../decorators/search/collections.test.ts | 18 ++++-- .../test/interfaces/condition-tree.test.ts | 5 +- .../interfaces/transforms/pattern.test.ts | 33 +++++++++++ 13 files changed, 137 insertions(+), 70 deletions(-) diff --git a/docs/agent-customization/search.md b/docs/agent-customization/search.md index c732548525..0614c72a67 100644 --- a/docs/agent-customization/search.md +++ b/docs/agent-customization/search.md @@ -15,15 +15,15 @@ When not defined otherwise by the [datasource](../datasources/README.md), the se ## Default behavior -By default, Forest Admin won't search only on some columns, depending on their respective types. +By default, Forest Admin will search only on some columns, depending on their respective types. -| Column Type | Default search behavior | -| ----------- | ---------------------------------------------------------------------------------------- | -| Enum | Column is equal to the search string (if the search string contains a value of the enum) | -| Number | Column is equal to the search string (if the search string is numeric) | -| String | Column contains the search string (case-insensitive) | -| Uuid | Column is equal to the search string (if the search string contains an uuid) | -| Other types | Column is ignored by the default search handler | +| Column Type | Default search behavior | +| ----------- | ---------------------------------------------------------------------- | +| Enum | Column is equal to the search string (case-insensitive) | +| Number | Column is equal to the search string (if the search string is numeric) | +| String | Column contains the search string (case-sensitive) | +| Uuid | Column is equal to the search string (case-sensitive) | +| Other types | Column is ignored by the default search handler | ## Customization @@ -34,6 +34,28 @@ For instance: - Search only on the columns which are relevant to your use-case. - Use a full-text indexes of your data (i.e Postgres `tsquery` and `tsvector`, Algolia, Elastic search, ...) +In order to customize the search bar, you must define a handler which returns a [`ConditionTree`](../under-the-hood/queries/filters.md#condition-trees). + +### Making the search case-insensitive + +In this example, we use the `searchExtended` condition to toggle between case-sensitive and insensitive search. + +```javascript +agent.customizeCollection('people', collection => + collection.replaceSearch((searchString, extendedMode) => { + const operator = extendedModel ? 'Contains' : 'IContains'; + + return { + aggregator: 'Or', + conditions: [ + { field: 'firstName', operator, value: searchString }, + { field: 'lastName', operator, value: searchString }, + ], + }; + }), +); +``` + ### Changing searched columns ```javascript diff --git a/packages/datasource-sequelize/src/utils/query-converter.ts b/packages/datasource-sequelize/src/utils/query-converter.ts index c093bcb0fa..6179e75630 100644 --- a/packages/datasource-sequelize/src/utils/query-converter.ts +++ b/packages/datasource-sequelize/src/utils/query-converter.ts @@ -47,10 +47,10 @@ export default class QueryConverter { return { [Op.or]: [this.makeWhereClause('Missing', field) as OrOperator, { [Op.eq]: '' }], }; - case 'Contains': - return where(fn('LOWER', col(field)), 'LIKE', `%${value.toLocaleLowerCase()}%`); - case 'EndsWith': - return where(fn('LOWER', col(field)), 'LIKE', `%${value.toLocaleLowerCase()}`); + case 'Like': + return { [Op.like]: value }; + case 'ILike': + return where(fn('LOWER', col(field)), 'LIKE', value); case 'Equal': return { [Op.eq]: value }; case 'GreaterThan': @@ -71,8 +71,6 @@ export default class QueryConverter { return { [Op.notIn]: this.asArray(value) }; case 'Present': return { [Op.ne]: null }; - case 'StartsWith': - return where(fn('LOWER', col(field)), 'LIKE', `${value.toLocaleLowerCase()}%`); default: throw new Error(`Unsupported operator: "${operator}".`); } diff --git a/packages/datasource-sequelize/src/utils/type-converter.ts b/packages/datasource-sequelize/src/utils/type-converter.ts index b4166b3dc2..70b5a92555 100644 --- a/packages/datasource-sequelize/src/utils/type-converter.ts +++ b/packages/datasource-sequelize/src/utils/type-converter.ts @@ -115,15 +115,13 @@ export default class TypeConverter { case 'String': return new Set([ ...TypeConverter.baseOperators, - 'Contains', - 'EndsWith', 'In', 'Like', + 'ILike', 'LongerThan', 'NotContains', 'NotIn', 'ShorterThan', - 'StartsWith', ]); case 'Date': case 'Dateonly': diff --git a/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts b/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts index 8a9f715017..f2049eb2c9 100644 --- a/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts +++ b/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts @@ -202,6 +202,7 @@ describe('Utils > QueryConverter', () => { describe('with a ConditionTreeLeaf node', () => { const defaultArrayValue = [21, 42, 84]; const defaultIntegerValue = 42; + const defaultStringValue = 'value'; it.each([ ['Blank', undefined, { [Op.or]: [{ [Op.is]: null }, { [Op.eq]: '' }] }], @@ -214,6 +215,7 @@ describe('Utils > QueryConverter', () => { ['NotEqual', defaultIntegerValue, { [Op.ne]: defaultIntegerValue }], ['NotIn', defaultArrayValue, { [Op.notIn]: defaultArrayValue }], ['Present', undefined, { [Op.ne]: null }], + ['Like', defaultStringValue, { [Op.like]: defaultStringValue }], ])( 'should generate a "where" Sequelize filter from a "%s" operator', (operator, value, where) => { @@ -236,9 +238,7 @@ describe('Utils > QueryConverter', () => { describe('using operator translate to SQL "LIKE" clause', () => { it.each([ - ['StartsWith', '__Value__', 'LIKE', '__value__%'], - ['EndsWith', '__vAlue__', 'LIKE', '%__value__'], - ['Contains', '__vaLue__', 'LIKE', '%__value__%'], + ['ILike', '__VaLuE__', 'LIKE', '__VaLuE__'], ['NotContains', '__valUe__', 'NOT LIKE', '%__value__%'], ])( 'should generate a "where" Sequelize filter from a "%s" operator', diff --git a/packages/datasource-sequelize/test/utils/type-converter.unit.test.ts b/packages/datasource-sequelize/test/utils/type-converter.unit.test.ts index 433f4bc042..b3cb9fd3af 100644 --- a/packages/datasource-sequelize/test/utils/type-converter.unit.test.ts +++ b/packages/datasource-sequelize/test/utils/type-converter.unit.test.ts @@ -90,11 +90,10 @@ describe('Utils > TypeConverter', () => { 'String', [ 'Blank', - 'Contains', - 'EndsWith', 'Equal', 'In', 'Like', + 'ILike', 'LongerThan', 'Missing', 'NotContains', @@ -102,7 +101,6 @@ describe('Utils > TypeConverter', () => { 'NotIn', 'Present', 'ShorterThan', - 'StartsWith', ], ], [ diff --git a/packages/datasource-toolkit/src/decorators/search/collection.ts b/packages/datasource-toolkit/src/decorators/search/collection.ts index 39debbc3e0..8d118ec3e9 100644 --- a/packages/datasource-toolkit/src/decorators/search/collection.ts +++ b/packages/datasource-toolkit/src/decorators/search/collection.ts @@ -1,10 +1,7 @@ +import { validate as uuidValidate } from 'uuid'; + import { Caller } from '../../interfaces/caller'; -import { - CollectionSchema, - ColumnSchema, - FieldSchema, - PrimitiveTypes, -} from '../../interfaces/schema'; +import { CollectionSchema, ColumnSchema, FieldSchema } from '../../interfaces/schema'; import { DataSource } from '../../interfaces/collection'; import { SearchReplacer } from './types'; import CollectionCustomizationContext from '../../context/collection-context'; @@ -13,7 +10,6 @@ import ConditionTree from '../../interfaces/query/condition-tree/nodes/base'; import ConditionTreeFactory from '../../interfaces/query/condition-tree/factory'; import ConditionTreeLeaf from '../../interfaces/query/condition-tree/nodes/leaf'; import PaginatedFilter from '../../interfaces/query/filter/paginated'; -import TypeGetter from '../../validation/type-getter'; export default class SearchCollectionDecorator extends CollectionDecorator { replacer: SearchReplacer = null; @@ -78,35 +74,33 @@ export default class SearchCollectionDecorator extends CollectionDecorator { searchString: string, ): ConditionTree { const { columnType, enumValues } = schema; - let condition: ConditionTree = null; - - const type = columnType as PrimitiveTypes; - const value = columnType === 'Number' ? Number(searchString) : searchString; - const searchType = TypeGetter.get(value, type); - - if ( - SearchCollectionDecorator.isValidEnum(enumValues, searchString, type) || - searchType === 'Number' || - searchType === 'Uuid' - ) { - condition = new ConditionTreeLeaf(field, 'Equal', value); - } else if (searchType === 'String') { - condition = new ConditionTreeLeaf(field, 'Contains', value); + const isNumber = Number(searchString).toString() === searchString; + const isUuid = uuidValidate(searchString); + + if (columnType === 'Number' && isNumber) { + return new ConditionTreeLeaf(field, 'Equal', Number(searchString)); } - return condition; + if (columnType === 'Enum') { + const searchValue = SearchCollectionDecorator.lenientFind(enumValues, searchString); + if (searchValue) return new ConditionTreeLeaf(field, 'Equal', searchValue); + } + + if (columnType === 'String') { + return new ConditionTreeLeaf(field, 'Contains', searchString); + } + + if (columnType === 'Uuid' && isUuid) { + return new ConditionTreeLeaf(field, 'Equal', searchString); + } + + return null; } - private static isValidEnum( - enumValues: string[], - searchString: string, - searchType: PrimitiveTypes, - ): boolean { + private static lenientFind(enumValues: string[], searchString: string): string { return ( - searchType === 'Enum' && - !!enumValues?.find( - enumValue => enumValue.toLocaleLowerCase() === searchString.toLocaleLowerCase().trim(), - ) + enumValues?.find(v => v === searchString.trim()) ?? + enumValues?.find(v => v.toLocaleLowerCase() === searchString.toLocaleLowerCase().trim()) ); } diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts index 9e5ba1ff28..9748c228ff 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts @@ -100,7 +100,9 @@ export default class ConditionTreeLeaf extends ConditionTree { case 'GreaterThan': return fieldValue > this.value; case 'Like': - return this.like(fieldValue as string, this.value as string); + return this.like(fieldValue as string, this.value as string, true); + case 'ILike': + return this.like(fieldValue as string, this.value as string, false); case 'LongerThan': return (fieldValue as string).length > this.value; case 'ShorterThan': @@ -131,7 +133,7 @@ export default class ConditionTreeLeaf extends ConditionTree { } /** @see https://stackoverflow.com/a/18418386/1897495 */ - private like(value: string, pattern: string): boolean { + private like(value: string, pattern: string, caseSensitive: boolean): boolean { if (!value) return false; let regexp = pattern; @@ -140,6 +142,6 @@ export default class ConditionTreeLeaf extends ConditionTree { regexp = regexp.replace(/([\.\\\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:\-])/g, '\\$1'); regexp = regexp.replace(/%/g, '.*').replace(/_/g, '.'); - return RegExp(`^${regexp}$`, 'gi').test(value); + return RegExp(`^${regexp}$`, caseSensitive ? 'g' : 'gi').test(value); } } diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/operators.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/operators.ts index 138f6ecc84..361ccd76ec 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/operators.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/operators.ts @@ -8,6 +8,7 @@ export const uniqueOperators = [ // Strings 'Like', + 'ILike', 'NotContains', 'LongerThan', 'ShorterThan', @@ -46,6 +47,9 @@ export const otherOperators = [ 'StartsWith', 'EndsWith', 'Contains', + 'IStartsWith', + 'IEndsWith', + 'IContains', // Dates 'Before', diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/transforms/pattern.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/transforms/pattern.ts index 39a6dd80cd..80a3dfcfc4 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/transforms/pattern.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/transforms/pattern.ts @@ -1,16 +1,21 @@ import { Alternative } from '../equivalence'; import { Operator } from '../nodes/operators'; -function likes(getPattern: (pattern: string) => string): Alternative { +function likes(getPattern: (pattern: string) => string, caseSensitive: boolean): Alternative { + const operator = caseSensitive ? 'Like' : 'ILike'; + return { - dependsOn: ['Like'], + dependsOn: [operator], forTypes: ['String'], - replacer: leaf => leaf.override({ operator: 'Like', value: getPattern(leaf.value as string) }), + replacer: leaf => leaf.override({ operator, value: getPattern(leaf.value as string) }), }; } export default (): Partial> => ({ - Contains: [likes(value => `%${value}%`)], - StartsWith: [likes(value => `${value}%`)], - EndsWith: [likes(value => `%${value}`)], + Contains: [likes(value => `%${value}%`, true)], + StartsWith: [likes(value => `${value}%`, true)], + EndsWith: [likes(value => `%${value}`, true)], + IContains: [likes(value => `%${value}%`, false)], + IStartsWith: [likes(value => `${value}%`, false)], + IEndsWith: [likes(value => `%${value}`, false)], }); diff --git a/packages/datasource-toolkit/src/validation/rules.ts b/packages/datasource-toolkit/src/validation/rules.ts index e06d75fd00..8e09b7cefc 100644 --- a/packages/datasource-toolkit/src/validation/rules.ts +++ b/packages/datasource-toolkit/src/validation/rules.ts @@ -38,6 +38,10 @@ export const MAP_ALLOWED_OPERATORS_FOR_COLUMN_TYPE: Readonly< 'LongerThan', 'ShorterThan', 'Like', + 'ILike', + 'IContains', + 'IEndsWith', + 'IStartsWith', ], Number: [...BASE_OPERATORS, ...ARRAY_OPERATORS, 'GreaterThan', 'LessThan'], Dateonly: [...BASE_OPERATORS, ...BASE_DATEONLY_OPERATORS], diff --git a/packages/datasource-toolkit/test/decorators/search/collections.test.ts b/packages/datasource-toolkit/test/decorators/search/collections.test.ts index c7c9ffc484..fe44923e69 100644 --- a/packages/datasource-toolkit/test/decorators/search/collections.test.ts +++ b/packages/datasource-toolkit/test/decorators/search/collections.test.ts @@ -226,6 +226,10 @@ describe('SearchCollectionDecorator', () => { columnType: 'Number', filterOperators: new Set(['Equal']), }), + fieldName2: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['Contains']), + }), }, }), }); @@ -240,7 +244,13 @@ describe('SearchCollectionDecorator', () => { ); expect(refinedFilter).toEqual({ search: null, - conditionTree: { field: 'fieldName', operator: 'Equal', value: 1584 }, + conditionTree: { + aggregator: 'Or', + conditions: [ + { field: 'fieldName', operator: 'Equal', value: 1584 }, + { field: 'fieldName2', operator: 'Contains', value: '1584' }, + ], + }, }); }); }); @@ -252,14 +262,14 @@ describe('SearchCollectionDecorator', () => { fields: { fieldName: factories.columnSchema.build({ columnType: 'Enum', - enumValues: ['AEnumValue'], + enumValues: ['AnEnUmVaLue'], filterOperators: new Set(['Equal']), }), }, }), }); - const filter = factories.filter.build({ search: 'AEnumValue' }); + const filter = factories.filter.build({ search: 'anenumvalue' }); const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); @@ -269,7 +279,7 @@ describe('SearchCollectionDecorator', () => { ); expect(refinedFilter).toEqual({ search: null, - conditionTree: { field: 'fieldName', operator: 'Equal', value: 'AEnumValue' }, + conditionTree: { field: 'fieldName', operator: 'Equal', value: 'AnEnUmVaLue' }, }); }); diff --git a/packages/datasource-toolkit/test/interfaces/condition-tree.test.ts b/packages/datasource-toolkit/test/interfaces/condition-tree.test.ts index 595ab400ab..a7da3dbd5f 100644 --- a/packages/datasource-toolkit/test/interfaces/condition-tree.test.ts +++ b/packages/datasource-toolkit/test/interfaces/condition-tree.test.ts @@ -344,9 +344,8 @@ describe('ConditionTree', () => { }); const allConditions = new ConditionTreeBranch('And', [ new ConditionTreeLeaf('string', 'Present'), - new ConditionTreeLeaf('string', 'Contains', 'value'), - new ConditionTreeLeaf('string', 'StartsWith', 'value'), - new ConditionTreeLeaf('string', 'EndsWith', 'value'), + new ConditionTreeLeaf('string', 'Like', '%value%'), + new ConditionTreeLeaf('string', 'ILike', '%VaLuE%'), new ConditionTreeLeaf('string', 'LessThan', 'valuf'), new ConditionTreeLeaf('string', 'Equal', 'value'), new ConditionTreeLeaf('string', 'GreaterThan', 'valud'), diff --git a/packages/datasource-toolkit/test/interfaces/transforms/pattern.test.ts b/packages/datasource-toolkit/test/interfaces/transforms/pattern.test.ts index 953d700b7a..97e0d4ee47 100644 --- a/packages/datasource-toolkit/test/interfaces/transforms/pattern.test.ts +++ b/packages/datasource-toolkit/test/interfaces/transforms/pattern.test.ts @@ -36,4 +36,37 @@ describe('ConditionTreeOperators > Pattern', () => { ).toEqual({ field: 'column', operator: 'Like', value: '%something' }); }); }); + + describe('IContains', () => { + test('should be rewritten', () => { + expect( + alternatives.IContains[0].replacer( + new ConditionTreeLeaf('column', 'IContains', 'something'), + 'Europe/Paris', + ), + ).toEqual({ field: 'column', operator: 'ILike', value: '%something%' }); + }); + }); + + describe('IStartsWith', () => { + test('should be rewritten', () => { + expect( + alternatives.IStartsWith[0].replacer( + new ConditionTreeLeaf('column', 'IStartsWith', 'something'), + 'Europe/Paris', + ), + ).toEqual({ field: 'column', operator: 'ILike', value: 'something%' }); + }); + }); + + describe('IEndsWith', () => { + test('should be rewritten', () => { + expect( + alternatives.IEndsWith[0].replacer( + new ConditionTreeLeaf('column', 'IEndsWith', 'something'), + 'Europe/Paris', + ), + ).toEqual({ field: 'column', operator: 'ILike', value: '%something' }); + }); + }); }); From a7939236dadaa8bbff2ed5a1058744d8a7725b14 Mon Sep 17 00:00:00 2001 From: Romain Gilliotte Date: Mon, 23 May 2022 17:15:33 +0200 Subject: [PATCH 2/7] docs: fix --- docs/agent-customization/search.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/agent-customization/search.md b/docs/agent-customization/search.md index 0614c72a67..89b566ab02 100644 --- a/docs/agent-customization/search.md +++ b/docs/agent-customization/search.md @@ -11,10 +11,10 @@ Two search modes are supported: normal and extended. ![Extended search call to action](../assets/search-bar-extended.png) -When not defined otherwise by the [datasource](../datasources/README.md), the search behavior is to attempt to search within columns of the collection (in normal mode), or columns of the collection of direct relations (in extended mode). - ## Default behavior +When not defined otherwise by the [datasource](../datasources/README.md), the search behavior is to attempt to search within columns of the collection (in normal mode), or columns of the collection of direct relations (in extended mode). + By default, Forest Admin will search only on some columns, depending on their respective types. | Column Type | Default search behavior | From e8940a6c4b9a68188730cfb06fa82a1993daada7 Mon Sep 17 00:00:00 2001 From: Romain Gilliotte Date: Tue, 24 May 2022 11:23:21 +0200 Subject: [PATCH 3/7] fix: search should be case insensitive --- .../src/decorators/search/collection.ts | 4 ++-- .../test/decorators/search/collections.test.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/datasource-toolkit/src/decorators/search/collection.ts b/packages/datasource-toolkit/src/decorators/search/collection.ts index 8d118ec3e9..c244774821 100644 --- a/packages/datasource-toolkit/src/decorators/search/collection.ts +++ b/packages/datasource-toolkit/src/decorators/search/collection.ts @@ -87,7 +87,7 @@ export default class SearchCollectionDecorator extends CollectionDecorator { } if (columnType === 'String') { - return new ConditionTreeLeaf(field, 'Contains', searchString); + return new ConditionTreeLeaf(field, 'IContains', searchString); } if (columnType === 'Uuid' && isUuid) { @@ -145,7 +145,7 @@ export default class SearchCollectionDecorator extends CollectionDecorator { } if (columnType === 'String') { - return filterOperators?.has('Contains'); + return filterOperators?.has('IContains'); } } diff --git a/packages/datasource-toolkit/test/decorators/search/collections.test.ts b/packages/datasource-toolkit/test/decorators/search/collections.test.ts index fe44923e69..08305cab14 100644 --- a/packages/datasource-toolkit/test/decorators/search/collections.test.ts +++ b/packages/datasource-toolkit/test/decorators/search/collections.test.ts @@ -118,7 +118,7 @@ describe('SearchCollectionDecorator', () => { fields: { fieldName: factories.columnSchema.build({ columnType: 'String', - filterOperators: new Set(['Contains']), + filterOperators: new Set(['IContains']), }), }, }), @@ -150,7 +150,7 @@ describe('SearchCollectionDecorator', () => { aggregator: 'And', conditions: [ { operator: 'Equal', field: 'aFieldName', value: 'fieldValue' }, - { field: 'fieldName', operator: 'Contains', value: 'a text' }, + { field: 'fieldName', operator: 'IContains', value: 'a text' }, ], }, }); @@ -164,7 +164,7 @@ describe('SearchCollectionDecorator', () => { fields: { fieldName: factories.columnSchema.build({ columnType: 'String', - filterOperators: new Set(['Contains']), + filterOperators: new Set(['IContains']), }), }, }), @@ -180,7 +180,7 @@ describe('SearchCollectionDecorator', () => { ); expect(refinedFilter).toEqual({ search: null, - conditionTree: { field: 'fieldName', operator: 'Contains', value: 'a text' }, + conditionTree: { field: 'fieldName', operator: 'IContains', value: 'a text' }, }); }); }); @@ -228,7 +228,7 @@ describe('SearchCollectionDecorator', () => { }), fieldName2: factories.columnSchema.build({ columnType: 'String', - filterOperators: new Set(['Contains']), + filterOperators: new Set(['IContains']), }), }, }), @@ -248,7 +248,7 @@ describe('SearchCollectionDecorator', () => { aggregator: 'Or', conditions: [ { field: 'fieldName', operator: 'Equal', value: 1584 }, - { field: 'fieldName2', operator: 'Contains', value: '1584' }, + { field: 'fieldName2', operator: 'IContains', value: '1584' }, ], }, }); From 0bb99afe95a72db07a2d6995711927631749c944 Mon Sep 17 00:00:00 2001 From: Romain Gilliotte Date: Tue, 24 May 2022 11:28:34 +0200 Subject: [PATCH 4/7] fix: search should be case insensitive --- docs/agent-customization/search.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/agent-customization/search.md b/docs/agent-customization/search.md index 89b566ab02..935726d3bc 100644 --- a/docs/agent-customization/search.md +++ b/docs/agent-customization/search.md @@ -21,8 +21,8 @@ By default, Forest Admin will search only on some columns, depending on their re | ----------- | ---------------------------------------------------------------------- | | Enum | Column is equal to the search string (case-insensitive) | | Number | Column is equal to the search string (if the search string is numeric) | -| String | Column contains the search string (case-sensitive) | -| Uuid | Column is equal to the search string (case-sensitive) | +| String | Column contains the search string (case-insensitive) | +| Uuid | Column is equal to the search string | | Other types | Column is ignored by the default search handler | ## Customization From 5ba10d5bfc8e5a45c0a4d15f440dba6b677c8d14 Mon Sep 17 00:00:00 2001 From: Romain Gilliotte Date: Tue, 24 May 2022 12:12:54 +0200 Subject: [PATCH 5/7] fix: search should be case insensitive --- docs/agent-customization/search.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent-customization/search.md b/docs/agent-customization/search.md index 935726d3bc..cae8451686 100644 --- a/docs/agent-customization/search.md +++ b/docs/agent-customization/search.md @@ -36,7 +36,7 @@ For instance: In order to customize the search bar, you must define a handler which returns a [`ConditionTree`](../under-the-hood/queries/filters.md#condition-trees). -### Making the search case-insensitive +### Making the search case-sensitive by default In this example, we use the `searchExtended` condition to toggle between case-sensitive and insensitive search. From 34ae2c484b422cd14c50d2522ea32ae3e985b4f3 Mon Sep 17 00:00:00 2001 From: Romain Gilliotte Date: Tue, 24 May 2022 15:11:17 +0200 Subject: [PATCH 6/7] fix: search should be case insensitive --- .../src/utils/query-converter.ts | 29 +- .../test/utils/query-converter.unit.test.ts | 350 +++++++++--------- 2 files changed, 205 insertions(+), 174 deletions(-) diff --git a/packages/datasource-sequelize/src/utils/query-converter.ts b/packages/datasource-sequelize/src/utils/query-converter.ts index 6179e75630..9a2ee9ad6b 100644 --- a/packages/datasource-sequelize/src/utils/query-converter.ts +++ b/packages/datasource-sequelize/src/utils/query-converter.ts @@ -8,6 +8,7 @@ import { Sort, } from '@forestadmin/datasource-toolkit'; import { + Dialect, IncludeOptions, ModelDefined, Op, @@ -23,9 +24,11 @@ import { Where } from 'sequelize/types/utils'; export default class QueryConverter { private model: ModelDefined; + private dialect: Dialect; constructor(model: ModelDefined) { this.model = model; + this.dialect = this.model.sequelize.getDialect() as Dialect; } private asArray(value: unknown) { @@ -35,8 +38,8 @@ export default class QueryConverter { } private makeWhereClause( - operator: Operator, field: string, + operator: Operator, // eslint-disable-next-line @typescript-eslint/no-explicit-any value?: any, ): WhereOperators | OrOperator | Where { @@ -45,12 +48,28 @@ export default class QueryConverter { switch (operator) { case 'Blank': return { - [Op.or]: [this.makeWhereClause('Missing', field) as OrOperator, { [Op.eq]: '' }], + [Op.or]: [this.makeWhereClause(field, 'Missing') as OrOperator, { [Op.eq]: '' }], }; + case 'Like': + if (this.dialect === 'sqlite') + return where(col(field), 'GLOB', value.replace(/%/g, '*').replace(/_/g, '?')); + if (this.dialect === 'mysql' || this.dialect === 'mariadb') + return where(fn('BINARY', col(field)), 'LIKE', value); + return { [Op.like]: value }; + case 'ILike': - return where(fn('LOWER', col(field)), 'LIKE', value); + if (this.dialect === 'postgres') return { [Op.iLike]: value }; + if (this.dialect === 'mysql' || this.dialect === 'mariadb' || this.dialect === 'sqlite') + return { [Op.like]: value }; + + return where(fn('LOWER', col(field)), 'LIKE', value.toLocaleLowerCase()); + + case 'NotContains': + return { + [Op.not]: this.makeWhereClause(field, 'Like', `%${value}%`) as WhereOperators, + }; case 'Equal': return { [Op.eq]: value }; case 'GreaterThan': @@ -63,8 +82,6 @@ export default class QueryConverter { return { [Op.lt]: value }; case 'Missing': return { [Op.is]: null }; - case 'NotContains': - return where(fn('LOWER', col(field)), 'NOT LIKE', `%${value.toLocaleLowerCase()}%`); case 'NotEqual': return { [Op.ne]: value }; case 'NotIn': @@ -142,8 +159,8 @@ export default class QueryConverter { } sequelizeWhereClause[isRelation ? `$${safeField}$` : safeField] = this.makeWhereClause( - operator, safeField, + operator, value, ); } else { diff --git a/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts b/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts index f2049eb2c9..811efb06e8 100644 --- a/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts +++ b/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts @@ -7,38 +7,59 @@ import { Projection, Sort, } from '@forestadmin/datasource-toolkit'; -import { Association, ModelDefined, Op } from 'sequelize'; +import { DataTypes, Dialect, Op, Sequelize } from 'sequelize'; import QueryConverter from '../../src/utils/query-converter'; describe('Utils > QueryConverter', () => { + const setupModel = (dialect: Dialect = 'postgres') => { + const sequelize = new Sequelize({ dialect }); + const model = sequelize.define('model', { + __field_1__: { + type: DataTypes.STRING, + }, + __field_2__: { + type: DataTypes.STRING, + }, + __renamed_field__: { + type: DataTypes.STRING, + field: 'fieldRenamed', + }, + }); + + return model; + }; + describe('getWhereFromConditionTreeToByPassInclude', () => { describe('with a condition tree acting on relation', () => { it('should generate a valid "where" clause with the primary keys', async () => { const conditionTree = new ConditionTreeLeaf('relation:__field__', 'Equal', '__value__'); - const model = { - getAttributes: () => ({ - relation: { field: '__field__' }, - idA: { field: 'idA' }, // primary key - idB: { field: 'idB' }, // primary key - }), - primaryKeyAttributes: ['idA', 'idB'], - findAll: jest - .fn() - .mockResolvedValue([ - { get: jest.fn().mockReturnValueOnce(1).mockReturnValueOnce(2) }, - { get: jest.fn().mockReturnValueOnce(3).mockReturnValueOnce(4) }, - ]), - associations: { - relation: { - target: { - primaryKeyAttributes: ['id'], - getAttributes: () => ({ __field__: { field: 'fieldName' } }), - } as unknown as ModelDefined, - } as Association, + const sequelize = new Sequelize({ dialect: 'postgres' }); + const model = sequelize.define('model', { + idA: { + type: DataTypes.INTEGER, + primaryKey: true, + }, + idB: { + type: DataTypes.INTEGER, + primaryKey: true, }, - } as unknown as ModelDefined; + }); + const relation = sequelize.define('relation', { + __field__: { + type: DataTypes.STRING, + field: 'fieldName', + }, + }); + model.belongsTo(relation); + + model.findAll = jest + .fn() + .mockResolvedValue([ + { get: jest.fn().mockReturnValueOnce(1).mockReturnValueOnce(2) }, + { get: jest.fn().mockReturnValueOnce(3).mockReturnValueOnce(4) }, + ]); const queryConverter = new QueryConverter(model); const where = await queryConverter.getWhereFromConditionTreeToByPassInclude(conditionTree); @@ -54,25 +75,18 @@ describe('Utils > QueryConverter', () => { describe('with a condition tree without relation', () => { it('should generate a valid where clause with ids', async () => { - const conditionTree = new ConditionTreeLeaf('__field__', 'Equal', '__value__'); - - const model = { - getAttributes: () => ({ - __field__: { field: '__field__' }, - }), - } as unknown as ModelDefined; - + const conditionTree = new ConditionTreeLeaf('__field_1__', 'Equal', '__value__'); + const model = setupModel(); const queryConverter = new QueryConverter(model); const where = await queryConverter.getWhereFromConditionTreeToByPassInclude(conditionTree); - expect(where).toEqual({ __field__: { [Op.eq]: '__value__' } }); + expect(where).toEqual({ __field_1__: { [Op.eq]: '__value__' } }); }); }); describe('without condition tree', () => { it('should generate a valid where clause with ids', async () => { - const model = {} as unknown as ModelDefined; - + const model = setupModel(); const queryConverter = new QueryConverter(model); const where = await queryConverter.getWhereFromConditionTreeToByPassInclude(null); @@ -87,7 +101,8 @@ describe('Utils > QueryConverter', () => { operator: undefined, } as unknown as ConditionTreeBranch; - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(() => queryConverter.getWhereFromConditionTree(conditionTree)).toThrow( 'Invalid ConditionTree.', @@ -97,7 +112,8 @@ describe('Utils > QueryConverter', () => { describe('with a condition tree', () => { describe('when a null condition tree is given', () => { it('should return an empty object', () => { - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(queryConverter.getWhereFromConditionTree(null)).toEqual({}); }); @@ -110,7 +126,8 @@ describe('Utils > QueryConverter', () => { new ConditionTreeLeaf('__field__', 'Equal', '__value__'), ]); - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(() => queryConverter.getWhereFromConditionTree(conditionTree)).toThrow( 'Invalid (null) aggregator.', @@ -119,7 +136,8 @@ describe('Utils > QueryConverter', () => { it('should throw an error when conditions is not an array', () => { const conditionTree = new ConditionTreeBranch('And', null); - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(() => queryConverter.getWhereFromConditionTree(conditionTree)).toThrow( 'Conditions must be an array.', @@ -128,7 +146,8 @@ describe('Utils > QueryConverter', () => { it('should not throw an error when there is no condition', () => { const conditionTree = new ConditionTreeBranch('And', []); - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(() => queryConverter.getWhereFromConditionTree(conditionTree)).not.toThrow(); }); @@ -139,10 +158,7 @@ describe('Utils > QueryConverter', () => { new ConditionTreeLeaf('__field_1__', 'Equal', '__value_1__'), ]); - const model = { - getAttributes: () => ({ __field_1__: { field: '__field_1__' } }), - } as unknown as ModelDefined; - + const model = setupModel(); const queryConverter = new QueryConverter(model); expect(() => queryConverter.getWhereFromConditionTree(conditionTree)).not.toThrow(); @@ -153,10 +169,7 @@ describe('Utils > QueryConverter', () => { new ConditionTreeLeaf('__field_1__', 'Equal', '__value_1__'), ]); - const model = { - getAttributes: () => ({ __field_1__: { field: '__field_1__' } }), - } as unknown as ModelDefined; - + const model = setupModel(); const queryConverter = new QueryConverter(model); expect(() => queryConverter.getWhereFromConditionTree(conditionTree)).not.toThrow(); @@ -176,23 +189,13 @@ describe('Utils > QueryConverter', () => { const conditionTree = new ConditionTreeBranch(aggregator as Aggregator, conditions); - const model = { - getAttributes: () => ({ - __field_1__: { field: '__field_1__' }, - __field_2__: { field: '__field_2__' }, - }), - } as unknown as ModelDefined; - + const model = setupModel(); const queryConverter = new QueryConverter(model); expect(queryConverter.getWhereFromConditionTree(conditionTree)).toEqual({ [operator]: [ - { - [conditions[0].field]: { [Op.eq]: conditions[0].value }, - }, - { - [conditions[1].field]: { [Op.eq]: conditions[1].value }, - }, + { [conditions[0].field]: { [Op.eq]: conditions[0].value } }, + { [conditions[1].field]: { [Op.eq]: conditions[1].value } }, ], }); }, @@ -202,7 +205,7 @@ describe('Utils > QueryConverter', () => { describe('with a ConditionTreeLeaf node', () => { const defaultArrayValue = [21, 42, 84]; const defaultIntegerValue = 42; - const defaultStringValue = 'value'; + const defaultStringValue = 'VaLuE'; it.each([ ['Blank', undefined, { [Op.or]: [{ [Op.is]: null }, { [Op.eq]: '' }] }], @@ -215,83 +218,104 @@ describe('Utils > QueryConverter', () => { ['NotEqual', defaultIntegerValue, { [Op.ne]: defaultIntegerValue }], ['NotIn', defaultArrayValue, { [Op.notIn]: defaultArrayValue }], ['Present', undefined, { [Op.ne]: null }], - ['Like', defaultStringValue, { [Op.like]: defaultStringValue }], + [ + 'NotContains', + defaultStringValue, + { [Op.not]: { [Op.like]: `%${defaultStringValue}%` } }, + ], ])( 'should generate a "where" Sequelize filter from a "%s" operator', (operator, value, where) => { - const conditionTree = new ConditionTreeLeaf('__field__', operator as Operator, value); - - const model = { - getAttributes: () => ({ __field__: { field: '__field__' } }), - } as unknown as ModelDefined; + const conditionTree = new ConditionTreeLeaf('__field_1__', operator as Operator, value); + const model = setupModel(); const queryConverter = new QueryConverter(model); const sequelizeFilter = queryConverter.getWhereFromConditionTree(conditionTree); - expect(sequelizeFilter).toEqual( - expect.objectContaining({ - __field__: where, - }), - ); + expect(sequelizeFilter).toHaveProperty('__field_1__', where); }, ); - describe('using operator translate to SQL "LIKE" clause', () => { + describe('whith "Like" operator', () => { it.each([ - ['ILike', '__VaLuE__', 'LIKE', '__VaLuE__'], - ['NotContains', '__valUe__', 'NOT LIKE', '%__value__%'], - ])( - 'should generate a "where" Sequelize filter from a "%s" operator', - (operator, value, sqlClause, like) => { - const conditionTree = new ConditionTreeLeaf('__field__', operator as Operator, value); - - const model = { - getAttributes: () => ({ __field__: { field: '__field__' } }), - } as unknown as ModelDefined; - - const queryConverter = new QueryConverter(model); - const sequelizeFilter = queryConverter.getWhereFromConditionTree(conditionTree); - - expect(sequelizeFilter).toEqual( - expect.objectContaining({ - __field__: { - attribute: { - fn: 'LOWER', - args: [{ col: '__field__' }], - }, - comparator: sqlClause, - logic: like, - }, - }), - ); - }, - ); + [ + 'mariadb', + { + attribute: { fn: 'BINARY', args: [{ col: '__field_1__' }] }, + comparator: 'LIKE', + logic: 'VaLuE', + }, + ], + ['mssql', { [Op.like]: 'VaLuE' }], + [ + 'mysql', + { + attribute: { fn: 'BINARY', args: [{ col: '__field_1__' }] }, + comparator: 'LIKE', + logic: 'VaLuE', + }, + ], + ['postgres', { [Op.like]: 'VaLuE' }], + [ + 'sqlite', + { + attribute: { col: '__field_1__' }, + comparator: 'GLOB', + logic: 'VaLuE', + }, + ], + ])('should generate a "where" Sequelize filter for "%s"', (dialect, where) => { + const tree = new ConditionTreeLeaf('__field_1__', 'Like', 'VaLuE'); + const model = setupModel(dialect as Dialect); + const queryConverter = new QueryConverter(model); + const sequelizeFilter = queryConverter.getWhereFromConditionTree(tree); + + expect(sequelizeFilter).toHaveProperty('__field_1__', where); + }); }); - it('should fail with a null operator', () => { - const model = { - getAttributes: () => ({ __field__: { field: '__field__' } }), - } as unknown as ModelDefined; + describe('with "ILike" operator', () => { + it.each([ + ['mariadb', { [Op.like]: 'VaLuE' }], + [ + 'mssql', + { + attribute: { fn: 'LOWER', args: [{ col: '__field_1__' }] }, + comparator: 'LIKE', + logic: 'value', + }, + ], + ['mysql', { [Op.like]: 'VaLuE' }], + ['postgres', { [Op.iLike]: 'VaLuE' }], + ['sqlite', { [Op.like]: 'VaLuE' }], + ])('should generate a "where" Sequelize filter for "%s"', (dialect, where) => { + const tree = new ConditionTreeLeaf('__field_1__', 'ILike', 'VaLuE'); + const model = setupModel(dialect as Dialect); + const queryConverter = new QueryConverter(model); + const sequelizeFilter = queryConverter.getWhereFromConditionTree(tree); + + expect(sequelizeFilter).toHaveProperty('__field_1__', where); + }); + }); + it('should fail with a null operator', () => { + const model = setupModel(); const queryConverter = new QueryConverter(model); expect(() => queryConverter.getWhereFromConditionTree( - new ConditionTreeLeaf('__field__', null, '__value__'), + new ConditionTreeLeaf('__field_1__', null, '__value__'), ), ).toThrow('Invalid (null) operator.'); }); it('should fail with an invalid operator', () => { - const model = { - getAttributes: () => ({ __field__: { field: '__field__' } }), - } as unknown as ModelDefined; - + const model = setupModel(); const queryConverter = new QueryConverter(model); expect(() => queryConverter.getWhereFromConditionTree( - new ConditionTreeLeaf('__field__', '__invalid__' as Operator, '__value__'), + new ConditionTreeLeaf('__field_1__', '__invalid__' as Operator, '__value__'), ), ).toThrow('Unsupported operator: "__invalid__".'); }); @@ -299,67 +323,61 @@ describe('Utils > QueryConverter', () => { describe('with a renamed field', () => { it('should generate a valid where clause', () => { - const conditionTree = new ConditionTreeLeaf('__field__', 'Equal', '__value__'); - - const model = { - getAttributes: () => ({ __field__: { field: 'fieldName' } }), - } as unknown as ModelDefined; + const conditionTree = new ConditionTreeLeaf('__renamed_field__', 'Equal', '__value__'); + const model = setupModel(); const queryConverter = new QueryConverter(model); expect(queryConverter.getWhereFromConditionTree(conditionTree)).toEqual({ - fieldName: { [Op.eq]: '__value__' }, + fieldRenamed: { [Op.eq]: '__value__' }, }); }); }); describe('with a condition tree acting on relation', () => { - it('should generate a valid where clause', () => { - const conditionTree = new ConditionTreeLeaf('relation:__field__', 'Equal', '__value__'); - - const model = { - associations: { - relation: { - target: { - getAttributes: () => ({ __field__: { field: 'fieldName' } }), - } as unknown as ModelDefined, - } as Association, + const setupModelWithRelation = () => { + const model = setupModel(); + const relation = model.sequelize.define('relation', { + __field_a__: { + type: DataTypes.STRING, + field: 'fieldNameA', }, - } as unknown as ModelDefined; + }); + const relationB = model.sequelize.define('relationB', { + __field_b__: { + type: DataTypes.STRING, + field: 'fieldNameB', + }, + }); + + relation.belongsTo(relationB); + model.belongsTo(relation); + + return model; + }; + + it('should generate a valid where clause', () => { + const conditionTree = new ConditionTreeLeaf('relation:__field_a__', 'Equal', '__value__'); + const model = setupModelWithRelation(); const queryConverter = new QueryConverter(model); expect(queryConverter.getWhereFromConditionTree(conditionTree)).toEqual({ - '$relation.fieldName$': { [Op.eq]: '__value__' }, + '$relation.fieldNameA$': { [Op.eq]: '__value__' }, }); }); describe('with deep relation', () => { it('should generate a valid where clause', () => { const conditionTree = new ConditionTreeLeaf( - 'relation:relationB:__field__', + 'relation:relationB:__field_b__', 'Equal', '__value__', ); - const model = { - associations: { - relation: { - target: { - associations: { - relationB: { - target: { - getAttributes: () => ({ __field__: { field: 'fieldName' } }), - }, - }, - }, - } as unknown as ModelDefined, - } as Association, - }, - } as unknown as ModelDefined; - + const model = setupModelWithRelation(); const queryConverter = new QueryConverter(model); expect(queryConverter.getWhereFromConditionTree(conditionTree)).toEqual({ - '$relation.relationB.fieldName$': { [Op.eq]: '__value__' }, + '$relation.relationB.fieldNameB$': { [Op.eq]: '__value__' }, }); }); }); @@ -373,35 +391,25 @@ describe('Utils > QueryConverter', () => { ['NotIn', 'NotIn', Op.notIn], ])('"%s"', (message, operator, sequelizeOperator) => { it('should handle atomic values', () => { - const model = { - getAttributes: () => ({ __field__: { field: '__field__' } }), - } as unknown as ModelDefined; - + const model = setupModel(); const queryConverter = new QueryConverter(model); const sequelizeFilter = queryConverter.getWhereFromConditionTree( - new ConditionTreeLeaf('__field__', operator as Operator, 42), + new ConditionTreeLeaf('__field_1__', operator as Operator, 42), ); - expect(sequelizeFilter).toEqual( - expect.objectContaining({ __field__: { [sequelizeOperator]: [42] } }), - ); + expect(sequelizeFilter).toHaveProperty('__field_1__', { [sequelizeOperator]: [42] }); }); it('should handle array values', () => { - const model = { - getAttributes: () => ({ __field__: { field: '__field__' } }), - } as unknown as ModelDefined; - + const model = setupModel(); const queryConverter = new QueryConverter(model); const sequelizeFilter = queryConverter.getWhereFromConditionTree( - new ConditionTreeLeaf('__field__', operator as Operator, [42]), + new ConditionTreeLeaf('__field_1__', operator as Operator, [42]), ); - expect(sequelizeFilter).toEqual( - expect.objectContaining({ __field__: { [sequelizeOperator]: [42] } }), - ); + expect(sequelizeFilter).toHaveProperty('__field_1__', { [sequelizeOperator]: [42] }); }); }); }); @@ -409,7 +417,8 @@ describe('Utils > QueryConverter', () => { describe('getOrderFromSort', () => { it('should omit the "order" clause when condition list is empty', () => { - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(queryConverter.getOrderFromSort(new Sort())).toEqual([]); }); @@ -420,7 +429,8 @@ describe('Utils > QueryConverter', () => { { field: '__b__', ascending: false }, ); - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(queryConverter.getOrderFromSort(sort)).toEqual([ ['__a__', 'ASC'], @@ -433,7 +443,8 @@ describe('Utils > QueryConverter', () => { describe('when projection have relation field', () => { it('should add include with attributes', () => { const projection = new Projection('model:another_field'); - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(queryConverter.getIncludeWithAttributesFromProjection(projection)).toEqual([ { association: 'model', include: [], attributes: ['another_field'] }, @@ -442,7 +453,8 @@ describe('Utils > QueryConverter', () => { it('should add include recursively with attributes', () => { const projection = new Projection('model:another_model:a_field'); - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(queryConverter.getIncludeWithAttributesFromProjection(projection)).toEqual([ { @@ -459,7 +471,8 @@ describe('Utils > QueryConverter', () => { describe('when projection have relation field', () => { it('should add include', () => { const projection = new Projection('model:another_field'); - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(queryConverter.getIncludeFromProjection(projection)).toEqual([ { association: 'model', include: [], attributes: [] }, @@ -468,7 +481,8 @@ describe('Utils > QueryConverter', () => { it('should add include recursively', () => { const projection = new Projection('model:another_model:a_field'); - const queryConverter = new QueryConverter({} as ModelDefined); + const model = setupModel(); + const queryConverter = new QueryConverter(model); expect(queryConverter.getIncludeFromProjection(projection)).toEqual([ { From 16aa7f74d270964543ce9a6f6fe9b0110cd0f392 Mon Sep 17 00:00:00 2001 From: Romain Gilliotte Date: Tue, 24 May 2022 15:36:46 +0200 Subject: [PATCH 7/7] fix: typo --- docs/agent-customization/search.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent-customization/search.md b/docs/agent-customization/search.md index cae8451686..b3e5c191d2 100644 --- a/docs/agent-customization/search.md +++ b/docs/agent-customization/search.md @@ -43,7 +43,7 @@ In this example, we use the `searchExtended` condition to toggle between case-se ```javascript agent.customizeCollection('people', collection => collection.replaceSearch((searchString, extendedMode) => { - const operator = extendedModel ? 'Contains' : 'IContains'; + const operator = extendedMode ? 'Contains' : 'IContains'; return { aggregator: 'Or',