Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Property filter token type Enum #85

Merged
merged 2 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/__tests__/operations/property-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,95 @@ describe('extended operators', () => {
});
});

describe('matching enum token', () => {
const obj = { value: 0 };
const items = [
/* index=0 */ { status: 'ACTIVE' },
/* index=1 */ { status: 'ACTIVATING' },
/* index=2 */ { status: 'NOT_ACTIVE' },
/* index=3 */ { status: 'DEACTIVATING' },
/* index=4 */ { status: 'TERMINATED' },
/* index=5 */ { status: 0 },
/* index=6 */ { status: obj },
];

function processWithProperty(propertyKey: string, operator: string, token: any, itemsOverride = items) {
return processItems(
itemsOverride,
{
propertyFilteringQuery: { operation: 'and', tokens: [{ propertyKey, operator, value: token }] },
},
{
propertyFiltering: {
filteringProperties: [
{
key: 'status',
operators: [
{ operator: '=', tokenType: 'enum' },
{ operator: '!=', tokenType: 'enum' },
{ operator: ':', tokenType: 'enum' },
{ operator: '!:', tokenType: 'value' },
],
groupValuesLabel: 'Status values',
propertyLabel: 'Status',
},
],
},
}
).items;
}

test.each(['=', '!=', ':'])('matches nothing when token=null and operator="%s"', operator => {
const processed = processWithProperty('status', operator, null);
expect(processed).toEqual([]);
});

test.each(['=', '!=', ':'])('matches nothing when token="" and operator="%s"', operator => {
const processed = processWithProperty('status', operator, '');
expect(processed).toEqual([]);
});

test('matches all when token=[] and operator="!="', () => {
const processed = processWithProperty('status', '!=', []);
expect(processed).toEqual(items);
});

test('matches nothing when token=[] and operator="="', () => {
const processed = processWithProperty('status', '=', []);
expect(processed).toEqual([]);
});

test.each([{ token: ['NOT_ACTIVE', 'ACTIVE'] }])('matches some when token=$token and operator="="', ({ token }) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why test.each here (and below)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is partially for convenience with other tests so that the tokens are defined here and not in the test body and partially to reflect the token in the test description (with $token).

const processed = processWithProperty('status', '=', token);
expect(processed).toEqual([items[0], items[2]]);
});

test.each([{ token: [obj, 0] }])('matches some when token=$token and operator="="', ({ token }) => {
const processed = processWithProperty('status', '=', token);
expect(processed).toEqual([items[5], items[6]]);
});

test.each([{ token: ['ACTIVE', 'NOT_ACTIVE'] }])('matches some when token=$token and operator="!="', ({ token }) => {
const processed = processWithProperty('status', '!=', token);
expect(processed).toEqual([items[1], items[3], items[4], items[5], items[6]]);
});

test.each([{ token: [0, obj] }])('matches some when token=$token and operator="!="', ({ token }) => {
const processed = processWithProperty('status', '!=', token);
expect(processed).toEqual([items[0], items[1], items[2], items[3], items[4]]);
});

test.each([['ACTIVE'], 'ACTIVE'])('matches nothing when token=%s and operator=":"', token => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does :'ACTIVE' not match anything?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is because the token type for ":" is set as "enum" so the enum matchers are used and those are only defined for "=" and "!=". It will work should the token type be "value" or missing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I'd missed the line { operator: '!:', tokenType: 'value' } above, so was missing why : and !: were behaving differently. Do we need any test coverage for { operator: '!:', tokenType: 'enum' }?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say it is enough to ensure the = and != are working as expected as every other operator is supposed to return false no matter what is the value.

Btw, we can alternatively throw instead of returning false when an unsupported configuration is used. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing might be excessive, but I think a dev time warning would make sense, sounds a good improvement.

const processed = processWithProperty('status', ':', token);
expect(processed).toEqual([]);
});

test('matches some when token="ING" and operator="!:"', () => {
const processed = processWithProperty('status', '!:', 'ING');
expect(processed).toEqual([items[0], items[2], items[4], items[5], items[6]]);
});
});

