diff --git a/src/__tests__/operations/property-filter.test.ts b/src/__tests__/operations/property-filter.test.ts index 266d2f4..3c8b2cf 100644 --- a/src/__tests__/operations/property-filter.test.ts +++ b/src/__tests__/operations/property-filter.test.ts @@ -3,6 +3,7 @@ import { test, expect, describe, vi } from 'vitest'; import { processItems } from '../../operations'; import { PropertyFilterOperator } from '../../interfaces'; +import * as logging from '../../logging'; const propertyFiltering = { filteringProperties: [ @@ -557,6 +558,40 @@ describe('extended operators', () => { expect(processed).toEqual(expectedResult); }); + test.each([ + { match: 'date' as const, operator: ':' }, + { match: 'date' as const, operator: '^' }, + { match: 'datetime' as const, operator: ':' }, + ])('warns if unsupported operator "$operator" given for match="$match"', ({ match, operator }) => { + const warnOnce = vi.spyOn(logging, 'warnOnce'); + const { items: processed } = processItems( + items, + { + propertyFilteringQuery: { + tokens: [{ propertyKey: 'timestamp', operator, value: '' }], + operation: 'and', + }, + }, + { + propertyFiltering: { + filteringProperties: [ + { + key: 'timestamp', + operators: [ + { operator: ':', match }, + { operator: '^', match }, + ], + propertyLabel: '', + groupValuesLabel: '', + }, + ], + }, + } + ); + expect(processed).toEqual([]); + expect(warnOnce).toHaveBeenCalledWith(`Unsupported operator "${operator}" given for match="${match}".`); + }); + test('throws if unexpected operator.match', () => { expect(() => processItems( @@ -587,6 +622,107 @@ 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 }) => { + 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 => { + 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]]); + }); + + test('warns when unsupported operator is used', () => { + const warnOnce = vi.spyOn(logging, 'warnOnce'); + processWithProperty('status', ':', []); + expect(warnOnce).toHaveBeenCalledWith('Unsupported operator ":" given for tokenType=="enum".'); + }); + + test('warns when token is not an array', () => { + const warnOnce = vi.spyOn(logging, 'warnOnce'); + processWithProperty('status', '=', null); + expect(warnOnce).toHaveBeenCalledWith('The token value must be an array when tokenType=="enum".'); + }); +}); + describe('Token groups', () => { test('token groups have precedence over tokens', () => { const { items: processed } = processItems( diff --git a/src/index.ts b/src/index.ts index 53fef67..f0c3a2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export { PropertyFilterOperatorMatch, PropertyFilterOption, PropertyFilterProperty, + PropertyFilterTokenType, PropertyFilterQuery, PropertyFilterToken, PropertyFilterTokenGroup, diff --git a/src/interfaces.ts b/src/interfaces.ts index 869356b..3f32545 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -133,11 +133,14 @@ export type PropertyFilterOperator = '<' | '<=' | '>' | '>=' | ':' | '!:' | '=' export interface PropertyFilterOperatorExtended { operator: PropertyFilterOperator; + tokenType?: PropertyFilterTokenType; match?: PropertyFilterOperatorMatch; form?: PropertyFilterOperatorForm; format?: PropertyFilterOperatorFormat; } +export type PropertyFilterTokenType = 'value' | 'enum'; + export type PropertyFilterOperatorMatch = | PropertyFilterOperatorMatchByType | PropertyFilterOperatorMatchCustom; @@ -182,6 +185,7 @@ export interface PropertyFilterProperty { defaultOperator?: PropertyFilterOperator; group?: string; } + export interface PropertyFilterOption { propertyKey: string; value: string; diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..757dbac --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Allow use of `process.env.NODE_ENV` specifically. + */ +declare const process: { env: { NODE_ENV?: string } }; + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +const messageCache = new Set(); + +export function warnOnce(message: string): void { + if (isDevelopment) { + const warning = `[AwsUi] collection-hooks ${message}`; + if (!messageCache.has(warning)) { + messageCache.add(warning); + console.warn(warning); + } + } +} diff --git a/src/operations/property-filter.ts b/src/operations/property-filter.ts index 7376887..c8c1c1a 100644 --- a/src/operations/property-filter.ts +++ b/src/operations/property-filter.ts @@ -8,40 +8,100 @@ import { UseCollectionOptions, PropertyFilterProperty, PropertyFilterTokenGroup, + PropertyFilterTokenType, } from '../interfaces'; import { compareDates, compareTimestamps } from '../date-utils/compare-dates.js'; import { Predicate } from './compose-filters'; +import { warnOnce } from '../logging.js'; const filterUsingOperator = ( itemValue: any, - tokenValue: any, - { operator, match }: PropertyFilterOperatorExtended + { + tokenValue, + operator: { operator, match, tokenType }, + }: { + tokenValue: any; + operator: PropertyFilterOperatorExtended; + } ) => { + // 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: + warnOnce(`Unsupported operator "${operator}" given for match="${match}".`); + 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)) { + warnOnce('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: + warnOnce(`Unsupported operator "${operator}" given for tokenType=="enum".`); 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; @@ -70,10 +130,10 @@ const filterUsingOperator = ( default: throw new Error('Unsupported operator given.'); } -}; +} function freeTextFilter( - value: string, + tokenValue: string, item: T, operator: PropertyFilterOperator, filteringPropertiesMap: FilteringPropertiesMap @@ -87,7 +147,7 @@ function freeTextFilter( 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 }); }); } @@ -100,12 +160,15 @@ function filterByToken(token: PropertyFilterToken, item: T, filteringProperti ) { return false; } - const operator = - filteringPropertiesMap[token.propertyKey as keyof FilteringPropertiesMap].operators[token.operator]; + const property = filteringPropertiesMap[token.propertyKey as keyof FilteringPropertiesMap]; + 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); } @@ -154,12 +217,10 @@ export function createPropertyFilterPredicate( 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