Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(datasource-customizer): implement gmail-style search #780

Merged
merged 71 commits into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
913d772
feat(datasource-customizer): implement some features of gmail-style s…
romain-gilliotte Jul 26, 2023
27efed3
test: coverage
romain-gilliotte Jul 27, 2023
947c56c
feat: better support
romain-gilliotte Jul 28, 2023
9828668
fix: bugs
romain-gilliotte Jul 28, 2023
1909658
test: coverage
romain-gilliotte Jul 28, 2023
6c63d16
fix: revert useless change
romain-gilliotte Jul 28, 2023
f6890f4
fix: emulate NotIContains
romain-gilliotte Jul 28, 2023
5d9a614
test: fix
romain-gilliotte Jul 28, 2023
07ddbe8
fix: coverage
romain-gilliotte Jul 28, 2023
af4bb60
fix: improve has operator
romain-gilliotte Jul 28, 2023
9fbfa50
fix: allow dash in fieldname
romain-gilliotte Jul 28, 2023
98b68e1
chore: merge remote-tracking branch 'origin/main' into feat/search
Dec 8, 2023
fa4f074
feat: use a real parser to support more syntaxes
Dec 15, 2023
721215a
feat: specify properties from relationships when searching
Dec 15, 2023
b1996e1
chore: merge remote-tracking branch 'origin/main' into feat/search
Dec 15, 2023
ab1bccd
feat: detect dates only between 1800 and now + 100years
Dec 15, 2023
93de23b
ci: fix eslint config
Dec 15, 2023
8dace90
chore: update dependencies
Dec 15, 2023
30e0231
refactor: fix linting issues
Dec 15, 2023
0f577cf
refactor: fix paths
Dec 15, 2023
a51e58f
fix: parsing behavior
Dec 15, 2023
dacab68
fix: fix regressions detected with tests
Dec 15, 2023
2672dd8
chore: rollback unwanted changes on config
Dec 15, 2023
57ed8b7
fix: declare that search can be null and not only undefined to avoid …
Dec 15, 2023
a1d36f2
fix: return matchAll when conditions are negated
Dec 15, 2023
05acbe8
feat: add support for month dates
Dec 15, 2023
440ec98
test: update tests
Dec 15, 2023
f6639b0
test: update tests
Dec 15, 2023
d7081af
feat: implement operators priority and parenthesis
Dec 15, 2023
8af26e4
test: add tests
Dec 15, 2023
4dd0c44
fix: allow trailing spaces
Dec 15, 2023
12b5ae7
fix: return the right default condition
Dec 15, 2023
9d86cda
refactor: declare the default condition only once
Dec 15, 2023
cee5467
fix: relax constraints on parenthesis
Dec 15, 2023
c100c1e
chore: merge remote-tracking branch 'origin/main' into feat/search
Jan 7, 2024
2d7425f
refactor: throw an error if the query is empty
Jan 7, 2024
81e246b
refactor: remove useless conditions
Jan 7, 2024
95169a3
test: add tests on buildBooleanFieldFilter
Jan 7, 2024
a0c534a
test: add tests for buildDateFieldFilter
Jan 7, 2024
1a10b87
test: add tests for boolean filters
Jan 7, 2024
118d105
feat: add support for array types and add tests
Jan 9, 2024
8a16859
feat: add better support for array types
Jan 9, 2024
12478e0
test: add tests
Jan 9, 2024
27c8322
feat: support new operators on number fields and add tests
Jan 9, 2024
ccc0204
test: add tests on number arrays
Jan 9, 2024
0b54e37
test: add tests on string fields
Jan 9, 2024
0f91f48
test: remove test on case-insensitive search
Jan 9, 2024
4efd675
feat: add the IncludesNone operator to improve search in arrays (WIP)
Jan 10, 2024
5c87e56
test: add tests on operator IncludesNone
Jan 12, 2024
09f95e6
test: fix tests
Jan 12, 2024
3582fdc
test: add test
Jan 12, 2024
9bc6374
test: add tests
Jan 12, 2024
4f11a93
test: fix tests
Jan 12, 2024
03d1a7c
test: update tests and conditions
Jan 12, 2024
559c29e
test: add tests
Jan 12, 2024
41276e4
test: fix tests
Jan 12, 2024
08194e0
test: update tests
Jan 12, 2024
3bda75d
fix: import index
Jan 12, 2024
fd24bb9
fix: parser definition
Jan 12, 2024
b9eb59c
fix: correctly parse 2 chars property names and add tests
Jan 12, 2024
19b9be3
test: add tests
Jan 12, 2024
82d63e7
test: add tests
Jan 12, 2024
f371d16
chore: update jest config
Jan 12, 2024
444f221
chore: remove generated code from CC
Jan 12, 2024
8e56681
chore: rollback changes on vscode settings
Jan 12, 2024
4be1aaa
chore: merge remote-tracking branch 'origin/main' into feat/search
Jan 26, 2024
11c315b
fix: support timezones in date search
Jan 26, 2024
205fce0
docs: add documentation about generated code
Jan 26, 2024
9b9d49b
chore: update yarn lock
Jan 26, 2024
1367a07
test: fix test
Jan 26, 2024
7afd86a
docs: add documentation on grammar file
Jan 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .codeclimate.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
version: '2' # required to adjust maintainability checks
version: "2" # required to adjust maintainability checks

