Skip to content

Commit

Permalink
feat(OData): add filterQueryOverride to OData Service (#1536)
Browse files Browse the repository at this point in the history
* feat(OData): add `filterQueryOverride` to OData Service
  • Loading branch information
zewa666 committed May 28, 2024
1 parent 9e89ded commit e8ffffe
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 10 deletions.
23 changes: 23 additions & 0 deletions docs/backend-services/OData.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ <h6 class="title is-6 italic">
(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.
</h6>

<div class="row mb-2">
Expand Down
32 changes: 31 additions & 1 deletion examples/vite-demo-vanilla-bundle/src/examples/example09.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' },
],
}
},
{
Expand All @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}
});
Expand Down
19 changes: 19 additions & 0 deletions packages/common/src/interfaces/backendServiceOption.interface.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<any> | 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
}
5 changes: 4 additions & 1 deletion packages/odata/src/interfaces/odataOption.interface.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -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[];

Expand Down
44 changes: 44 additions & 0 deletions packages/odata/src/services/__tests__/grid-odata.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 21 additions & 8 deletions packages/odata/src/services/grid-odata.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down
23 changes: 23 additions & 0 deletions test/cypress/e2e/example09.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit e8ffffe

Please sign in to comment.