describe('Token groups', () => {
test('token groups have precedence over tokens', () => {
const { items: processed } = processItems(
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
PropertyFilterOperatorMatch,
PropertyFilterOption,
PropertyFilterProperty,
PropertyFilterTokenType,
PropertyFilterQuery,
PropertyFilterToken,
PropertyFilterTokenGroup,
Expand Down
4 changes: 4 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,14 @@ export type PropertyFilterOperator = '<' | '<=' | '>' | '>=' | ':' | '!:' | '='

export interface PropertyFilterOperatorExtended<TokenValue> {
operator: PropertyFilterOperator;
tokenType?: PropertyFilterTokenType;
match?: PropertyFilterOperatorMatch<TokenValue>;
form?: PropertyFilterOperatorForm<TokenValue>;
format?: PropertyFilterOperatorFormat<TokenValue>;
}

export type PropertyFilterTokenType = 'value' | 'enum';

export type PropertyFilterOperatorMatch<TokenValue> =
| PropertyFilterOperatorMatchByType
| PropertyFilterOperatorMatchCustom<TokenValue>;
Expand Down Expand Up @@ -182,6 +185,7 @@ export interface PropertyFilterProperty<TokenValue = any> {
defaultOperator?: PropertyFilterOperator;
group?: string;
}

export interface PropertyFilterOption {
propertyKey: string;
value: string;
Expand Down
118 changes: 89 additions & 29 deletions src/operations/property-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,99 @@ import {
UseCollectionOptions,
PropertyFilterProperty,
PropertyFilterTokenGroup,
PropertyFilterTokenType,
} from '../interfaces';
import { compareDates, compareTimestamps } from '../date-utils/compare-dates.js';
import { Predicate } from './compose-filters';

const filterUsingOperator = (
itemValue: any,
tokenValue: any,
{ operator, match }: PropertyFilterOperatorExtended<any>
{
tokenValue,
operator: { operator, match, tokenType },
}: {
tokenValue: any;
operator: PropertyFilterOperatorExtended<any>;
}
) => {
// For match="date" or match="datetime" we expect the value to be a Date object.
// The token value is expected to be an ISO date- or datetime string, example:
// match(operator="=", token="2020-01-01", value=new Date("2020-01-01")) == true
if (match === 'date' || match === 'datetime') {
const comparator = match === 'date' ? compareDates : compareTimestamps;
const comparisonResult = comparator(itemValue, tokenValue);
return matchDateValue({ tokenValue, itemValue, operator, match });
}
// For custom match functions there is no expectation to value or token type: the function is supposed
// to handle everything. It is recommended to treat both the token and the value as unknowns.
else if (typeof match === 'function') {
return match(itemValue, tokenValue);
} else if (match) {
throw new Error('Unsupported `operator.match` type given.');
}
// For default matching logic we expect the value to be a primitive type or an object that matches by reference.
// The token can be an array (tokenType="enum") or a value (tokenType="value" or tokenType=undefined), examples:
// match(operator="=", token="A", value="A") == true
// match(operator="=", token=["A", "B"], value="A") == true
return matchPrimitiveValue({ tokenValue, itemValue, operator, tokenType });
};

function matchDateValue({
tokenValue,
itemValue,
operator,
match,
}: {
tokenValue: any;
itemValue: any;
operator: PropertyFilterOperator;
match: 'date' | 'datetime';
}) {
const comparator = match === 'date' ? compareDates : compareTimestamps;
const comparisonResult = comparator(itemValue, tokenValue);
switch (operator) {
case '<':
return comparisonResult < 0;
case '<=':
return comparisonResult <= 0;
case '>':
return comparisonResult > 0;
case '>=':
return comparisonResult >= 0;
case '=':
return comparisonResult === 0;
case '!=':
return comparisonResult !== 0;
default:
// Other operators are not supported.
return false;
}
}

function matchPrimitiveValue({
tokenValue,
itemValue,
operator,
tokenType,
}: {
tokenValue: any;
itemValue: any;
operator: PropertyFilterOperator;
tokenType?: PropertyFilterTokenType;
}): boolean {
if (tokenType === 'enum') {
if (!tokenValue || !Array.isArray(tokenValue)) {
// The token value must be an array when tokenType=="enum".
return false;
}
switch (operator) {
case '<':
return comparisonResult < 0;
case '<=':
return comparisonResult <= 0;
case '>':
return comparisonResult > 0;
case '>=':
return comparisonResult >= 0;
case '=':
return comparisonResult === 0;
return tokenValue && tokenValue.includes(itemValue);
case '!=':
return comparisonResult !== 0;
return !tokenValue || !tokenValue.includes(itemValue);
default:
// Other operators are not supported.
return false;
}
} else if (typeof match === 'function') {
return match(itemValue, tokenValue);
} else if (match) {
throw new Error('Unsupported `operator.match` type given.');
}

switch (operator) {
case '<':
return itemValue < tokenValue;
Expand Down Expand Up @@ -70,10 +129,10 @@ const filterUsingOperator = (
default:
throw new Error('Unsupported operator given.');
}
};
}

function freeTextFilter<T>(
value: string,
tokenValue: string,
item: T,
operator: PropertyFilterOperator,
filteringPropertiesMap: FilteringPropertiesMap<T>
Expand All @@ -87,7 +146,7 @@ function freeTextFilter<T>(
if (!propertyOperator) {
return isNegation;
}
return filterUsingOperator(item[propertyKey as keyof typeof item], value, propertyOperator);
return filterUsingOperator(item[propertyKey as keyof typeof item], { tokenValue, operator: propertyOperator });
});
}

Expand All @@ -100,12 +159,15 @@ function filterByToken<T>(token: PropertyFilterToken, item: T, filteringProperti
) {
return false;
}
const operator =
filteringPropertiesMap[token.propertyKey as keyof FilteringPropertiesMap<T>].operators[token.operator];
const property = filteringPropertiesMap[token.propertyKey as keyof FilteringPropertiesMap<T>];
const operator = property.operators[token.operator];
const itemValue: any = operator?.match
? item[token.propertyKey as keyof T]
: fixupFalsyValues(item[token.propertyKey as keyof T]);
return filterUsingOperator(itemValue, token.value, operator ?? { operator: token.operator });
return filterUsingOperator(itemValue, {
tokenValue: token.value,
operator: operator ?? { operator: token.operator },
});
}
return freeTextFilter(token.value, item, token.operator, filteringPropertiesMap);
}
Expand Down Expand Up @@ -154,12 +216,10 @@ export function createPropertyFilterPredicate<T>(
if (typeof op === 'string') {
operatorMap[op] = { operator: op };
} else {
operatorMap[op.operator] = { operator: op.operator, match: op.match };
operatorMap[op.operator] = { operator: op.operator, match: op.match, tokenType: op.tokenType };
}
});
acc[key as keyof T] = {
operators: operatorMap,
};
acc[key as keyof T] = { operators: operatorMap };
return acc;
},
{} as FilteringPropertiesMap<T>
Expand Down
Loading