diff --git a/src/__tests__/operations/property-filter.test.ts b/src/__tests__/operations/property-filter.test.ts index c841ba4..266d2f4 100644 --- a/src/__tests__/operations/property-filter.test.ts +++ b/src/__tests__/operations/property-filter.test.ts @@ -586,3 +586,169 @@ describe('extended operators', () => { ).toThrow('Unsupported `operator.match` type given.'); }); }); + +describe('Token groups', () => { + test('token groups have precedence over tokens', () => { + const { items: processed } = processItems( + [{ field: 'A' }, { field: 'B' }], + { + propertyFilteringQuery: { + operation: 'and', + tokens: [{ propertyKey: 'field', operator: '=', value: 'A' }], + tokenGroups: [{ propertyKey: 'field', operator: '=', value: 'B' }], + }, + }, + { propertyFiltering } + ); + expect(processed).toEqual([{ field: 'B' }]); + }); + + test('filters by two OR token groups', () => { + const { items: processed } = processItems( + [ + { field: 'A1', anotherField: 'A2' }, + { field: 'A2', anotherField: 'A1' }, + { field: 'A1', anotherField: 'A3' }, + { field: 'A3', anotherField: 'A1' }, + { field: 'A2', anotherField: 'A3' }, + { field: 'A3', anotherField: 'A2' }, + { field: 'A3', anotherField: 'A3' }, + ], + { + propertyFilteringQuery: { + operation: 'and', + tokens: [], + tokenGroups: [ + { + operation: 'or', + tokens: [ + { propertyKey: 'field', operator: '=', value: 'A1' }, + { propertyKey: 'anotherField', operator: '=', value: 'A1' }, + ], + }, + { + operation: 'or', + tokens: [ + { propertyKey: 'field', operator: '=', value: 'A2' }, + { propertyKey: 'anotherField', operator: '=', value: 'A2' }, + ], + }, + ], + }, + }, + { propertyFiltering } + ); + expect(processed).toEqual([ + { field: 'A1', anotherField: 'A2' }, + { field: 'A2', anotherField: 'A1' }, + ]); + }); + + test('filters by two AND token groups', () => { + const { items: processed } = processItems( + [ + { field: 'A1', anotherField: 'A1' }, + { field: 'A2', anotherField: 'A2' }, + { field: 'A1', anotherField: 'A2' }, + { field: 'A2', anotherField: 'A1' }, + { field: 'A3', anotherField: 'A3' }, + ], + { + propertyFilteringQuery: { + operation: 'or', + tokens: [], + tokenGroups: [ + { + operation: 'and', + tokens: [ + { propertyKey: 'field', operator: '=', value: 'A1' }, + { propertyKey: 'anotherField', operator: '=', value: 'A1' }, + ], + }, + { + operation: 'and', + tokens: [ + { propertyKey: 'field', operator: '=', value: 'A2' }, + { propertyKey: 'anotherField', operator: '=', value: 'A2' }, + ], + }, + ], + }, + }, + { propertyFiltering } + ); + expect(processed).toEqual([ + { field: 'A1', anotherField: 'A1' }, + { field: 'A2', anotherField: 'A2' }, + ]); + }); + + test('filters by a deeply nested group', () => { + const { items: processed } = processItems( + [ + { field: 'A1', anotherField: 'A1' }, + { field: 'A2', anotherField: 'A2' }, + { field: 'A1', anotherField: 'A2' }, + { field: 'A2', anotherField: 'A1' }, + { field: 'A1', anotherField: 'A3' }, + { field: 'A3', anotherField: 'A1' }, + { field: 'A2', anotherField: 'A3' }, + { field: 'A3', anotherField: 'A2' }, + { field: 'A3', anotherField: 'A3' }, + ], + { + propertyFilteringQuery: { + operation: 'or', + tokens: [], + tokenGroups: [ + { + operation: 'and', + tokens: [ + { + operation: 'or', + tokens: [ + { propertyKey: 'field', operator: '=', value: 'A1' }, + { propertyKey: 'anotherField', operator: '=', value: 'A1' }, + ], + }, + { + operation: 'or', + tokens: [ + { propertyKey: 'field', operator: '=', value: 'A2' }, + { propertyKey: 'anotherField', operator: '=', value: 'A2' }, + ], + }, + ], + }, + { + operation: 'or', + tokens: [ + { + operation: 'and', + tokens: [ + { propertyKey: 'field', operator: '=', value: 'A1' }, + { propertyKey: 'anotherField', operator: '=', value: 'A1' }, + ], + }, + { + operation: 'and', + tokens: [ + { propertyKey: 'field', operator: '=', value: 'A2' }, + { propertyKey: 'anotherField', operator: '=', value: 'A2' }, + ], + }, + ], + }, + ], + }, + }, + { propertyFiltering } + ); + expect(processed).toEqual([ + { field: 'A1', anotherField: 'A1' }, + { field: 'A2', anotherField: 'A2' }, + { field: 'A1', anotherField: 'A2' }, + { field: 'A2', anotherField: 'A1' }, + ]); + }); +}); diff --git a/src/interfaces.ts b/src/interfaces.ts index c2bd74b..869356b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -165,9 +165,14 @@ export interface PropertyFilterToken { propertyKey?: string; operator: PropertyFilterOperator; } +export interface PropertyFilterTokenGroup { + operation: PropertyFilterOperation; + tokens: readonly (PropertyFilterToken | PropertyFilterTokenGroup)[]; +} export interface PropertyFilterQuery { tokens: readonly PropertyFilterToken[]; operation: PropertyFilterOperation; + tokenGroups?: readonly (PropertyFilterToken | PropertyFilterTokenGroup)[]; } export interface PropertyFilterProperty { key: string; diff --git a/src/operations/property-filter.ts b/src/operations/property-filter.ts index f5ccc58..7376887 100644 --- a/src/operations/property-filter.ts +++ b/src/operations/property-filter.ts @@ -7,6 +7,7 @@ import { PropertyFilterToken, UseCollectionOptions, PropertyFilterProperty, + PropertyFilterTokenGroup, } from '../interfaces'; import { compareDates, compareTimestamps } from '../date-utils/compare-dates.js'; import { Predicate } from './compose-filters'; @@ -110,15 +111,22 @@ function filterByToken(token: PropertyFilterToken, item: T, filteringProperti } function defaultFilteringFunction(filteringPropertiesMap: FilteringPropertiesMap) { - return (item: T, { tokens, operation }: PropertyFilterQuery) => { - let result = operation === 'and' ? true : !tokens.length; - for (const token of tokens) { - result = - operation === 'and' - ? result && filterByToken(token, item, filteringPropertiesMap) - : result || filterByToken(token, item, filteringPropertiesMap); + return (item: T, query: PropertyFilterQuery) => { + function evaluate(tokenOrGroup: PropertyFilterToken | PropertyFilterTokenGroup): boolean { + if ('operation' in tokenOrGroup) { + let result = tokenOrGroup.operation === 'and' ? true : !tokenOrGroup.tokens.length; + for (const group of tokenOrGroup.tokens) { + result = tokenOrGroup.operation === 'and' ? result && evaluate(group) : result || evaluate(group); + } + return result; + } else { + return filterByToken(tokenOrGroup, item, filteringPropertiesMap); + } } - return result; + return evaluate({ + operation: query.operation, + tokens: query.tokenGroups ?? query.tokens, + }); }; }