From 8ff9c137776eea35561cb5bd1818af4fb50240a6 Mon Sep 17 00:00:00 2001 From: ChronicStone Date: Wed, 17 Jul 2024 22:03:27 +0200 Subject: [PATCH] feat(filters): add support for regex match mode --- docs/features/filtering.md | 1 + docs/filter-match-modes/regex.md | 160 +++++++++++++++++++++++++++ src/query.ts | 10 ++ src/types.ts | 34 +++--- src/utils.ts | 2 + test/fixtures/filtering.fixture.json | 160 +++++++++++++++++++++++++++ test/index.test.ts | 4 +- 7 files changed, 357 insertions(+), 14 deletions(-) create mode 100644 docs/filter-match-modes/regex.md diff --git a/docs/features/filtering.md b/docs/features/filtering.md index 099ae02..644a7ac 100644 --- a/docs/features/filtering.md +++ b/docs/features/filtering.md @@ -174,4 +174,5 @@ ArrayQuery supports various match modes for different types of comparisons. Each - 'lessThanOrEqual' - 'exists' - 'arrayLength' +- 'regex' - 'objectMatch' diff --git a/docs/filter-match-modes/regex.md b/docs/filter-match-modes/regex.md new file mode 100644 index 0000000..1aebb0d --- /dev/null +++ b/docs/filter-match-modes/regex.md @@ -0,0 +1,160 @@ +# Regex Match Mode + +The regex match mode allows you to filter data using regular expressions. This powerful feature enables complex pattern matching across your dataset. + +## Basic Usage + +To use the regex match mode, set the `matchMode` to `'regex'` in your filter configuration: + +```ts twoslash +import { query } from '@chronicstone/array-query' + +const data = [ + { id: 1, email: 'john@example.com' }, + { id: 2, email: 'jane@test.com' }, + { id: 3, email: 'bob@example.org' } +] + +const result = query(data, { + filter: [ + { key: 'email', matchMode: 'regex', value: '@example\\.(com|org)' } + ] +}) +``` + +This query will return all items where the email matches the pattern `@example.com` or `@example.org`. + +## Flags + +You can modify the behavior of the regex matching by providing flags. Flags are specified using the `params` option: + +```ts twoslash +import { query } from '@chronicstone/array-query' + +const data = [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'jane smith' }, + { id: 3, name: 'Bob Johnson' } +] + +const result = query(data, { + filter: [ + { + key: 'name', + matchMode: 'regex', + value: '^j.*', + params: { flags: 'i' } + } + ] +}) +``` + +## Raw RegExp + +Instead of defining the regex pattern as a string, you can also pass a regular expression object directly: + +```ts twoslash +import { query } from '@chronicstone/array-query' + +const data = [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'jane smith' }, + { id: 3, name: 'Bob Johnson' } +] + +const result = query(data, { + filter: [ + { + key: 'name', + matchMode: 'regex', + value: /^j.*/i + } + ] +}) +``` + +For a detailed explanation of available flags and their usage, please refer to the [MDN documentation on Regular Expression Flags](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#advanced_searching_with_flags). + +## Examples + +### Case-insensitive Matching + +```ts twoslash +import { query } from '@chronicstone/array-query' + +const data = [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'jane smith' }, + { id: 3, name: 'Bob Johnson' } +] + +const result = query(data, { + filter: [ + { + key: 'name', + matchMode: 'regex', + value: '^j.*', + params: { flags: 'i' } + } + ] +}) +``` + +This will match both "John Doe" and "jane smith". + +### Multi-line Matching + +```ts twoslash +import { query } from '@chronicstone/array-query' + +const data = [ + { id: 1, description: 'First line\nSecond line' }, + { id: 2, description: 'One line only' }, + { id: 3, description: 'First line\nLast line' } +] + +const result = query(data, { + filter: [ + { + key: 'description', + matchMode: 'regex', + value: '^Last', + params: { flags: 'm' } + } + ] +}) +``` + +This will match the item with id 3, where "Last" appears at the start of a line. + +## Combining with Other Filters + +You can combine regex filters with other filter types: + +```typescript +const result = query(data, { + filter: [ + { + key: 'name', + matchMode: 'regex', + value: '^j.*', + params: { flags: 'i' } + }, + { key: 'age', matchMode: 'greaterThan', value: 28 } + ] +}) +``` + +This will find items where the name starts with 'j' (case-insensitive) AND the age is greater than 28. + +## Performance Considerations + +While regex matching is powerful, it can be computationally expensive, especially on large datasets or with complex patterns. Use it judiciously and consider performance implications in your use case. + +## Escaping Special Characters + +Remember to properly escape special regex characters in your pattern strings. For example, to match a literal period, use `\\.` instead of `.`. + +## Further Reading + +For more information on JavaScript regular expressions, including pattern syntax and usage, refer to the [MDN Regular Expressions Guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). diff --git a/src/query.ts b/src/query.ts index 0bae048..1b22a6f 100644 --- a/src/query.ts +++ b/src/query.ts @@ -137,6 +137,16 @@ export function query>( }) } + if (filter.matchMode === 'regex') { + return processFilterWithLookup({ + type: 'regex', + params: filter?.params ?? null, + operator, + value, + filter: filter.value, + }) + } + if (filter.matchMode === 'objectMatch') { const params = typeof filter.params === 'function' ? filter.params(filter.value) : filter.params const filterValue = params?.transformFilterValue?.(filter.value) ?? filter.value diff --git a/src/types.ts b/src/types.ts index 1ce0c96..59ecd94 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,16 +37,20 @@ export type FilterMatchMode = | 'lessThan' | 'lessThanOrEqual' | 'exists' + | 'regex' | 'arrayLength' | 'objectMatch' export type NonObjectMatchMode = Exclude export type ComparatorMatchMode = Extract - +export type RegexMatchMode = Extract export interface ComparatorParams { dateMode?: boolean } +export interface RegexParams { + flags?: string +} export interface ObjectMapFilterParams { operator: 'AND' | 'OR' properties: Array<{ @@ -59,10 +63,13 @@ export interface ObjectMapFilterParams { } export type MatchModeCore = ({ - matchMode: Exclude + matchMode: Exclude } | { matchMode: ComparatorMatchMode params?: ComparatorParams +} | { + matchMode: 'regex' + params?: RegexParams } | { matchMode: 'objectMatch' params: ObjectMapFilterParams | ((value: any) => ObjectMapFilterParams) @@ -108,15 +115,16 @@ export interface QueryParams< export type QueryResult> = P extends { limit: number } ? { totalRows: number, totalPages: number, rows: T[], unpaginatedRows: T[] } : { rows: T[] } export interface MatchModeProcessorMap { - equals: ({ value, filter }: { value: any, filter: any }) => boolean - notEquals: ({ value, filter }: { value: any, filter: any }) => boolean - exists: ({ value, filter }: { value: any, filter: any }) => boolean - contains: ({ value, filter }: { value: any, filter: any }) => boolean - greaterThan: ({ value, filter }: { value: any, filter: any, params?: ComparatorParams }) => boolean - greaterThanOrEqual: ({ value, filter }: { value: any, filter: any, params?: ComparatorParams }) => boolean - lessThan: ({ value, filter }: { value: any, filter: any, params?: ComparatorParams }) => boolean - lessThanOrEqual: ({ value, filter }: { value: any, filter: any, params?: ComparatorParams }) => boolean - between: ({ value, filter }: { value: any, filter: any, params?: ComparatorParams }) => boolean - arrayLength: ({ value, filter }: { value: any, filter: any }) => boolean - objectMatch: ({ value, filter, params }: { value: any, filter: any, params: ObjectMapFilterParams, index?: number }) => boolean + equals: (f: { value: any, filter: any }) => boolean + notEquals: (f: { value: any, filter: any }) => boolean + exists: (f: { value: any, filter: any }) => boolean + contains: (f: { value: any, filter: any }) => boolean + greaterThan: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean + greaterThanOrEqual: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean + lessThan: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean + lessThanOrEqual: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean + between: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean + arrayLength: (f: { value: any, filter: any }) => boolean + objectMatch: (f: { value: any, filter: any, params: ObjectMapFilterParams, index?: number }) => boolean + regex: (f: { value: any, filter: any, params?: RegexParams }) => boolean } diff --git a/src/utils.ts b/src/utils.ts index 230fb01..08f7176 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -38,6 +38,8 @@ export const MatchModeProcessor: MatchModeProcessorMap = { between: ({ value, filter, params }) => { return params?.dateMode ? new Date(value) >= new Date(filter[0]) && new Date(value) <= new Date(filter[1]) : value >= filter[0] && value <= filter[1] }, + regex: ({ value, filter, params }) => + typeof value === 'string' && new RegExp(filter, params?.flags ?? '').test(value), arrayLength: ({ value, filter }) => Array.isArray(value) && value.length === filter, objectMatch: ({ value, filter, params, index }) => { const properties = typeof index !== 'undefined' && params.matchPropertyAtIndex ? [params.properties[index]] : params.properties diff --git a/test/fixtures/filtering.fixture.json b/test/fixtures/filtering.fixture.json index ca6ffdc..31a7608 100644 --- a/test/fixtures/filtering.fixture.json +++ b/test/fixtures/filtering.fixture.json @@ -209,6 +209,166 @@ ] } }, + { + "title": "Regex match mode - basic matching", + "data": [ + { "id": 1, "email": "john@example.com" }, + { "id": 2, "email": "jane@test.com" }, + { "id": 3, "email": "bob@example.org" } + ], + "query": { + "filter": [ + { "key": "email", "matchMode": "regex", "value": "@example\\.(com|org)" } + ] + }, + "result": { + "rows": [ + { "id": 1, "email": "john@example.com" }, + { "id": 3, "email": "bob@example.org" } + ] + } + }, + { + "title": "Regex match mode - case insensitive", + "data": [ + { "id": 1, "name": "John Doe" }, + { "id": 2, "name": "jane smith" }, + { "id": 3, "name": "Bob Johnson" } + ], + "query": { + "filter": [ + { + "key": "name", + "matchMode": "regex", + "value": "^j.*", + "params": { "flags": "i" } + } + ] + }, + "result": { + "rows": [ + { "id": 1, "name": "John Doe" }, + { "id": 2, "name": "jane smith" } + ] + } + }, + { + "title": "Regex match mode - case sensitive", + "data": [ + { "id": 1, "name": "John Doe" }, + { "id": 2, "name": "jane smith" }, + { "id": 3, "name": "Bob Johnson" } + ], + "query": { + "filter": [ + { + "key": "name", + "matchMode": "regex", + "value": "^j.*" + } + ] + }, + "result": { + "rows": [ + { "id": 2, "name": "jane smith" } + ] + } + }, + { + "title": "Regex match mode - global flag", + "data": [ + { "id": 1, "text": "The quick brown fox" }, + { "id": 2, "text": "The lazy dog" }, + { "id": 3, "text": "Quick foxes are quick" } + ], + "query": { + "filter": [ + { + "key": "text", + "matchMode": "regex", + "value": "quick", + "params": { "flags": "g" } + } + ] + }, + "result": { + "rows": [ + { "id": 1, "text": "The quick brown fox" }, + { "id": 3, "text": "Quick foxes are quick" } + ] + } + }, + { + "title": "Regex match mode - multiline flag", + "data": [ + { "id": 1, "description": "First line\nSecond line" }, + { "id": 2, "description": "One line only" }, + { "id": 3, "description": "First line\nLast line" } + ], + "query": { + "filter": [ + { + "key": "description", + "matchMode": "regex", + "value": "^Last", + "params": { "flags": "m" } + } + ] + }, + "result": { + "rows": [ + { "id": 3, "description": "First line\nLast line" } + ] + } + }, + { + "title": "Regex match mode - combined flags", + "data": [ + { "id": 1, "content": "HELLO\nWorld" }, + { "id": 2, "content": "hello\nWORLD" }, + { "id": 3, "content": "Hi\nthere" } + ], + "query": { + "filter": [ + { + "key": "content", + "matchMode": "regex", + "value": "^world", + "params": { "flags": "im" } + } + ] + }, + "result": { + "rows": [ + { "id": 1, "content": "HELLO\nWorld" }, + { "id": 2, "content": "hello\nWORLD" } + ] + } + }, + { + "title": "Regex match mode - with other filters", + "data": [ + { "id": 1, "name": "John Doe", "age": 30 }, + { "id": 2, "name": "Jane Smith", "age": 25 }, + { "id": 3, "name": "Bob Johnson", "age": 35 } + ], + "query": { + "filter": [ + { + "key": "name", + "matchMode": "regex", + "value": "^j.*", + "params": { "flags": "i" } + }, + { "key": "age", "matchMode": "greaterThan", "value": 28 } + ] + }, + "result": { + "rows": [ + { "id": 1, "name": "John Doe", "age": 30 } + ] + } + }, { "title": "Object Match mode", "data": [ diff --git a/test/index.test.ts b/test/index.test.ts index d37eaaf..4618a31 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { describe, expect, it } from 'vitest' import { query } from '../src' import PaginationFixtures from './fixtures/pagination.fixture.json' @@ -29,6 +30,8 @@ for (const fixture of fixtures) { for (const test of fixture.tests) { it(test.title, () => { const { unpaginatedRows, ...result } = (query as any)(test.data, test.query) + console.log('expected', test.result) + console.log('actual', result) expect(result).toEqual(test.result) }) } @@ -55,7 +58,6 @@ describe('performance check', () => { }) const end = performance.now() - // eslint-disable-next-line no-console console.info('Time taken:', end - start) expect(end - start).toBeLessThan(500) })