checks:
method-complexity:
enabled: true
config:
threshold: 10
exclude_patterns:
- 'packages/_example/'
- '**/test/'
- "packages/_example/"
- "**/test/"
- "packages/datasource-customizer/src/decorators/search/generated-parser/*.ts"
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
with:
node-version: 18.14.0
- name: Start docker containers
if: ${{ matrix.package == 'datasource-mongoose' || matrix.package == 'datasource-sql' }}
if: ${{ matrix.package == 'datasource-mongoose' || matrix.package == 'datasource-sql' || matrix.package == 'datasource-sequelize' }}
ghusse marked this conversation as resolved.
Show resolved Hide resolved
run: docker-compose -f ./packages/${{ matrix.package }}/docker-compose.yml up -d; sleep 5
- name: Restore dependencies from cache
uses: actions/cache/restore@v3
Expand Down
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const config: Config.InitialOptions = {
collectCoverageFrom: [
'<rootDir>/packages/*/src/**/*.ts',
'!<rootDir>/packages/_example/src/**/*.ts',
'!<rootDir>/packages/datasource-customizer/src/decorators/search/generated-parser/*.ts',
],
testMatch: ['<rootDir>/packages/*/test/**/*.test.ts'],
setupFilesAfterEnv: ['jest-extended/all'],
Expand Down
3 changes: 3 additions & 0 deletions packages/datasource-customizer/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
ignorePatterns: ['src/decorators/search/generated-parser/**/*'],
};
2 changes: 2 additions & 0 deletions packages/datasource-customizer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dist/**/*.d.ts"
],
"scripts": {
"build:parser": "antlr4 -Xexact-output-dir -o src/decorators/search/generated-parser -Dlanguage=TypeScript src/decorators/search/Query.g4",
"build": "tsc",
"build:watch": "tsc --watch",
"clean": "rm -rf coverage dist",
Expand All @@ -29,6 +30,7 @@
},
"dependencies": {
"@forestadmin/datasource-toolkit": "1.29.2",
"antlr4": "^4.13.1-patch-1",
"file-type": "^16.5.4",
"luxon": "^3.2.1",
"object-hash": "^3.0.0",
Expand Down
45 changes: 45 additions & 0 deletions packages/datasource-customizer/src/decorators/search/Query.g4
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// This file contains the description of the supported syntax for search queries.
// It is used by the ANTLR parser generator to generate the parser.
// To support additional syntax, this file must be updated and the parser regenerated.
// It requires antlr4-tools to be installed on your machine
//
grammar Query;
ghusse marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to link to some doc from here. If anybody stumbles upon this file they'll be able to look it up


query: (and | or | queryToken | parenthesized) EOF;

parenthesized: PARENS_OPEN (or | and) PARENS_CLOSE;
PARENS_OPEN: '(' ' '*;
PARENS_CLOSE: ' '* ')';

// OR is a bit different because of operator precedence
or: (and | queryToken | parenthesized) (SEPARATOR OR SEPARATOR (and | queryToken | parenthesized))+;
OR: 'OR';

and: (queryToken | parenthesized) (SEPARATOR (AND SEPARATOR)? (queryToken | parenthesized))+;
AND: 'AND';

queryToken: (quoted | negated | propertyMatching | word);


quoted: SINGLE_QUOTED | DOUBLE_QUOTED;
SINGLE_QUOTED: '\'' SINGLE_QUOTED_CONTENT '\'' | '\'' '\'';
fragment SINGLE_QUOTED_CONTENT:~[']*;
DOUBLE_QUOTED: '"' DOUBLE_QUOTED_CONTENT '"' | '"' '"';
fragment DOUBLE_QUOTED_CONTENT: ~["]*;

negated: NEGATION (word | quoted | propertyMatching);
NEGATION: '-';

propertyMatching: name ':' value;
name: TOKEN;
value: word | quoted;

word: TOKEN;
TOKEN: ONE_CHAR_TOKEN | TWO_CHARS_TOKEN | MULTIPLE_CHARS_TOKEN;
fragment ONE_CHAR_TOKEN: ~[\r\n :\-()];
fragment TWO_CHARS_TOKEN: ~[\r\n :\-(]~[\r\n :)];
fragment MULTIPLE_CHARS_TOKEN:~[\r\n :\-(]~[\r\n :]+~[\r\n :)];

SEPARATOR: SPACING+ | EOF;
SPACING: [\r\n ];
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Caller, Collection, ConditionTree } from '@forestadmin/datasource-toolkit';

import CollectionCustomizationContext from '../../context/collection-context';
import { TCollectionName, TColumnName, TSchema } from '../../templates';

export type SearchOptions<
S extends TSchema = TSchema,
N extends TCollectionName<S> = TCollectionName<S>,
> = {
/**
* Include fields from the first level of relations
*/
extended?: boolean;
/**
* Remove these fields from the default search fields
*/
excludeFields?: Array<TColumnName<S, N>>;
/**
* Add these fields to the default search fields
*/
includeFields?: Array<TColumnName<S, N>>;
/**
* Replace the list of default searched field by these fields
*/
onlyFields?: Array<TColumnName<S, N>>;
};

export default class CollectionSearchContext<
S extends TSchema = TSchema,
N extends TCollectionName<S> = TCollectionName<S>,
> extends CollectionCustomizationContext<S, N> {
constructor(
collection: Collection,
caller: Caller,
public readonly generateSearchFilter: (
searchText: string,
options?: SearchOptions<S, N>,
) => ConditionTree,
) {
super(collection, caller);
}
}
126 changes: 66 additions & 60 deletions packages/datasource-customizer/src/decorators/search/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,45 @@ import {
ColumnSchema,
ConditionTree,
ConditionTreeFactory,
ConditionTreeLeaf,
Operator,
DataSourceDecorator,
PaginatedFilter,
} from '@forestadmin/datasource-toolkit';
import { validate as uuidValidate } from 'uuid';

import CollectionSearchContext, { SearchOptions } from './collection-search-context';
import normalizeName from './normalize-name';
import { extractSpecifiedFields, generateConditionTree, parseQuery } from './parse-query';
import { SearchDefinition } from './types';
import CollectionCustomizationContext from '../../context/collection-context';

export default class SearchCollectionDecorator extends CollectionDecorator {
override dataSource: DataSourceDecorator<SearchCollectionDecorator>;
replacer: SearchDefinition = null;

replaceSearch(replacer: SearchDefinition): void {
this.replacer = replacer;
}

public override refineSchema(subSchema: CollectionSchema): CollectionSchema {
override refineSchema(subSchema: CollectionSchema): CollectionSchema {
return { ...subSchema, searchable: true };
}

public override async refineFilter(
caller: Caller,
filter?: PaginatedFilter,
): Promise<PaginatedFilter> {
override async refineFilter(caller: Caller, filter?: PaginatedFilter): Promise<PaginatedFilter> {
// Search string is not significant
if (!filter?.search?.trim().length) {
return filter?.override({ search: null });
}

// Implement search ourselves
if (this.replacer || !this.childCollection.schema.searchable) {
const ctx = new CollectionCustomizationContext(this, caller);
let tree = this.defaultReplacer(filter.search, filter.searchExtended);
const ctx = new CollectionSearchContext(this, caller, this.generateSearchFilter.bind(this));
let tree: ConditionTree;

if (this.replacer) {
const plainTree = await this.replacer(filter.search, filter.searchExtended, ctx);
tree = ConditionTreeFactory.fromPlainObject(plainTree);
} else {
tree = this.generateSearchFilter(caller, filter.search, {
extended: filter.searchExtended,
});
}

// Note that if no fields are searchable with the provided searchString, the conditions
Expand All @@ -58,57 +60,41 @@ export default class SearchCollectionDecorator extends CollectionDecorator {
return filter;
}

private defaultReplacer(search: string, extended: boolean): ConditionTree {
const searchableFields = SearchCollectionDecorator.getFields(this.childCollection, extended);
const conditions = searchableFields
.map(([field, schema]) => SearchCollectionDecorator.buildCondition(field, schema, search))
.filter(Boolean);

return ConditionTreeFactory.union(...conditions);
}

private static buildCondition(
field: string,
schema: ColumnSchema,
searchString: string,
private generateSearchFilter(
caller: Caller,
searchText: string,
options?: SearchOptions,
): ConditionTree {
const { columnType, enumValues, filterOperators } = schema;
const isNumber = Number(searchString).toString() === searchString;
const isUuid = uuidValidate(searchString);

if (columnType === 'Number' && isNumber && filterOperators?.has('Equal')) {
return new ConditionTreeLeaf(field, 'Equal', Number(searchString));
}

if (columnType === 'Enum' && filterOperators?.has('Equal')) {
const searchValue = SearchCollectionDecorator.lenientFind(enumValues, searchString);

if (searchValue) return new ConditionTreeLeaf(field, 'Equal', searchValue);
}

if (columnType === 'String') {
const isCaseSensitive = searchString.toLocaleLowerCase() !== searchString.toLocaleUpperCase();
const supportsIContains = filterOperators?.has('IContains');
const supportsContains = filterOperators?.has('Contains');
const supportsEqual = filterOperators?.has('Equal');

// Perf: don't use case-insensitive operator when the search string is indifferent to case
let operator: Operator;
if (supportsIContains && (isCaseSensitive || !supportsContains)) operator = 'IContains';
else if (supportsContains) operator = 'Contains';
else if (supportsEqual) operator = 'Equal';
const parsedQuery = parseQuery(searchText);

const specifiedFields = options?.onlyFields ? [] : extractSpecifiedFields(parsedQuery);

const defaultFields = options?.onlyFields
? []
: this.getFields(this.childCollection, Boolean(options?.extended));

const searchableFields = new Map(
[
...defaultFields,
...[...specifiedFields, ...(options?.onlyFields ?? []), ...(options?.includeFields ?? [])]
.map(name => this.lenientGetSchema(name))
.filter(Boolean)
.map(schema => [schema.field, schema.schema] as [string, ColumnSchema]),
]
.filter(Boolean)
.filter(([field]) => !options?.excludeFields?.includes(field)),
);

if (operator) return new ConditionTreeLeaf(field, operator, searchString);
}
const conditionTree = generateConditionTree(caller, parsedQuery, [...searchableFields]);

if (columnType === 'Uuid' && isUuid && filterOperators?.has('Equal')) {
return new ConditionTreeLeaf(field, 'Equal', searchString);
if (!conditionTree && searchText.trim().length) {
return ConditionTreeFactory.MatchNone;
}

return null;
return conditionTree;
}

private static getFields(collection: Collection, extended: boolean): [string, ColumnSchema][] {
private getFields(collection: Collection, extended: boolean): [string, ColumnSchema][] {
const fields: [string, ColumnSchema][] = [];

for (const [name, field] of Object.entries(collection.schema.fields)) {
Expand All @@ -125,10 +111,30 @@ export default class SearchCollectionDecorator extends CollectionDecorator {
return fields;
}

private static lenientFind(haystack: string[], needle: string): string {
return (
haystack?.find(v => v === needle.trim()) ??
haystack?.find(v => v.toLocaleLowerCase() === needle.toLocaleLowerCase().trim())
);
private lenientGetSchema(path: string): { field: string; schema: ColumnSchema } | null {
const [prefix, suffix] = path.split(/:(.*)/);
const fuzzyPrefix = normalizeName(prefix);

for (const [field, schema] of Object.entries(this.schema.fields)) {
const fuzzyFieldName = normalizeName(field);

if (fuzzyPrefix === fuzzyFieldName) {
if (!suffix && schema.type === 'Column') {
return { field, schema };
}

if (
suffix &&
(schema.type === 'OneToMany' || schema.type === 'ManyToOne' || schema.type === 'OneToOne')
) {
const related = this.dataSource.getCollection(schema.foreignCollection);
const fuzzy = related.lenientGetSchema(suffix);

if (fuzzy) return { field: `${field}:${fuzzy.field}`, schema: fuzzy.schema };
}
}
}

return null;
}
}
Loading
Loading