diff --git a/packages/graphql/src/services/__tests__/graphql.service.spec.ts b/packages/graphql/src/services/__tests__/graphql.service.spec.ts index 693631c63..1d9f4dfd8 100644 --- a/packages/graphql/src/services/__tests__/graphql.service.spec.ts +++ b/packages/graphql/src/services/__tests__/graphql.service.spec.ts @@ -28,6 +28,7 @@ function removeSpaces(text: string) { const gridOptionMock = { enablePagination: true, + defaultFilterRangeOperator: OperatorType.rangeInclusive, backendServiceApi: { service: undefined, options: { datasetName: '' }, @@ -851,7 +852,7 @@ describe('GraphqlService', () => { expect(removeSpaces(query)).toBe(removeSpaces(expectation)); }); - it('should return a query with search having a range of exclusive numbers when the search value contains 2 (..) to represent a range of numbers', () => { + it('should return a query with search having a range of exclusive numbers when the search value contains 2 dots (..) to represent a range of numbers', () => { const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GE, value:"2"}, {field:duration, operator:LE, value:"33"}]) { totalCount,nodes{ id,company,gender,name } }}`; const mockColumn = { id: 'duration', field: 'duration' } as Column; const mockColumnFilters = { @@ -865,6 +866,62 @@ describe('GraphqlService', () => { expect(removeSpaces(query)).toBe(removeSpaces(expectation)); }); + it('should return a query to filter a search value between an inclusive range of numbers using the 2 dots (..) separator, the "RangeInclusive" operator and the range has an unbounded end', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GE, value:"5"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['5..'], operator: 'RangeInclusive' }, + } 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 to filter a search value between an inclusive range of numbers using the 2 dots (..) separator, the "RangeInclusive" operator and the range has an unbounded begin', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:LE, value:"5"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['..5'], operator: 'RangeInclusive' }, + } 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 to filter a search value between an inclusive range of numbers using the 2 dots (..) separator, the "RangeExclusive" operator and the range has an unbounded end', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GT, value:"5"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['5..'], operator: 'RangeExclusive' }, + } 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 to filter a search value between an inclusive range of numbers using the 2 dots (..) separator, the "RangeExclusive" operator and the range has an unbounded begin', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:LT, value:"5"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['..5'], operator: 'RangeExclusive' }, + } 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 a range of inclusive numbers when 2 searchTerms numbers are provided and the operator is "RangeInclusive"', () => { const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GE, value:2}, {field:duration, operator:LE, value:33}]) { totalCount,nodes{ id,company,gender,name } }}`; const mockColumn = { id: 'duration', field: 'duration' } as Column; @@ -879,7 +936,7 @@ describe('GraphqlService', () => { expect(removeSpaces(query)).toBe(removeSpaces(expectation)); }); - it('should return a query with search having a range of exclusive dates when the search value contains 2 (..) to represent a range of dates', () => { + it('should return a query with search having a range of exclusive dates when the search value contains 2 dots (..) to represent a range of dates', () => { const expectation = `query{users(first:10, offset:0, filterBy:[{field:startDate, operator:GE, value:"2001-01-01"}, {field:startDate, operator:LE, value:"2001-01-31"}]) { totalCount,nodes{ id,company,gender,name } }}`; const mockColumn = { id: 'startDate', field: 'startDate' } as Column; const mockColumnFilters = { @@ -907,6 +964,38 @@ describe('GraphqlService', () => { expect(removeSpaces(query)).toBe(removeSpaces(expectation)); }); + it('should return a query with a date equal when only 1 searchTerms is provided and even if the operator is set to a range', () => { + const expectation = `query{users(first:10,offset:0,filterBy:[{field:company,operator:Contains,value:"abc"},{field:updatedDate,operator:EQ,value:"2001-01-20"}]){totalCount,nodes{id,company,gender,name}}}`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-01-20'], operator: 'RangeExclusive' }, + } 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 without any date filtering when searchTerms is an empty array', () => { + const expectation = `query{users(first:10,offset:0,filterBy:[{field:company,operator:Contains,value:"abc"}]){totalCount,nodes{id,company,gender,name}}}`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: [], operator: 'RangeExclusive' }, + } 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 a CSV string when the filter operator is IN ', () => { const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:IN, value:"female,male"}]) { totalCount,nodes{ id,company,gender,name } }}`; const mockColumn = { id: 'gender', field: 'gender' } as Column; @@ -1057,11 +1146,11 @@ describe('GraphqlService', () => { describe('presets', () => { beforeEach(() => { - const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'duration', field: 'duration' }, { id: 'startDate', field: 'startDate' }]; + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'duration', field: 'duration', type: FieldType.number }, { id: 'startDate', field: 'startDate' }]; jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); }); - it('should return a query with search having a range of exclusive numbers when the search value contains 2 (..) to represent a range of numbers', () => { + it('should return a query with search having a range of exclusive numbers when the search value contains 2 dots (..) to represent a range of numbers', () => { const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GE, value:"2"}, {field:duration, operator:LE, value:"33"}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; const presetFilters = [ @@ -1077,8 +1166,8 @@ describe('GraphqlService', () => { expect(currentFilters).toEqual(presetFilters); }); - it('should return a query with a filter with range of numbers with decimals when the preset is a filter range with 3 dots (..) separator', () => { - const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GE, value:"0.5"}, {field:duration, operator:LE, value:".88"}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; + it('should return a query with a filter with range of numbers with decimals when the preset is a filter range with 2 dots (..) separator and range ends with a fraction', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GE, value:"0.5"}, {field:duration, operator:LE, value:"0.88"}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; const presetFilters = [ { columnId: 'duration', searchTerms: ['0.5...88'] }, ] as CurrentFilter[]; @@ -1122,7 +1211,7 @@ describe('GraphqlService', () => { expect(currentFilters).toEqual(presetFilters); }); - it('should return a query with search having a range of exclusive dates when the search value contains 2 (..) to represent a range of dates', () => { + it('should return a query with search having a range of exclusive dates when the search value contains 2 dots (..) to represent a range of dates', () => { const expectation = `query{users(first:10, offset:0, filterBy:[{field:startDate, operator:GE, value:"2001-01-01"}, {field:startDate, operator:LE, value:"2001-01-31"}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; const presetFilters = [ { columnId: 'startDate', searchTerms: ['2001-01-01..2001-01-31'] }, @@ -1166,6 +1255,62 @@ describe('GraphqlService', () => { expect(removeSpaces(query)).toBe(removeSpaces(expectation)); expect(currentFilters).toEqual(presetFilters); }); + + it('should return a query to filter a search value with a fraction of a number that is missing a leading 0', () => { + const expectation = `query{users(first:10,offset:0,filterBy:[{field:duration,operator:EQ,value:"0.22"}]){totalCount,nodes{id,company,gender,duration,startDate}}}`; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['.22'] }, + } 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 without invalid characters to filter a search value that does contains invalid characters', () => { + const expectation = `query{users(first:10,offset:0,filterBy:[{field:duration,operator:EQ,value:"-22"}]){totalCount,nodes{id,company,gender,duration,startDate}}}`; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.float } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['-2a2'] }, + } 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 without invalid characters to filter a search value with an integer that contains invalid characters', () => { + const expectation = `query{users(first:10,offset:0,filterBy:[{field:duration,operator:EQ,value:"22"}]){totalCount,nodes{id,company,gender,duration,startDate}}}`; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.integer } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['22;'] }, + } 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 without invalid characters to filter a search value with a number that only has a minus characters', () => { + const expectation = `query{users(first:10,offset:0,filterBy:[{field:duration,operator:EQ,value:"0"}]){totalCount,nodes{id,company,gender,duration,startDate}}}`; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['-'] }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); }); describe('updateSorters method', () => { diff --git a/packages/graphql/src/services/graphql.service.ts b/packages/graphql/src/services/graphql.service.ts index 16cdab9f0..8a85f9e53 100644 --- a/packages/graphql/src/services/graphql.service.ts +++ b/packages/graphql/src/services/graphql.service.ts @@ -392,7 +392,8 @@ export class GraphqlService implements BackendService { } const fieldName = columnDef.filter?.queryField || columnDef.queryFieldFilter || columnDef.queryField || columnDef.field || columnDef.name || ''; - let searchTerms = columnFilter && columnFilter.searchTerms || []; + const fieldType = columnDef.type || FieldType.string; + let searchTerms = columnFilter?.searchTerms ?? []; let fieldSearchValue = (Array.isArray(searchTerms) && searchTerms.length === 1) ? searchTerms[0] : ''; if (typeof fieldSearchValue === 'undefined') { fieldSearchValue = ''; @@ -402,7 +403,7 @@ export class GraphqlService implements BackendService { throw new Error(`GraphQL filter could not find the field name to query the search, your column definition must include a valid "field" or "name" (optionally you can also use the "queryfield").`); } - fieldSearchValue = '' + fieldSearchValue; // make sure it's a string + fieldSearchValue = `${fieldSearchValue}`; // make sure it's a string const matches = fieldSearchValue.match(/^([<>!=\*]{0,2})(.*[^<>!=\*])([\*]?)$/); // group 1: Operator, 2: searchValue, 3: last char is '*' (meaning starts with, ex.: abc*) let operator: OperatorString = columnFilter.operator || ((matches) ? matches[1] : ''); searchValue = (!!matches) ? matches[2] : ''; @@ -413,16 +414,23 @@ export class GraphqlService implements BackendService { continue; } - if (Array.isArray(searchTerms) && searchTerms.length === 1 && typeof searchTerms[0] === 'string' && searchTerms[0].indexOf('..') > 0) { - searchTerms = searchTerms[0].split('..'); + if (Array.isArray(searchTerms) && searchTerms.length === 1 && typeof searchTerms[0] === 'string' && searchTerms[0].indexOf('..') >= 0) { if (!operator) { - operator = OperatorType.rangeInclusive; + operator = this._gridOptions.defaultFilterRangeOperator as OperatorString; + } + searchTerms = searchTerms[0].split('..', 2); + if (searchTerms[0] === '') { + operator = operator === OperatorType.rangeInclusive ? '<=' : operator === OperatorType.rangeExclusive ? '<' : operator; + searchTerms = searchTerms.slice(1); + searchValue = searchTerms[0]; + } else if (searchTerms[1] === '') { + operator = operator === OperatorType.rangeInclusive ? '>=' : operator === OperatorType.rangeExclusive ? '>' : operator; + searchTerms = searchTerms.slice(0, 1); + searchValue = searchTerms[0]; } } if (typeof searchValue === 'string') { - // escaping the search value - searchValue = searchValue.replace(`'`, `''`); // escape single quotes by doubling them if (operator === '*' || operator === 'a*' || operator === '*z' || lastValueChar === '*') { operator = ((operator === '*' || operator === '*z') ? 'EndsWith' : 'StartsWith') as OperatorString; } @@ -434,13 +442,28 @@ export class GraphqlService implements BackendService { operator = columnDef.filter.operator; } + // No operator and 2 search terms should lead to default range operator. + if (!operator && Array.isArray(searchTerms) && searchTerms.length === 2 && searchTerms[0] && searchTerms[1]) { + operator = this._gridOptions.defaultFilterRangeOperator as OperatorString; + } + + // Range with 1 searchterm should lead to equals for a date field. + if ((operator === OperatorType.rangeInclusive || OperatorType.rangeExclusive) && Array.isArray(searchTerms) && searchTerms.length === 1 && fieldType === FieldType.date) { + operator = OperatorType.equal; + } + + // Normalize all search values + searchValue = this.normalizeSearchValue(fieldType, searchValue); + if (Array.isArray(searchTerms)) { + searchTerms.forEach((_part, index) => { + searchTerms[index] = this.normalizeSearchValue(fieldType, searchTerms[index]); + }); + } + // when having more than 1 search term (we need to create a CSV string for GraphQL "IN" or "NOT IN" filter search) if (searchTerms && searchTerms.length > 1 && (operator === 'IN' || operator === 'NIN' || operator === 'NOT_IN')) { searchValue = searchTerms.join(','); - } else if (searchTerms && searchTerms.length === 2 && (!operator || operator === OperatorType.rangeExclusive || operator === OperatorType.rangeInclusive)) { - if (!operator) { - operator = OperatorType.rangeInclusive; - } + } else if (searchTerms && searchTerms.length === 2 && (operator === OperatorType.rangeExclusive || operator === OperatorType.rangeInclusive)) { searchByArray.push({ field: fieldName, operator: (operator === OperatorType.rangeInclusive ? 'GE' : 'GT'), value: searchTerms[0] }); searchByArray.push({ field: fieldName, operator: (operator === OperatorType.rangeInclusive ? 'LE' : 'LT'), value: searchTerms[1] }); continue; @@ -448,7 +471,7 @@ export class GraphqlService implements BackendService { // if we still don't have an operator find the proper Operator to use by it's field type if (!operator) { - operator = mapOperatorByFieldType(columnDef.type || FieldType.string); + operator = mapOperatorByFieldType(fieldType); } // build the search array @@ -613,4 +636,44 @@ export class GraphqlService implements BackendService { return tmpFilter; }); } + + /** Normalizes the search value according to field type. */ + private normalizeSearchValue(fieldType: typeof FieldType[keyof typeof FieldType], searchValue: any) { + switch (fieldType) { + case FieldType.date: + case FieldType.string: + case FieldType.text: + case FieldType.readonly: + if (typeof searchValue === 'string') { + // escape single quotes by doubling them + searchValue = searchValue.replace(/'/g, `''`); + } + break; + case FieldType.integer: + case FieldType.number: + case FieldType.float: + if (typeof searchValue === 'string') { + // Parse a valid decimal from the string. + + // Replace double dots with single dots + searchValue = searchValue.replace(/\.\./g, '.'); + // Remove a trailing dot + searchValue = searchValue.replace(/\.+$/g, ''); + // Prefix a leading dot with 0 + searchValue = searchValue.replace(/^\.+/g, '0.'); + // Prefix leading dash dot with -0. + searchValue = searchValue.replace(/^\-+\.+/g, '-0.'); + // Remove any non valid decimal characters from the search string + searchValue = searchValue.replace(/(?!^\-)[^\d\.]/g, ''); + + // if nothing left, search for 0 + if (searchValue === '' || searchValue === '-') { + searchValue = '0'; + } + } + break; + } + + return searchValue; + } }