-
Notifications
You must be signed in to change notification settings - Fork 530
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
212 additions
and
0 deletions.
There are no files selected for viewing
94 changes: 94 additions & 0 deletions
94
packages/instantsearch-core/__tests__/match-filters.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import { Conditions, matchConditions } from '../match-filters'; | ||
|
||
describe('matchConditions', () => { | ||
describe('facetFilters', () => { | ||
test.each([ | ||
{ | ||
title: 'a single facet filter', | ||
target: { objectID: '1', color: 'red' }, | ||
conditions: { facetFilters: ['color:red'] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'a single facet filter with different casing', | ||
target: { objectID: '1', color: 'RED' }, | ||
conditions: { facetFilters: ['color:red'] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'a single facet filter with different accent', | ||
target: { objectID: '1', color: 'Am\u00e9lie' }, | ||
conditions: { facetFilters: ['color:Am\u0065\u0301lie'] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'a single facet filter with a negation', | ||
target: { objectID: '1', color: 'red' }, | ||
conditions: { facetFilters: ['color:-red'] } as Conditions, | ||
expected: false, | ||
}, | ||
{ | ||
title: 'a single facet filter with an escaped negation', | ||
target: { objectID: '1', color: '-red' }, | ||
conditions: { facetFilters: ['color:\\-red'] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'multiple filters', | ||
target: { objectID: '1', color: 'red', size: 'm' }, | ||
conditions: { facetFilters: ['color:red', 'size:m'] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'disjunctive filters (all match)', | ||
target: { objectID: '1', color: 'red', size: 'm' }, | ||
conditions: { facetFilters: [['color:red', 'size:m']] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'disjunctive filters (one matches)', | ||
target: { objectID: '1', color: 'red', size: 's' }, | ||
conditions: { facetFilters: [['color:red', 'size:m']] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'disjunctive filters (none match)', | ||
target: { objectID: '1', color: 'blue', size: 's' }, | ||
conditions: { facetFilters: [['color:red', 'size:m']] } as Conditions, | ||
expected: false, | ||
}, | ||
{ | ||
title: 'disjunctive filters (negated)', | ||
target: { objectID: '1', color: 'red', size: 'm' }, | ||
conditions: { facetFilters: [['color:red', '-size:m']] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'numeric filters', | ||
target: { objectID: '1', price: 42 }, | ||
conditions: { facetFilters: ['price:42'] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'numeric filters with negation', | ||
target: { objectID: '1', price: 42 }, | ||
conditions: { facetFilters: ['price:-42'] } as Conditions, | ||
expected: false, | ||
}, | ||
{ | ||
title: 'numeric filters with negation (different value)', | ||
target: { objectID: '1', price: 42 }, | ||
conditions: { facetFilters: ['price:-43'] } as Conditions, | ||
expected: true, | ||
}, | ||
{ | ||
title: 'numeric filters with negation (multiple conditions)', | ||
target: { objectID: '1', price: 43 }, | ||
conditions: { facetFilters: ['price:-42', 'price:43'] } as Conditions, | ||
expected: true, | ||
}, | ||
])('returns $expected with $title', ({ target, conditions, expected }) => { | ||
expect(matchConditions(target, conditions)).toBe(expected); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
type attribute = string; | ||
type value = string | number | boolean; | ||
type filter = `${attribute}:${value}`; | ||
|
||
export type Conditions = { | ||
/** | ||
* List of filters to match. Conditions are combined with AND. | ||
* If you want to combine filters with OR, you can use an array of filters as an item. | ||
*/ | ||
facetFilters?: Array<filter | filter[]>; | ||
filters?: string; | ||
}; | ||
|
||
type Value = string | number | boolean | undefined; | ||
|
||
type AlgoliaRecord = { | ||
objectID: string; | ||
[key: string]: Value | Value[] | Record<string, Value>; | ||
}; | ||
|
||
export function matchConditions( | ||
target: AlgoliaRecord, | ||
conditions: Conditions | ||
): boolean { | ||
return ( | ||
matchFacetFilters(target, conditions.facetFilters) && | ||
matchFilters(target, conditions.filters) | ||
); | ||
} | ||
|
||
function matchFacetFilters( | ||
target: AlgoliaRecord, | ||
facetFilters: Conditions['facetFilters'] | ||
): boolean { | ||
if (!facetFilters) return true; | ||
return facetFilters.every((condition) => | ||
Array.isArray(condition) | ||
? condition.some((c) => matchCondition(target, c)) | ||
: matchCondition(target, condition) | ||
); | ||
} | ||
|
||
function matchFilters( | ||
target: AlgoliaRecord, | ||
filters: Conditions['filters'] | ||
): boolean { | ||
if (!filters) return true; | ||
// TODO: implement the filter matching logic | ||
return true; | ||
} | ||
|
||
function matchCondition(target: AlgoliaRecord, condition: filter): boolean { | ||
const { attribute, value, negated } = splitFilter(condition); | ||
const targetValue = get(target, attribute); | ||
if (Array.isArray(targetValue)) { | ||
return targetValue.some((v) => equals(v, value, negated)); | ||
} | ||
return equals(targetValue, value, negated); | ||
} | ||
|
||
/** | ||
* Compare two values and return whether they are equal. If the condition is negated, the result is inverted. | ||
*/ | ||
function equals(a: Value | undefined, b: Value, negated: boolean): boolean { | ||
return negated ? a !== b : a === b; | ||
} | ||
|
||
function get(target: AlgoliaRecord, attribute: attribute): Value | undefined { | ||
const path = attribute.split(/(?<!\\)\./); | ||
// @ts-expect-error - too JavaScripty | ||
const value = path.reduce((current, key) => current && current[key], target); | ||
return normalize(value); | ||
} | ||
|
||
function splitFilter(filter: filter): { | ||
attribute: attribute; | ||
value: Value; | ||
negated: boolean; | ||
} { | ||
const [attribute, value] = filter.split(/(?<!\\):/) as [attribute, string]; | ||
const negated = value[0] === '-'; | ||
const normalizedValue = normalize(value); | ||
const removedNegation = negated | ||
? removeNegation(normalizedValue) | ||
: normalizedValue; | ||
return { | ||
attribute, | ||
value: removedNegation, | ||
negated, | ||
}; | ||
} | ||
|
||
function normalize(value: Value): Value { | ||
if (value === 'true') return true; | ||
if (value === 'false') return false; | ||
if (!isNaN(Number(value))) return Number(value); | ||
if (typeof value === 'string') | ||
return removeLeadingEscape(value.toLocaleLowerCase().normalize('NFC')); | ||
return value; | ||
} | ||
|
||
function removeLeadingEscape(value: string | `\\${string}`): string { | ||
return value[0] === '\\' ? value.slice(1) : value; | ||
} | ||
|
||
function removeNegation(value: Value): Value { | ||
switch (typeof value) { | ||
case 'string': { | ||
return value[0] === '-' ? value.slice(1) : value; | ||
} | ||
case 'number': { | ||
return -value; | ||
} | ||
default: { | ||
return !value; | ||
} | ||
} | ||
} |