Skip to content

Commit

Permalink
feat(filters): add StartsWith/EndsWith (a*z) to OData/GraphQL (#1532)
Browse files Browse the repository at this point in the history
* feat(filters): add StartsWith/EndsWith (`a*z`) to OData/GraphQL
  • Loading branch information
ghiscoding committed May 18, 2024
1 parent b800da3 commit 237d6a8
Show file tree
Hide file tree
Showing 13 changed files with 295 additions and 75 deletions.
33 changes: 20 additions & 13 deletions examples/vite-demo-vanilla-bundle/src/examples/example09.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,16 @@ export default class Example09 {
}
}
}
if (filterBy.includes('startswith')) {
if (filterBy.includes('startswith') && filterBy.includes('endswith')) {
const filterStartMatch = filterBy.match(/startswith\(([a-zA-Z ]*),\s?'(.*?)'/);
const filterEndMatch = filterBy.match(/endswith\(([a-zA-Z ]*),\s?'(.*?)'/);
const fieldName = filterStartMatch[1].trim();
columnFilters[fieldName] = { type: 'starts+ends', term: [filterStartMatch[2].trim(), filterEndMatch[2].trim()] };
} else if (filterBy.includes('startswith')) {
const filterMatch = filterBy.match(/startswith\(([a-zA-Z ]*),\s?'(.*?)'/);
const fieldName = filterMatch[1].trim();
columnFilters[fieldName] = { type: 'starts', term: filterMatch[2].trim() };
}
if (filterBy.includes('endswith')) {
} else if (filterBy.includes('endswith')) {
const filterMatch = filterBy.match(/endswith\(([a-zA-Z ]*),\s?'(.*?)'/);
const fieldName = filterMatch[1].trim();
columnFilters[fieldName] = { type: 'ends', term: filterMatch[2].trim() };
Expand Down Expand Up @@ -308,7 +312,7 @@ export default class Example09 {
const filterType = columnFilters[columnId].type;
const searchTerm = columnFilters[columnId].term;
let colId = columnId;
if (columnId && columnId.indexOf(' ') !== -1) {
if (columnId?.indexOf(' ') !== -1) {
const splitIds = columnId.split(' ');
colId = splitIds[splitIds.length - 1];
}
Expand All @@ -321,16 +325,19 @@ export default class Example09 {
}

if (filterTerm) {
const [term1, term2] = Array.isArray(searchTerm) ? searchTerm : [searchTerm];

switch (filterType) {
case 'eq': return filterTerm.toLowerCase() === searchTerm;
case 'ne': return filterTerm.toLowerCase() !== searchTerm;
case 'le': return filterTerm.toLowerCase() <= searchTerm;
case 'lt': return filterTerm.toLowerCase() < searchTerm;
case 'gt': return filterTerm.toLowerCase() > searchTerm;
case 'ge': return filterTerm.toLowerCase() >= searchTerm;
case 'ends': return filterTerm.toLowerCase().endsWith(searchTerm);
case 'starts': return filterTerm.toLowerCase().startsWith(searchTerm);
case 'substring': return filterTerm.toLowerCase().includes(searchTerm);
case 'eq': return filterTerm.toLowerCase() === term1;
case 'ne': return filterTerm.toLowerCase() !== term1;
case 'le': return filterTerm.toLowerCase() <= term1;
case 'lt': return filterTerm.toLowerCase() < term1;
case 'gt': return filterTerm.toLowerCase() > term1;
case 'ge': return filterTerm.toLowerCase() >= term1;
case 'ends': return filterTerm.toLowerCase().endsWith(term1);
case 'starts': return filterTerm.toLowerCase().startsWith(term1);
case 'starts+ends': return filterTerm.toLowerCase().startsWith(term1) && filterTerm.toLowerCase().endsWith(term2);
case 'substring': return filterTerm.toLowerCase().includes(term1);
}
}
});
Expand Down
6 changes: 4 additions & 2 deletions examples/vite-demo-vanilla-bundle/src/examples/example10.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ export default class Example10 {
filters: [
// you can use OperatorType or type them as string, e.g.: operator: 'EQ'
{ columnId: 'gender', searchTerms: ['male'], operator: OperatorType.equal },
{ columnId: 'name', searchTerms: ['John Doe'], operator: OperatorType.contains },
// { columnId: 'name', searchTerms: ['John Doe'], operator: OperatorType.contains },
{ columnId: 'name', searchTerms: ['Joh*oe'], operator: OperatorType.startsWithEndsWith },
{ columnId: 'company', searchTerms: ['xyz'], operator: 'IN' },

// use a date range with 2 searchTerms values
Expand Down Expand Up @@ -331,7 +332,8 @@ export default class Example10 {
this.sgb?.filterService.updateFilters([
// you can use OperatorType or type them as string, e.g.: operator: 'EQ'
{ columnId: 'gender', searchTerms: ['male'], operator: OperatorType.equal },
{ columnId: 'name', searchTerms: ['John Doe'], operator: OperatorType.contains },
// { columnId: 'name', searchTerms: ['John Doe'], operator: OperatorType.contains },
{ columnId: 'name', searchTerms: ['Joh*oe'], operator: OperatorType.startsWithEndsWith },
{ columnId: 'company', searchTerms: ['xyz'], operator: 'IN' },

// use a date range with 2 searchTerms values
Expand Down
15 changes: 8 additions & 7 deletions examples/vite-demo-vanilla-bundle/src/examples/example14.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,32 +159,33 @@ export default class Example14 {
if (searchVals?.length) {
const columnId = searchFilterArgs.columnId;
const searchVal = searchVals[0] as string;
const cellValue = dataContext[columnId].toLowerCase();
const results = searchVal.matchAll(/^%([^%\r\n]+)[^%\r\n]*$|(.*)%(.+)%(.*)|(.+)%(.+)|([^%\r\n]+)%$/gi);
const arrayOfMatches = Array.from(results);
const matches = arrayOfMatches.length ? arrayOfMatches[0] : [];
const [_, endW, containSW, contain, containEndW, comboSW, comboEW, startW] = matches;

if (endW) {
// example: "%001" ends with A
return dataContext[columnId].endsWith(endW);
return cellValue.endsWith(endW.toLowerCase());
} else if (containSW && contain) {
// example: "%Ti%001", contains A + ends with B
return dataContext[columnId].startsWith(containSW) && dataContext[columnId].includes(contain);
return cellValue.startsWith(containSW.toLowerCase()) && cellValue.includes(contain.toLowerCase());
} else if (contain && containEndW) {
// example: "%Ti%001", contains A + ends with B
return dataContext[columnId].includes(contain) && dataContext[columnId].endsWith(containEndW);
return cellValue.includes(contain) && cellValue.endsWith(containEndW.toLowerCase());
} else if (contain && !containEndW) {
// example: "%Ti%", contains A anywhere
return dataContext[columnId].includes(contain);
return cellValue.includes(contain.toLowerCase());
} else if (comboSW && comboEW) {
// example: "Ti%001", combo starts with A + ends with B
return dataContext[columnId].startsWith(comboSW) && dataContext[columnId].endsWith(comboEW);
return cellValue.startsWith(comboSW.toLowerCase()) && cellValue.endsWith(comboEW.toLowerCase());
} else if (startW) {
// example: "Ti%", starts with A
return dataContext[columnId].startsWith(startW);
return cellValue.startsWith(startW.toLowerCase());
}
// anything else
return dataContext[columnId].includes(searchVal);
return cellValue.includes(searchVal.toLowerCase());
}

// if we fall here then the value is not filtered out
Expand Down
29 changes: 21 additions & 8 deletions examples/vite-demo-vanilla-bundle/src/examples/example15.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,16 @@ export default class Example15 {
columnFilters[fieldName] = { type: 'equal', term: filterMatch[2].trim() };
}
}
if (filterBy.includes('startswith')) {
if (filterBy.includes('startswith') && filterBy.includes('endswith')) {
const filterStartMatch = filterBy.match(/startswith\(([a-zA-Z ]*),\s?'(.*?)'/);
const filterEndMatch = filterBy.match(/endswith\(([a-zA-Z ]*),\s?'(.*?)'/);
const fieldName = filterStartMatch[1].trim();
columnFilters[fieldName] = { type: 'starts+ends', term: [filterStartMatch[2].trim(), filterEndMatch[2].trim()] };
} else if (filterBy.includes('startswith')) {
const filterMatch = filterBy.match(/startswith\(([a-zA-Z ]*),\s?'(.*?)'/);
const fieldName = filterMatch[1].trim();
columnFilters[fieldName] = { type: 'starts', term: filterMatch[2].trim() };
}
if (filterBy.includes('endswith')) {
} else if (filterBy.includes('endswith')) {
const filterMatch = filterBy.match(/endswith\(([a-zA-Z ]*),\s?'(.*?)'/);
const fieldName = filterMatch[1].trim();
columnFilters[fieldName] = { type: 'ends', term: filterMatch[2].trim() };
Expand Down Expand Up @@ -344,17 +348,26 @@ export default class Example15 {
const filterType = columnFilters[columnId].type;
const searchTerm = columnFilters[columnId].term;
let colId = columnId;
if (columnId && columnId.indexOf(' ') !== -1) {
if (columnId?.indexOf(' ') !== -1) {
const splitIds = columnId.split(' ');
colId = splitIds[splitIds.length - 1];
}
const filterTerm = column[colId];

if (filterTerm) {
const [term1, term2] = Array.isArray(searchTerm) ? searchTerm : [searchTerm];
switch (filterType) {
case 'equal': return filterTerm.toLowerCase() === searchTerm;
case 'ends': return filterTerm.toLowerCase().endsWith(searchTerm);
case 'starts': return filterTerm.toLowerCase().startsWith(searchTerm);
case 'substring': return filterTerm.toLowerCase().includes(searchTerm);
case 'eq':
case 'equal': return filterTerm.toLowerCase() === term1;
case 'ne': return filterTerm.toLowerCase() !== term1;
case 'le': return filterTerm.toLowerCase() <= term1;
case 'lt': return filterTerm.toLowerCase() < term1;
case 'gt': return filterTerm.toLowerCase() > term1;
case 'ge': return filterTerm.toLowerCase() >= term1;
case 'ends': return filterTerm.toLowerCase().endsWith(term1);
case 'starts': return filterTerm.toLowerCase().startsWith(term1);
case 'starts+ends': return filterTerm.toLowerCase().startsWith(term1) && filterTerm.toLowerCase().endsWith(term2);
case 'substring': return filterTerm.toLowerCase().includes(term1);
}
}
});
Expand Down
19 changes: 14 additions & 5 deletions packages/common/src/filter-conditions/stringFilterCondition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,23 @@ export const executeStringFilterCondition: FilterCondition = ((options: FilterCo
*/
export function getFilterParsedText(inputSearchTerms: SearchTerm[] | undefined): SearchTerm[] {
const defaultSearchTerm = ''; // when nothing is provided, we'll default to 0
const searchTerms = Array.isArray(inputSearchTerms) && inputSearchTerms || [defaultSearchTerm];
let searchTerms = Array.isArray(inputSearchTerms) && inputSearchTerms || [defaultSearchTerm];
const parsedSearchValues: string[] = [];
let searchValue1;
let searchValue2;
if (searchTerms.length === 2 || (typeof searchTerms[0] === 'string' && (searchTerms[0] as string).indexOf('..') > 0)) {
const searchValues = (searchTerms.length === 2) ? searchTerms : (searchTerms[0] as string).split('..');
searchValue1 = `${Array.isArray(searchValues) ? searchValues[0] : ''}`;
searchValue2 = `${Array.isArray(searchValues) ? searchValues[1] : ''}`;

if (searchTerms.length === 1 && typeof searchTerms[0] === 'string') {
const st = searchTerms[0] as string;
if (st.indexOf('..') > 0) {
searchTerms = st.split('..');
} else if (st.indexOf('*') > 0 && st.indexOf('*') < st.length - 1) {
searchTerms = st.split('*');
}
}

if (searchTerms.length === 2) {
searchValue1 = `${searchTerms[0]}`;
searchValue2 = `${searchTerms[1]}`;
} else {
const parsedSearchValue = (Array.isArray(inputSearchTerms) && inputSearchTerms.length > 0) ? inputSearchTerms[0] : '';
searchValue1 = parsedSearchValue === undefined || parsedSearchValue === null ? '' : `${parsedSearchValue}`; // make sure it's a string
Expand Down
20 changes: 10 additions & 10 deletions packages/common/src/services/filter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ export class FilterService {
* @returns FilterConditionOption
*/
parseFormInputFilterConditions(inputSearchTerms: SearchTerm[] | undefined, columnFilter: Omit<SearchColumnFilter, 'searchTerms'>): Omit<FilterConditionOption, 'cellValue'> {
let searchValues: SearchTerm[] = extend(true, [], inputSearchTerms) || [];
const searchValues: SearchTerm[] = extend(true, [], inputSearchTerms) || [];
let fieldSearchValue = (Array.isArray(searchValues) && searchValues.length === 1) ? searchValues[0] : '';
const columnDef = columnFilter.columnDef;
const fieldType = columnDef.filter?.type ?? columnDef.type ?? FieldType.string;
Expand All @@ -426,22 +426,22 @@ export class FilterService {
// run regex to find possible filter operators unless the user disabled the feature
const autoParseInputFilterOperator = columnDef.autoParseInputFilterOperator ?? this._gridOptions.autoParseInputFilterOperator;

// group (1): comboStartsWith, (2): comboEndsWith, (3): Operator, (4): searchValue, (5): last char is '*' (meaning starts with, ex.: abc*)
// group (2): comboStartsWith, (3): comboEndsWith, (4): Operator, (1 or 5): searchValue, (6): last char is '*' (meaning starts with, ex.: abc*)
matches = autoParseInputFilterOperator !== false
? fieldSearchValue.match(/^(.*[^\\*\r\n])[*]{1}(.*[^*\r\n])|^([<>!=*]{0,2})(.*[^<>!=*])([*]?)$/) || []
: [fieldSearchValue, '', '', '', fieldSearchValue, ''];
? fieldSearchValue.match(/^((.*[^\\*\r\n])[*]{1}(.*[^*\r\n]))|^([<>!=*]{0,2})(.*[^<>!=*])([*]?)$/) || []
: [fieldSearchValue, '', '', '', '', fieldSearchValue, ''];
}

const comboStartsWith = matches?.[1] || '';
const comboEndsWith = matches?.[2] || '';
let operator = matches?.[3] || columnFilter.operator;
const searchTerm = matches?.[4] || '';
const inputLastChar = matches?.[5] || (operator === '*z' ? '*' : '');
const comboStartsWith = matches?.[2] || '';
const comboEndsWith = matches?.[3] || '';
let operator = matches?.[4] || columnFilter.operator;
let searchTerm = matches?.[1] || matches?.[5] || '';
const inputLastChar = matches?.[6] || (operator === '*z' ? '*' : '');

if (typeof fieldSearchValue === 'string') {
fieldSearchValue = fieldSearchValue.replace(`'`, `''`); // escape any single quotes by doubling them
if (comboStartsWith && comboEndsWith) {
searchValues = [comboStartsWith, comboEndsWith];
searchTerm = fieldSearchValue;
operator = OperatorType.startsWithEndsWith;
} else if (operator === '*' || operator === '*z') {
operator = OperatorType.endsWith;
Expand Down
28 changes: 28 additions & 0 deletions packages/graphql/src/services/__tests__/graphql.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,34 @@ describe('GraphqlService', () => {
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
});

it('should return a query with search having the operator StartsWith & EndsWith when search value has the "*" symbol with chars on both side of it', () => {
const expectation = `query{users(first:10, offset:0, filterBy:[{field:name, operator:StartsWith, value:"Ca"},{field:name, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`;
const mockColumn = { id: 'name', field: 'name' } as Column;
const mockColumnFilters = {
name: { columnId: 'name', columnDef: mockColumn, searchTerms: ['Ca*le'], type: FieldType.string },
} as ColumnFilters;

service.init(serviceOptions, paginationOptions, gridStub);
service.updateFilters(mockColumnFilters, false);
const query = service.buildQuery();

expect(removeSpaces(query)).toBe(removeSpaces(expectation));
});

it('should return a query with search having the operator StartsWithEndsWith when the operator was provided as "a*z"', () => {
const expectation = `query{users(first:10, offset:0, filterBy:[{field:name, operator:StartsWith, value:"Ca"},{field:name, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`;
const mockColumn = { id: 'name', field: 'name' } as Column;
const mockColumnFilters = {
name: { columnId: 'name', columnDef: mockColumn, searchTerms: ['Ca*le'], operator: 'a*z', type: FieldType.string },
} as ColumnFilters;

service.init(serviceOptions, paginationOptions, gridStub);
service.updateFilters(mockColumnFilters, false);
const query = service.buildQuery();

expect(removeSpaces(query)).toBe(removeSpaces(expectation));
});

it('should return a query with search having the operator Greater of Equal when the search value was provided as ">=10"', () => {
const expectation = `query{users(first:10, offset:0, filterBy:[{field:age, operator:GE, value:"10"}]) { totalCount,nodes{ id,company,gender,name } }}`;
const mockColumn = { id: 'age', field: 'age' } as Column;
Expand Down
Loading

0 comments on commit 237d6a8

Please sign in to comment.