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 @@
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();