Skip to content

Commit

Permalink
playground: filters matcher
Browse files Browse the repository at this point in the history
  • Loading branch information
Haroenv committed Jan 22, 2025
1 parent 68fabfe commit bf9af8f
Show file tree
Hide file tree
Showing 2 changed files with 212 additions and 0 deletions.
94 changes: 94 additions & 0 deletions packages/instantsearch-core/__tests__/match-filters.test.ts
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);
});
});
});
118 changes: 118 additions & 0 deletions packages/instantsearch-core/match-filters.ts
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;
}
}
}

0 comments on commit bf9af8f

Please sign in to comment.