diff --git a/docs/backend-services/OData.md b/docs/backend-services/OData.md index a9e6a6dcd..3df827dea 100644 --- a/docs/backend-services/OData.md +++ b/docs/backend-services/OData.md @@ -221,6 +221,29 @@ Navigations within navigations are also supported. For example `columns: [{ id: The dataset from the backend is automatically extracted and navigation fields are flattened so the grid can display them and sort/filter just work. The exact property that is used as the dataset depends on the oData version: `d.results` for v2, `results` for v3 and `value` for v4. If needed a custom extractor function can be set through `oDataOptions.datasetExtractor`. For example if the backend responds with `{ value: [{ id: 1, nav1: { field1: 'x' }, { nav2: { field2: 'y' } } ] }` this will be flattened to `{ value: [{ id: 1, 'nav1/field1': 'x', 'nav2/field2': 'y' } ] }`. +### Override the filter query + +Column filters may have a `Custom` operator, that acts as a placeholder for you to define your own logic. To do so, the easiest way is to provide the `filterQueryOverride` callback in the OdataOptions. This method will be called with `BackendServiceFilterQueryOverrideArgs` to let you decide dynamically on how the filter should be assembled. + +E.g. you could listen for a specific column and the active OperatorType.custom in order to switch the filter to a matchesPattern SQL LIKE search: + +```ts +backendServiceApi: { + options: { + filterQueryOverride: ({ fieldName, columnDef, columnFilterOperator, searchValue }) => { + if (columnFilterOperator === OperatorType.custom && columnDef?.id === 'name') { + let matchesSearch = (searchValue as string).replace(/\*/g, '.*'); + matchesSearch = matchesSearch.slice(0, 1) + '%5E' + matchesSearch.slice(1); + matchesSearch = matchesSearch.slice(0, -1) + '$\''; + + return `matchesPattern(${fieldName}, ${matchesSearch})`; + } + }, + } +} + +``` + ## UI Sample of the OData demo ![Slickgrid Server Side](https://github.com/ghiscoding/aurelia-slickgrid/blob/master/screenshots/pagination.png) \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example09.html b/examples/vite-demo-vanilla-bundle/src/examples/example09.html index f2ba40434..26ad06cd5 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example09.html +++ b/examples/vite-demo-vanilla-bundle/src/examples/example09.html @@ -16,6 +16,7 @@
(the UI should rollback to previous state before you did the action). Also changing Page Size to 50,000 will also throw which again is for demo purposes. Moreover, the example will persist changes in the local storage, so if you refresh the page, it will remember your last settings. + Last but not least, the column Name has a filter with a custom %% operator that behaves like a SQL LIKE operator supporting % wildcards.
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example09.ts b/examples/vite-demo-vanilla-bundle/src/examples/example09.ts index c836736fb..3eeae4264 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example09.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example09.ts @@ -8,6 +8,8 @@ import './example09.scss'; const STORAGE_KEY = 'slickgrid-universal-example09-gridstate'; const defaultPageSize = 20; +const CARET_HTML_ESCAPED = '%5E'; +const PERCENT_HTML_ESCAPED = '%25'; export default class Example09 { private _bindingEventService: BindingEventService; @@ -79,7 +81,15 @@ export default class Example09 { type: FieldType.string, filterable: true, filter: { - model: Filters.compoundInput + model: Filters.compoundInput, + compoundOperatorList: [ + { operator: '', desc: 'Contains' }, + { operator: '<>', desc: 'Not Contains' }, + { operator: '=', desc: 'Equals' }, + { operator: '!=', desc: 'Not equal to' }, + { operator: 'a*', desc: 'Starts With' }, + { operator: 'Custom', desc: 'SQL Like' }, + ], } }, { @@ -104,6 +114,10 @@ export default class Example09 { hideInFilterHeaderRow: false, hideInColumnTitleRow: true }, + compoundOperatorAltTexts: { + // where '=' is any of the `OperatorString` type shown above + text: { 'Custom': { operatorAlt: '%%', descAlt: 'SQL Like' } }, + }, enableCellNavigation: true, enableFiltering: true, enableCheckboxSelector: true, @@ -131,6 +145,15 @@ export default class Example09 { enableCount: this.isCountEnabled, // add the count in the OData query, which will return a property named "__count" (v2) or "@odata.count" (v4) enableSelect: this.isSelectEnabled, enableExpand: this.isExpandEnabled, + filterQueryOverride: ({ fieldName, columnDef, columnFilterOperator, searchValue }) => { + if (columnFilterOperator === OperatorType.custom && columnDef?.id === 'name') { + let matchesSearch = (searchValue as string).replace(/\*/g, '.*'); + matchesSearch = matchesSearch.slice(0, 1) + CARET_HTML_ESCAPED + matchesSearch.slice(1); + matchesSearch = matchesSearch.slice(0, -1) + '$\''; + + return `matchesPattern(${fieldName}, ${matchesSearch})`; + } + }, version: this.odataVersion // defaults to 2, the query string is slightly different between OData 2 and 4 }, onError: (error: Error) => { @@ -224,6 +247,12 @@ export default class Example09 { } if (param.includes('$filter=')) { const filterBy = param.substring('$filter='.length).replace('%20', ' '); + if (filterBy.includes('matchespattern')) { + const regex = new RegExp(`matchespattern\\(([a-zA-Z]+),\\s'${CARET_HTML_ESCAPED}(.*?)'\\)`, 'i'); + const filterMatch = filterBy.match(regex); + const fieldName = filterMatch[1].trim(); + columnFilters[fieldName] = { type: 'matchespattern', term: '^' + filterMatch[2].trim() }; + } if (filterBy.includes('contains')) { const filterMatch = filterBy.match(/contains\(([a-zA-Z/]+),\s?'(.*?)'/); const fieldName = filterMatch[1].trim(); @@ -338,6 +367,7 @@ export default class Example09 { 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); + case 'matchespattern': return new RegExp((term1 as string).replaceAll(PERCENT_HTML_ESCAPED, '.*'), 'i').test(filterTerm); } } }); diff --git a/packages/common/src/interfaces/backendServiceOption.interface.ts b/packages/common/src/interfaces/backendServiceOption.interface.ts index 0292d3cd5..52458bb15 100644 --- a/packages/common/src/interfaces/backendServiceOption.interface.ts +++ b/packages/common/src/interfaces/backendServiceOption.interface.ts @@ -1,3 +1,7 @@ +import type { SlickGrid } from '../core'; +import type { OperatorType } from '../enums'; +import type { Column } from './column.interface'; + export interface BackendServiceOption { /** What are the pagination options? ex.: (first, last, offset) */ paginationOptions?: any; @@ -11,3 +15,18 @@ export interface BackendServiceOption { /** Execute the process callback command on component init (page load) */ executeProcessCommandOnInit?: boolean; } + +export interface BackendServiceFilterQueryOverrideArgs { + /** The column to define the filter for */ + columnDef: Column | undefined; + /** The OData fieldName as target of the filter */ + fieldName: string; + /** The operator selected by the user via the compound operator dropdown */ + columnFilterOperator: OperatorType; + /** The inferred operator. See columnDef.autoParseInputFilterOperator */ + operator: OperatorType; + /** The entered search value */ + searchValue: any; + /** A reference to the SlickGrid instance */ + grid: SlickGrid | undefined +} \ No newline at end of file diff --git a/packages/odata/src/interfaces/odataOption.interface.ts b/packages/odata/src/interfaces/odataOption.interface.ts index 1e68f40c8..a95f9b405 100644 --- a/packages/odata/src/interfaces/odataOption.interface.ts +++ b/packages/odata/src/interfaces/odataOption.interface.ts @@ -1,4 +1,4 @@ -import type { BackendServiceOption, CaseType } from '@slickgrid-universal/common'; +import type { BackendServiceFilterQueryOverrideArgs, BackendServiceOption, CaseType } from '@slickgrid-universal/common'; export interface OdataOption extends BackendServiceOption { /** What is the casing type to use? Typically that would be 1 of the following 2: camelCase or PascalCase */ @@ -37,6 +37,9 @@ export interface OdataOption extends BackendServiceOption { /** Filter queue */ filterQueue?: any[]; + /** An optional predicate function to overide the built-in filter construction */ + filterQueryOverride?: (args: BackendServiceFilterQueryOverrideArgs) => string | undefined; + /** Sorting string (or array of string) that must be a valid OData string */ orderBy?: string | string[]; diff --git a/packages/odata/src/services/__tests__/grid-odata.service.spec.ts b/packages/odata/src/services/__tests__/grid-odata.service.spec.ts index eaa58ca2e..aa68b0060 100644 --- a/packages/odata/src/services/__tests__/grid-odata.service.spec.ts +++ b/packages/odata/src/services/__tests__/grid-odata.service.spec.ts @@ -764,6 +764,50 @@ describe('GridOdataService', () => { expect(query).toBe(expectation); }); + it('should bypass default behavior if filterQueryOverride is defined and does not return undefined', () => { + const expectation = `$top=10&$filter=(foo eq 'bar')`; + 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; + + const sOptions = { ...serviceOptions, filterQueryOverride: () => 'foo eq \'bar\'' }; + service.init(sOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should continue with default behavior if filterQueryOverride returns undefined', () => { + const expectation = `$top=10&$filter=(startswith(Name, 'Ca') and endswith(Name, 'le'))`; + 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; + + const sOptions = { ...serviceOptions, filterQueryOverride: () => undefined }; + service.init(sOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should continue with default behavior if filterQueryOverride is not provided', () => { + const expectation = `$top=10&$filter=(startswith(Name, 'Ca') and endswith(Name, 'le'))`; + 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(query).toBe(expectation); + }); + it('should return a query with search having the operator Greater of Equal when the search value was provided as ">=10"', () => { const expectation = `$top=10&$filter=(Age ge '10')`; const mockColumn = { id: 'age', field: 'age' } as Column; diff --git a/packages/odata/src/services/grid-odata.service.ts b/packages/odata/src/services/grid-odata.service.ts index f521c5050..40ea75bb8 100644 --- a/packages/odata/src/services/grid-odata.service.ts +++ b/packages/odata/src/services/grid-odata.service.ts @@ -104,10 +104,10 @@ export class GridOdataService implements BackendService { } postProcess(processResult: any): void { - const odataVersion = this._odataService?.options?.version ?? 2; + const odataVersion = this._odataService.options.version ?? 2; - if (this.pagination && this._odataService?.options?.enableCount) { - const countExtractor = this._odataService?.options?.countExtractor ?? + if (this.pagination && this._odataService.options.enableCount) { + const countExtractor = this._odataService.options.countExtractor ?? odataVersion >= 4 ? (r: any) => r?.['@odata.count'] : odataVersion === 3 ? (r: any) => r?.['__count'] : (r: any) => r?.d?.['__count']; @@ -117,8 +117,8 @@ export class GridOdataService implements BackendService { } } - if (this._odataService?.options?.enableExpand) { - const datasetExtractor = this._odataService?.options?.datasetExtractor ?? + if (this._odataService.options.enableExpand) { + const datasetExtractor = this._odataService.options.datasetExtractor ?? odataVersion >= 4 ? (r: any) => r?.value : odataVersion === 3 ? (r: any) => r?.results : (r: any) => r?.d?.results; @@ -295,7 +295,7 @@ export class GridOdataService implements BackendService { updateFilters(columnFilters: ColumnFilters | CurrentFilter[], isUpdatedByPresetOrDynamically?: boolean) { let searchBy = ''; const searchByArray: string[] = []; - const odataVersion = this._odataService?.options?.version ?? 2; + const odataVersion = this._odataService.options.version ?? 2; // on filter preset load, we need to keep current filters if (isUpdatedByPresetOrDynamically) { @@ -426,8 +426,21 @@ export class GridOdataService implements BackendService { fieldName = titleCase(getHtmlStringOutput(fieldName || '')); } - // StartsWith + EndsWith combo - if (operator === OperatorType.startsWithEndsWith && Array.isArray(searchTerms) && searchTerms.length === 2) { + let filterQueryOverride: string | undefined = undefined; + if (typeof this._odataService.options.filterQueryOverride === 'function') { + filterQueryOverride = this._odataService.options.filterQueryOverride({ + fieldName: getHtmlStringOutput(fieldName), + columnDef, + operator, + columnFilterOperator: columnFilter.operator, + searchValue, + grid: this._grid + }); + } + + if (filterQueryOverride !== undefined) { + searchBy = filterQueryOverride; + } else if (operator === OperatorType.startsWithEndsWith && Array.isArray(searchTerms) && searchTerms.length === 2) { const tmpSearchTerms: string[] = []; const [sw, ew] = searchTerms; diff --git a/test/cypress/e2e/example09.cy.ts b/test/cypress/e2e/example09.cy.ts index e8ecf5e1e..b9ba0a5bd 100644 --- a/test/cypress/e2e/example09.cy.ts +++ b/test/cypress/e2e/example09.cy.ts @@ -310,6 +310,29 @@ describe('Example 09 - OData Grid', () => { .should('have.length', 1); }); + it('should perform filterQueryOverride when operator "%%" is selected', () => { + cy.get('.search-filter.filter-name select').find('option').last().then((element) => { + cy.get('.search-filter.filter-name select').select(element.val()); + }); + + cy.get('.search-filter.filter-name') + .find('input') + .clear() + .type('Jo%yn%er'); + + // wait for the query to finish + cy.get('[data-test=status]').should('contain', 'finished'); + + cy.get('[data-test=odata-query-result]') + .should(($span) => { + expect($span.text()).to.eq(`$count=true&$top=10&$filter=(matchesPattern(Name, '%5EJo%25yn%25er$'))`); + }); + + cy.get('.grid9') + .find('.slick-row') + .should('have.length', 1); + }); + it('should click on Set Dynamic Filter and expect query and filters to be changed', () => { cy.get('[data-test=set-dynamic-filter]') .click();