├── docs
├── api-reference
│ ├── query-filter.md
│ ├── query-function.md
│ ├── query-params.md
│ └── filter-match-mode.md
├── public
│ ├── favicon.ico
│ └── images
│ │ └── logo.svg
├── filter-match-modes
│ ├── not-equals.md
│ ├── equals.md
│ ├── contains.md
│ ├── exists.md
│ ├── less-than.md
│ ├── between.md
│ ├── greater-than.md
│ ├── array-length.md
│ ├── less-than-or-equal.md
│ ├── greater-than-or-equal.md
│ ├── regex.md
│ └── object-match.md
├── .vitepress
│ ├── theme
│ │ ├── index.ts
│ │ └── style.css
│ └── config.ts
├── getting-started
│ ├── installation.md
│ └── introduction.md
├── index.md
└── features
│ ├── pagination.md
│ ├── type-safety.md
│ ├── searching.md
│ ├── sorting.md
│ └── filtering.md
├── .github
├── FUNDING.yml
└── workflows
│ ├── release.yml
│ └── ci.yml
├── .npmrc
├── CONTRIBUTING.md
├── bun.lockb
├── src
├── index.ts
├── types.ts
├── query.ts
└── utils.ts
├── pnpm-workspace.yaml
├── eslint.config.js
├── .gitignore
├── tsconfig.json
├── LICENSE
├── .vscode
└── settings.json
├── package.json
├── test
├── index.test.ts
└── fixtures
│ ├── pagination.fixture.json
│ ├── sorting.fixture.json
│ ├── search.fixture.json
│ └── filtering.fixture.json
└── README.md
/docs/api-reference/query-filter.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/api-reference/query-function.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/api-reference/query-params.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/api-reference/filter-match-mode.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [antfu]
2 | opencollective: antfu
3 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | ignore-workspace-root-check=true
2 | shell-emulator=true
3 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Please refer to https://github.com/antfu/contribute
2 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChronicStone/array-ql/HEAD/bun.lockb
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export type * from './types'
2 | export { query } from './query'
3 |
--------------------------------------------------------------------------------
/docs/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChronicStone/array-ql/HEAD/docs/public/favicon.ico
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - playground
3 | - docs
4 | - packages/*
5 | - examples/*
6 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import antfu from '@antfu/eslint-config'
3 |
4 | export default antfu()
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .cache
2 | .DS_Store
3 | .idea
4 | *.log
5 | *.tgz
6 | coverage
7 | dist
8 | lib-cov
9 | logs
10 | node_modules
11 | temp
12 | playground/.nuxt
13 | docs/.vitepress/dist
14 | docs/.vitepress/cache
15 | .vercel
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["ESNext"],
5 | "module": "ESNext",
6 | "moduleResolution": "Bundler",
7 | "resolveJsonModule": true,
8 | "strict": true,
9 | "strictNullChecks": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "skipDefaultLibCheck": true,
13 | "skipLibCheck": true
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | permissions:
4 | contents: write
5 |
6 | on:
7 | push:
8 | tags:
9 | - 'v*'
10 |
11 | jobs:
12 | release:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v4
16 | with:
17 | fetch-depth: 0
18 |
19 | - name: Set node
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: lts/*
23 |
24 | - run: npx changelogithub
25 | env:
26 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
27 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/not-equals.md:
--------------------------------------------------------------------------------
1 | # Not Equals Match Mode
2 |
3 | The 'notEquals' match mode checks for inequality between the field value and the specified value.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', status: 'active' },
10 | { id: 2, name: 'Jane', status: 'inactive' },
11 | { id: 3, name: 'Bob', status: 'active' }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'status', matchMode: 'notEquals', value: 'active' }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 2, name: 'Jane', status: 'inactive' }
25 | ]
26 | ```
27 |
28 | This filter returns all items where the 'status' is not equal to 'active'.
29 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/equals.md:
--------------------------------------------------------------------------------
1 | # Equals Match Mode
2 |
3 | The 'equals' match mode checks for exact equality between the field value and the specified value.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', status: 'active' },
10 | { id: 2, name: 'Jane', status: 'inactive' },
11 | { id: 3, name: 'Bob', status: 'active' }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'status', matchMode: 'equals', value: 'active' }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 1, name: 'John', status: 'active' },
25 | { id: 3, name: 'Bob', status: 'active' }
26 | ]
27 | ```
28 |
29 | This filter returns all items where the 'status' is exactly equal to 'active'.
30 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/contains.md:
--------------------------------------------------------------------------------
1 | # Contains Match Mode
2 |
3 | The 'contains' match mode checks if a string value contains the specified substring.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John Doe', email: 'john@example.com' },
10 | { id: 2, name: 'Jane Smith', email: 'jane@example.com' },
11 | { id: 3, name: 'Bob Johnson', email: 'bob@example.com' }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'name', matchMode: 'contains', value: 'oh' }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 1, name: 'John Doe', email: 'john@example.com' }
25 | ]
26 | ```
27 |
28 | In this example, the filter returns all items where the 'name' field contains the substring 'oh'.
29 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/index.ts:
--------------------------------------------------------------------------------
1 | // https://vitepress.dev/guide/custom-theme
2 | import { h } from 'vue'
3 | import type { Theme } from 'vitepress'
4 | import DefaultTheme from 'vitepress/theme'
5 | import './style.css'
6 | import TwoslashFloatingVue from '@shikijs/vitepress-twoslash/client'
7 | import '@shikijs/vitepress-twoslash/style.css'
8 | import vitepressNprogress from 'vitepress-plugin-nprogress'
9 | import 'vitepress-plugin-nprogress/lib/css/index.css'
10 | import 'uno.css'
11 |
12 | export default {
13 | extends: DefaultTheme,
14 | Layout: () => {
15 | return h(DefaultTheme.Layout, null, {
16 | // https://vitepress.dev/guide/extending-default-theme#layout-slots
17 | })
18 | },
19 | enhanceApp(ctx) {
20 | ctx.app.use(TwoslashFloatingVue)
21 | vitepressNprogress(ctx)
22 | },
23 | } satisfies Theme
24 |
--------------------------------------------------------------------------------
/docs/public/images/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Anthony Fu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // Disable the default formatter, use eslint instead
3 | "prettier.enable": false,
4 | "editor.formatOnSave": false,
5 |
6 | // Auto fix
7 | "editor.codeActionsOnSave": {
8 | "source.fixAll.eslint": "explicit",
9 | "source.organizeImports": "never"
10 | },
11 |
12 | // Silent the stylistic rules in you IDE, but still auto fix them
13 | "eslint.rules.customizations": [
14 | { "rule": "style/*", "severity": "off" },
15 | { "rule": "*-indent", "severity": "off" },
16 | { "rule": "*-spacing", "severity": "off" },
17 | { "rule": "*-spaces", "severity": "off" },
18 | { "rule": "*-order", "severity": "off" },
19 | { "rule": "*-dangle", "severity": "off" },
20 | { "rule": "*-newline", "severity": "off" },
21 | { "rule": "*quotes", "severity": "off" },
22 | { "rule": "*semi", "severity": "off" }
23 | ],
24 |
25 | // Enable eslint for all supported languages
26 | "eslint.validate": [
27 | "javascript",
28 | "javascriptreact",
29 | "typescript",
30 | "typescriptreact",
31 | "vue",
32 | "html",
33 | "markdown",
34 | "json",
35 | "jsonc",
36 | "yaml"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/exists.md:
--------------------------------------------------------------------------------
1 | # Exists Match Mode
2 |
3 | The 'exists' match mode checks if a specified field exists (or doesn't exist) in the data objects.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', age: 25 },
10 | { id: 2, name: 'Jane' },
11 | { id: 3, name: 'Bob', age: 35 }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'age', matchMode: 'exists', value: true }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 1, name: 'John', age: 25 },
25 | { id: 3, name: 'Bob', age: 35 }
26 | ]
27 | ```
28 |
29 | This filter returns all items where the 'age' field exists.
30 |
31 | To find items where a field doesn't exist:
32 |
33 | ```ts twoslash
34 | import { query } from '@chronicstone/array-query'
35 |
36 | const data = [
37 | { id: 1, name: 'John', age: 25 },
38 | { id: 2, name: 'Jane' },
39 | { id: 3, name: 'Bob', age: 35 }
40 | ]
41 |
42 | const result = query(data, {
43 | filter: [
44 | { key: 'age', matchMode: 'exists', value: false }
45 | ]
46 | })
47 | ```
48 |
49 | Output:
50 | ```ts twoslash
51 | [
52 | { id: 2, name: 'Jane' }
53 | ]
54 | ```
55 |
56 | This filter returns all items where the 'age' field does not exist.
57 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 | branches:
10 | - main
11 |
12 | jobs:
13 | lint:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: Install Bun
19 | uses: oven-sh/setup-bun@v1
20 |
21 | - name: Install dependencies
22 | run: bun install
23 |
24 | - name: Lint
25 | run: bun run lint
26 |
27 | typecheck:
28 | runs-on: ubuntu-latest
29 | steps:
30 | - uses: actions/checkout@v4
31 |
32 | - name: Install Bun
33 | uses: oven-sh/setup-bun@v1
34 |
35 | - name: Install dependencies
36 | run: bun install
37 |
38 | - name: Typecheck
39 | run: bun run typecheck
40 |
41 | test:
42 | runs-on: ${{ matrix.os }}
43 |
44 | strategy:
45 | matrix:
46 | os: [ubuntu-latest, windows-latest, macos-latest]
47 | fail-fast: false
48 |
49 | steps:
50 | - uses: actions/checkout@v4
51 |
52 | - name: Install Bun
53 | uses: oven-sh/setup-bun@v1
54 |
55 | - name: Install dependencies
56 | run: bun install
57 |
58 | - name: Build
59 | run: bun run build
60 |
61 | - name: Test
62 | run: bun run test
63 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/less-than.md:
--------------------------------------------------------------------------------
1 | # Less Than Match Mode
2 |
3 | The 'lessThan' match mode checks if a numeric or date value is less than the specified value.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', age: 25 },
10 | { id: 2, name: 'Jane', age: 30 },
11 | { id: 3, name: 'Bob', age: 35 }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'age', matchMode: 'lessThan', value: 30 }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 1, name: 'John', age: 25 }
25 | ]
26 | ```
27 |
28 | This filter returns all items where the 'age' is less than 30.
29 |
30 | For date comparisons, you can use the `dateMode` parameter:
31 |
32 | ```ts twoslash
33 | import { query } from '@chronicstone/array-query'
34 |
35 | const data = [
36 | { id: 1, name: 'John', joinDate: '2023-01-15' },
37 | { id: 2, name: 'Jane', joinDate: '2023-06-20' },
38 | { id: 3, name: 'Bob', joinDate: '2023-12-05' }
39 | ]
40 |
41 | const result = query(data, {
42 | filter: [
43 | {
44 | key: 'joinDate',
45 | matchMode: 'lessThan',
46 | value: '2023-06-01',
47 | params: { dateMode: true }
48 | }
49 | ]
50 | })
51 | ```
52 |
53 | Output:
54 | ```ts twoslash
55 | [
56 | { id: 1, name: 'John', joinDate: '2023-01-15' }
57 | ]
58 | ```
59 |
60 | This filter returns all items where the 'joinDate' is before June 1, 2023.
61 |
--------------------------------------------------------------------------------
/docs/getting-started/installation.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | Installing ArrayQuery in your project is straightforward. You can use your preferred package manager to add it to your dependencies.
4 |
5 | ## Installing ArrayQuery
6 |
7 | Choose your preferred package manager from the options below:
8 |
9 | ::: code-group
10 | ```sh [npm]
11 | npm install @chronicstone/array-query
12 | ```
13 |
14 | ```sh [yarn]
15 | yarn add @chronicstone/array-query
16 | ```
17 |
18 | ```sh [pnpm]
19 | pnpm add @chronicstone/array-query
20 | ```
21 |
22 | ```sh [bun]
23 | bun add @chronicstone/array-query
24 | ```
25 | :::
26 |
27 | ## Importing ArrayQuery
28 |
29 | Once installed, you can import the `query` function from ArrayQuery in your TypeScript or JavaScript files:
30 |
31 | ```ts twoslash
32 | import { query } from '@chronicstone/array-query'
33 | ```
34 |
35 | The package also exports all its types :
36 |
37 | ```ts twoslash
38 | import type { FilterMatchMode, QueryFilter, QueryParams } from '@chronicstone/array-query'
39 | ```
40 |
41 | ## Use the library
42 |
43 | You can now use the `query` function to perform various operations on your array data.
44 |
45 | ```ts twoslash
46 | import { query } from '@chronicstone/array-query'
47 |
48 | const data = [
49 | { id: 1, name: 'John Doe', age: 30 },
50 | { id: 2, name: 'Jane Smith', age: 25 },
51 | { id: 3, name: 'Bob Johnson', age: 35 }
52 | ]
53 |
54 | const result = query(data, {
55 | limit: 10,
56 | sort: { key: 'age', order: 'asc' },
57 | })
58 | ```
59 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/between.md:
--------------------------------------------------------------------------------
1 | # Between Match Mode
2 |
3 | The 'between' match mode checks if a numeric or date value is within a specified range.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', age: 25 },
10 | { id: 2, name: 'Jane', age: 30 },
11 | { id: 3, name: 'Bob', age: 35 }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'age', matchMode: 'between', value: [28, 32] }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 2, name: 'Jane', age: 30 }
25 | ]
26 | ```
27 |
28 | This filter returns all items where the 'age' is between 28 and 32 (inclusive).
29 |
30 | For date comparisons, you can use the `dateMode` parameter:
31 |
32 | ```ts twoslash
33 | import { query } from '@chronicstone/array-query'
34 |
35 | const data = [
36 | { id: 1, name: 'John', joinDate: '2023-01-15' },
37 | { id: 2, name: 'Jane', joinDate: '2023-06-20' },
38 | { id: 3, name: 'Bob', joinDate: '2023-12-05' }
39 | ]
40 |
41 | const result = query(data, {
42 | filter: [
43 | {
44 | key: 'joinDate',
45 | matchMode: 'between',
46 | value: ['2023-05-01', '2023-08-31'],
47 | params: { dateMode: true }
48 | }
49 | ]
50 | })
51 | ```
52 |
53 | Output:
54 | ```ts twoslash
55 | [
56 | { id: 2, name: 'Jane', joinDate: '2023-06-20' }
57 | ]
58 | ```
59 |
60 | This filter returns all items where the 'joinDate' is between May 1, 2023 and August 31, 2023.
61 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/greater-than.md:
--------------------------------------------------------------------------------
1 | # Greater Than Match Mode
2 |
3 | The 'greaterThan' match mode checks if a numeric or date value is greater than the specified value.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', age: 25 },
10 | { id: 2, name: 'Jane', age: 30 },
11 | { id: 3, name: 'Bob', age: 35 }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'age', matchMode: 'greaterThan', value: 28 }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 2, name: 'Jane', age: 30 },
25 | { id: 3, name: 'Bob', age: 35 }
26 | ]
27 | ```
28 |
29 | This filter returns all items where the 'age' is greater than 28.
30 |
31 | For date comparisons, you can use the `dateMode` parameter:
32 |
33 | ```ts twoslash
34 | import { query } from '@chronicstone/array-query'
35 |
36 | const data = [
37 | { id: 1, name: 'John', joinDate: '2023-01-15' },
38 | { id: 2, name: 'Jane', joinDate: '2023-06-20' },
39 | { id: 3, name: 'Bob', joinDate: '2023-12-05' }
40 | ]
41 |
42 | const result = query(data, {
43 | filter: [
44 | {
45 | key: 'joinDate',
46 | matchMode: 'greaterThan',
47 | value: '2023-06-01',
48 | params: { dateMode: true }
49 | }
50 | ]
51 | })
52 | ```
53 |
54 | Output:
55 | ```ts twoslash
56 | [
57 | { id: 2, name: 'Jane', joinDate: '2023-06-20' },
58 | { id: 3, name: 'Bob', joinDate: '2023-12-05' }
59 | ]
60 | ```
61 |
62 | This filter returns all items where the 'joinDate' is after June 1, 2023.
63 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/array-length.md:
--------------------------------------------------------------------------------
1 | # Array Length Match Mode
2 |
3 | The 'arrayLength' match mode checks the length of an array field against a specified value.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', skills: ['JavaScript', 'TypeScript', 'React'] },
10 | { id: 2, name: 'Jane', skills: ['Python', 'Django'] },
11 | { id: 3, name: 'Bob', skills: ['Java', 'Spring', 'Hibernate', 'SQL'] }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'skills', matchMode: 'arrayLength', value: 3 }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 1, name: 'John', skills: ['JavaScript', 'TypeScript', 'React'] }
25 | ]
26 | ```
27 |
28 | This filter returns all items where the 'skills' array has exactly 3 elements.
29 |
30 | You can also use comparison operators with 'arrayLength':
31 |
32 | ```ts twoslash
33 | import { query } from '@chronicstone/array-query'
34 |
35 | const data = [
36 | { id: 1, name: 'John', skills: ['JavaScript', 'TypeScript', 'React'] },
37 | { id: 2, name: 'Jane', skills: ['Python', 'Django'] },
38 | { id: 3, name: 'Bob', skills: ['Java', 'Spring', 'Hibernate', 'SQL'] }
39 | ]
40 |
41 | const result = query(data, {
42 | filter: [
43 | { key: 'skills', matchMode: 'arrayLength', value: 2 }
44 | ]
45 | })
46 | ```
47 |
48 | Output:
49 | ```ts twoslash
50 | [
51 | { id: 2, name: 'Jane', skills: ['Python', 'Django'] },
52 | ]
53 | ```
54 |
55 | This filter returns all items where the 'skills' array has more than 2 elements.
56 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/less-than-or-equal.md:
--------------------------------------------------------------------------------
1 | # Less Than or Equal Match Mode
2 |
3 | The 'lessThanOrEqual' match mode checks if a numeric or date value is less than or equal to the specified value.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', age: 25 },
10 | { id: 2, name: 'Jane', age: 30 },
11 | { id: 3, name: 'Bob', age: 35 }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'age', matchMode: 'lessThanOrEqual', value: 30 }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 1, name: 'John', age: 25 },
25 | { id: 2, name: 'Jane', age: 30 }
26 | ]
27 | ```
28 |
29 | This filter returns all items where the 'age' is less than or equal to 30.
30 |
31 | For date comparisons, you can use the `dateMode` parameter:
32 |
33 | ```ts twoslash
34 | import { query } from '@chronicstone/array-query'
35 |
36 | const data = [
37 | { id: 1, name: 'John', joinDate: '2023-01-15' },
38 | { id: 2, name: 'Jane', joinDate: '2023-06-20' },
39 | { id: 3, name: 'Bob', joinDate: '2023-12-05' }
40 | ]
41 |
42 | const result = query(data, {
43 | filter: [
44 | {
45 | key: 'joinDate',
46 | matchMode: 'lessThanOrEqual',
47 | value: '2023-06-20',
48 | params: { dateMode: true }
49 | }
50 | ]
51 | })
52 | ```
53 |
54 | Output:
55 | ```ts twoslash
56 | [
57 | { id: 1, name: 'John', joinDate: '2023-01-15' },
58 | { id: 2, name: 'Jane', joinDate: '2023-06-20' }
59 | ]
60 | ```
61 |
62 | This filter returns all items where the 'joinDate' is on or before June 20, 2023.
63 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/greater-than-or-equal.md:
--------------------------------------------------------------------------------
1 | # Greater Than or Equal Match Mode
2 |
3 | The 'greaterThanOrEqual' match mode checks if a numeric or date value is greater than or equal to the specified value.
4 |
5 | ```ts twoslash
6 | import { query } from '@chronicstone/array-query'
7 |
8 | const data = [
9 | { id: 1, name: 'John', age: 25 },
10 | { id: 2, name: 'Jane', age: 30 },
11 | { id: 3, name: 'Bob', age: 35 }
12 | ]
13 |
14 | const result = query(data, {
15 | filter: [
16 | { key: 'age', matchMode: 'greaterThanOrEqual', value: 30 }
17 | ]
18 | })
19 | ```
20 |
21 | Output:
22 | ```ts twoslash
23 | [
24 | { id: 2, name: 'Jane', age: 30 },
25 | { id: 3, name: 'Bob', age: 35 }
26 | ]
27 | ```
28 |
29 | This filter returns all items where the 'age' is greater than or equal to 30.
30 |
31 | For date comparisons, you can use the `dateMode` parameter:
32 |
33 | ```ts twoslash
34 | import { query } from '@chronicstone/array-query'
35 |
36 | const data = [
37 | { id: 1, name: 'John', joinDate: '2023-01-15' },
38 | { id: 2, name: 'Jane', joinDate: '2023-06-20' },
39 | { id: 3, name: 'Bob', joinDate: '2023-12-05' }
40 | ]
41 |
42 | const result = query(data, {
43 | filter: [
44 | {
45 | key: 'joinDate',
46 | matchMode: 'greaterThanOrEqual',
47 | value: '2023-06-20',
48 | params: { dateMode: true }
49 | }
50 | ]
51 | })
52 | ```
53 |
54 | Output:
55 | ```ts twoslash
56 | [
57 | { id: 2, name: 'Jane', joinDate: '2023-06-20' },
58 | { id: 3, name: 'Bob', joinDate: '2023-12-05' }
59 | ]
60 | ```
61 |
62 | This filter returns all items where the 'joinDate' is on or after June 20, 2023.
63 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: home
3 |
4 | title: Type-Safe ORM-like Querying for Local Arrays
5 | hero:
6 | name: "ArrayQuery"
7 | text: "Type-Safe ORM-like Querying for Arrays"
8 | tagline: "Manipulate and retrieve data from arrays with ease using a familiar and intuitive API."
9 | image:
10 | src: /images/logo.svg
11 | alt: ArrayQuery
12 | style:
13 | margin-left: 50px
14 | actions:
15 | - theme: brand
16 | text: Get Started
17 | link: /getting-started/introduction
18 | - theme: alt
19 | text: View on GitHub
20 | link: https://github.com/ChronicStone/array-ql
21 |
22 | features:
23 | - title: Type-Safe Querying
24 | details: Enjoy 100% type-safe query configurations with keys inferred from your dataset, enhancing developer experience and reducing errors.
25 | icon: "🛡️"
26 | - title: Powerful Pagination
27 | details: Effortlessly navigate through large datasets with built-in, performant pagination support.
28 | icon: "📄"
29 | - title: Advanced Filtering
30 | details: Apply complex, multi-level filters with various match modes for precise and flexible data retrieval.
31 | icon: "🧭"
32 | - title: Full-Text Search
33 | details: Perform lightning-fast, comprehensive searches across multiple fields in your data.
34 | icon: "🔎"
35 | - title: Zero Dependencies
36 | details: Benefit from a lightweight, bloat-free library that won't burden your project with unnecessary code.
37 | icon: "🪶"
38 | - title: Flexible Sorting
39 | details: Order results based on multiple fields, with support for custom sorting logic and directional control.
40 | icon: "🔢"
41 | - title: Optimized Performance
42 | details: Process millions of rows in milliseconds, with speed that scales for even the largest datasets.
43 | icon: "⚡"
44 | - title: Intuitive API
45 | details: Enjoy a clean, elegant API that makes complex data operations simple and readable.
46 | icon: "🧩"
47 |
48 | footer:
49 | message: "Licensed under the MIT License. Created by [Your Name]. Extensible and customizable for developers."
50 | ---
51 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chronicstone/array-query",
3 | "type": "module",
4 | "version": "1.0.5",
5 | "description": "A simple and lightweight array query library",
6 | "author": "Cyprien THAO ",
7 | "license": "MIT",
8 | "homepage": "https://github.com/chronicstone/array-ql#readme",
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/chronicstone/array-ql.git"
12 | },
13 | "bugs": "https://github.com/chronicstone/array-ql/issues",
14 | "keywords": [],
15 | "sideEffects": false,
16 | "exports": {
17 | ".": {
18 | "types": "./dist/index.d.ts",
19 | "import": "./dist/index.mjs",
20 | "require": "./dist/index.cjs"
21 | }
22 | },
23 | "main": "./dist/index.mjs",
24 | "module": "./dist/index.mjs",
25 | "types": "./dist/index.d.ts",
26 | "typesVersions": {
27 | "*": {
28 | "*": [
29 | "./dist/*",
30 | "./dist/index.d.ts"
31 | ]
32 | }
33 | },
34 | "files": [
35 | "dist"
36 | ],
37 | "scripts": {
38 | "build": "unbuild",
39 | "dev": "unbuild --stub",
40 | "lint": "eslint .",
41 | "prepublishOnly": "nr build",
42 | "release": "bumpp && npm publish",
43 | "start": "esno src/index.ts",
44 | "test": "vitest",
45 | "typecheck": "tsc --noEmit",
46 | "prepare": "simple-git-hooks",
47 | "docs:dev": "vitepress dev docs",
48 | "docs:build": "vitepress build docs",
49 | "docs:preview": "vitepress preview docs"
50 | },
51 | "devDependencies": {
52 | "@antfu/eslint-config": "^2.22.0-beta.2",
53 | "@antfu/ni": "^0.21.12",
54 | "@antfu/utils": "^0.7.10",
55 | "@chronicstone/array-query": "0.2.6",
56 | "@faker-js/faker": "^8.4.1",
57 | "@shikijs/vitepress-twoslash": "^1.10.3",
58 | "@types/node": "^20.14.10",
59 | "bumpp": "^9.4.1",
60 | "eslint": "^9.6.0",
61 | "esno": "^4.7.0",
62 | "lint-staged": "^15.2.7",
63 | "markdown-it-container": "^4.0.0",
64 | "pnpm": "^9.4.0",
65 | "rimraf": "^5.0.8",
66 | "simple-git-hooks": "^2.11.1",
67 | "typescript": "^5.5.3",
68 | "unbuild": "^2.0.0",
69 | "unocss": "^0.61.5",
70 | "vite": "^5.3.3",
71 | "vitepress": "^1.3.1",
72 | "vitepress-plugin-nprogress": "^0.0.4",
73 | "vitest": "^1.6.0"
74 | },
75 | "simple-git-hooks": {
76 | "pre-commit": "pnpm lint-staged"
77 | },
78 | "lint-staged": {
79 | "*": "eslint --fix"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/test/index.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import { describe, expect, it } from 'vitest'
3 | import { query } from '../src'
4 | import { omit } from '../src/utils'
5 | import PaginationFixtures from './fixtures/pagination.fixture.json'
6 | import SortingFixtures from './fixtures/sorting.fixture.json'
7 | import FilteringFixtures from './fixtures/filtering.fixture.json'
8 | import SearchFixtures from './fixtures/search.fixture.json'
9 |
10 | const fixtures = [
11 | {
12 | key: 'pagination',
13 | tests: PaginationFixtures,
14 | },
15 | {
16 | key: 'sorting',
17 | tests: SortingFixtures,
18 | },
19 | {
20 | key: 'search',
21 | tests: SearchFixtures,
22 | },
23 | {
24 | key: 'filtering',
25 | tests: FilteringFixtures,
26 | },
27 | ]
28 |
29 | for (const fixture of fixtures) {
30 | describe(`${fixture.key} tests`, () => {
31 | for (const test of fixture.tests) {
32 | it(test.title, () => {
33 | const result = (query as any)(test.data, test.query)
34 | const matchResult = Array.isArray(result) ? { rows: result } : omit(result, ['unpaginatedRows'])
35 | console.log('expected', JSON.stringify(test.result, null, 2))
36 | console.log('actual', JSON.stringify(matchResult, null, 2))
37 | expect(matchResult).toEqual(test.result)
38 | })
39 | }
40 | })
41 | }
42 |
43 | describe('performance check', () => {
44 | const getValue = () => Math.floor(Math.random() * 100)
45 | const startItems = performance.now()
46 | const items = Array.from({ length: 1000000 }, (_, i) => ({
47 | id: i,
48 | name: `Item ${i}`,
49 | value: getValue(),
50 | other: [],
51 | address: { city: 'New York', country: 'USA' },
52 | age: Math.floor(Math.random() * 100),
53 | }))
54 | const endItems = performance.now()
55 | it('query 1M rows - paginate + sort + search + filter in less than 50ms', () => {
56 | console.info('Time taken to generate 1M items:', endItems - startItems)
57 | const start = performance.now()
58 | query(items, {
59 | limit: 100,
60 | sort: [
61 | { key: 'age', dir: 'asc' },
62 | { key: 'name', dir: 'asc' },
63 | ],
64 | search: {
65 | value: 'Item 1',
66 | keys: ['name'],
67 | },
68 | filter: [{ key: 'value', matchMode: 'greaterThan', value: 50 }],
69 | })
70 |
71 | const end = performance.now()
72 | console.info('Time taken to query 1M items:', end - start)
73 | expect(end - start).toBeLessThan(50)
74 | })
75 | })
76 |
--------------------------------------------------------------------------------
/docs/getting-started/introduction.md:
--------------------------------------------------------------------------------
1 | # Introduction to ArrayQuery
2 |
3 | ArrayQuery is a powerful TypeScript library designed to bring Type-Safe ORM-like querying capabilities to local arrays. It provides an intuitive and efficient way to manipulate and retrieve data from arrays, offering functionality similar to database querying but for in-memory JavaScript/TypeScript arrays.
4 |
5 | ## What is ArrayQuery?
6 |
7 | ArrayQuery is a lightweight, yet feature-rich library that allows developers to perform complex operations on arrays of objects with ease. It's particularly useful when working with large datasets in memory, or when you need to implement advanced filtering, sorting, and pagination capabilities in front-end applications.
8 |
9 | ## Key Features
10 |
11 | ArrayQuery offers a range of powerful features to enhance your data manipulation capabilities:
12 |
13 | 1. **Pagination**: Easily paginate through large datasets, specifying the number of items per page and the current page number.
14 |
15 | 2. **Full-Text Searching**: Perform comprehensive searches across multiple fields in your data, allowing for flexible and powerful search functionality.
16 |
17 | 3. **Advanced Filtering**: Apply complex filters with various match modes for precise data retrieval. From simple equality checks to complex object matching, ArrayQuery provides a wide range of filtering options.
18 |
19 | 4. **Flexible Sorting**: Order your results based on any field, with support for both ascending and descending orders.
20 |
21 | 5. **Type-Safe Operations**: Leverage TypeScript for type-safe querying and manipulation of your data, reducing errors and improving developer experience.
22 |
23 | 6. **Complex Object Filtering**: Filter nested objects and arrays with ease using advanced match modes, allowing for intricate data querying.
24 |
25 | 8. **Lightweight and Fast**: Optimized for performance, ArrayQuery adds minimal overhead to your applications while providing powerful functionality.
26 |
27 | ## When to Use ArrayQuery
28 |
29 | ArrayQuery is ideal for scenarios where you need to:
30 |
31 | - Implement complex filtering and sorting in client-side applications
32 | - Paginate large datasets efficiently
33 | - Perform full-text searches across multiple fields
34 | - Handle complex filtering and sorting
35 | - Implement data table functionalities in web applications
36 |
37 | Whether you're building a data-heavy dashboard, implementing an advanced search feature, or simply need more control over your array manipulations, ArrayQuery provides the tools you need to work with your data effectively and efficiently.
38 |
39 | In the following sections, we'll dive deeper into each feature and provide examples of how to use ArrayQuery in your projects.
40 |
--------------------------------------------------------------------------------
/docs/features/pagination.md:
--------------------------------------------------------------------------------
1 | # Pagination
2 |
3 | Pagination is a crucial feature when dealing with large datasets. ArrayQuery provides an easy-to-use pagination system that allows you to efficiently retrieve subsets of your data.
4 |
5 | ## How Pagination Works
6 |
7 | ArrayQuery's pagination system operates based on two main parameters:
8 |
9 | 1. `page`: The current page number (1-indexed)
10 | 2. `limit`: The number of items per page
11 |
12 | When you apply pagination to your query, ArrayQuery will return the specified subset of data along with metadata about the pagination results.
13 |
14 | ## Basic Usage
15 |
16 | Here's a simple example of how to use pagination with ArrayQuery:
17 |
18 | ```ts twoslash
19 | import { query } from '@chronicstone/array-query'
20 |
21 | const data = [
22 | { id: 1, name: 'Item 1' },
23 | { id: 2, name: 'Item 2' },
24 | { id: 3, name: 'Item 3' },
25 | { id: 4, name: 'Item 4' },
26 | { id: 5, name: 'Item 5' },
27 | { id: 6, name: 'Item 6' },
28 | { id: 7, name: 'Item 7' },
29 | { id: 8, name: 'Item 8' },
30 | { id: 9, name: 'Item 9' },
31 | { id: 10, name: 'Item 10' },
32 | ]
33 |
34 | const result = query(data, {
35 | page: 1,
36 | limit: 5
37 | })
38 | ```
39 |
40 | This query will return the following result:
41 |
42 | ## Understanding the Result
43 |
44 | The outout of query is different with and without pagination. When pagination is applied, the result will be an object with the following properties:
45 |
46 | - `rows: T[]`: An array containing the items for the requested page
47 | - `unpaginatedRows: T[]`: An array containing the items for the entire dataset, with filter / sort .. without pagination
48 | - `totalPages`: The total number of pages based on the dataset size and the limit
49 | - `totalRows`: The total number of rows in the entire dataset
50 |
51 | ```ts twoslash
52 | import { query } from '@chronicstone/array-query'
53 |
54 | const data = [
55 | { id: 1, name: 'Item 1' },
56 | { id: 2, name: 'Item 2' },
57 | { id: 3, name: 'Item 3' },
58 | { id: 4, name: 'Item 4' },
59 | { id: 5, name: 'Item 5' },
60 | ]
61 |
62 | const result = query(data, {
63 | page: 2,
64 | limit: 2
65 | })
66 |
67 | console.log(result)
68 | // HOVER result TO SEE OUTPUT TYPE
69 | ```
70 |
71 | When pagination is not applied, the result will be an an object with the following properties:
72 |
73 | - `rows: T[]`: An array containing the items for the entire dataset
74 |
75 | ```ts twoslash
76 | import { query } from '@chronicstone/array-query'
77 |
78 | const data = [
79 | { id: 1, name: 'Item 1' },
80 | { id: 2, name: 'Item 2' },
81 | { id: 3, name: 'Item 3' },
82 | { id: 4, name: 'Item 4' },
83 | { id: 5, name: 'Item 5' },
84 | ]
85 |
86 | const result = query(data, {})
87 | // HOVER result TO SEE OUTPUT TYPE
88 | ```
89 |
--------------------------------------------------------------------------------
/docs/features/type-safety.md:
--------------------------------------------------------------------------------
1 | # Type-Safe Queries
2 |
3 | ArrayQuery provides type safety for query parameters, ensuring that you can only use valid keys for sorting, searching, and filtering. This feature helps catch errors at compile-time and provides excellent autocompletion support in your IDE.
4 |
5 | ## Sorting
6 |
7 | When specifying the `key` for sorting, you'll get type-safe suggestions based on the properties of your data:
8 |
9 | ```ts twoslash
10 | // @noErrors
11 | import { query } from '@chronicstone/array-query'
12 |
13 | const users = [
14 | { id: 1, name: 'Alice', age: 30, email: 'alice@example.com' },
15 | { id: 2, name: 'Bob', age: 25, email: 'bob@example.com' },
16 | ]
17 |
18 | const result = query(users, {
19 | sort: { key: '' }
20 | // ^|
21 | })
22 | ```
23 |
24 | Hovering over the empty string will show a popover with valid options: `"id" | "name" | "age" | "email"`.
25 |
26 | ## Searching
27 |
28 | The `keys` array in the search options is also type-safe:
29 |
30 | ```ts twoslash
31 | // @noErrors
32 | import { query } from '@chronicstone/array-query'
33 |
34 | const products = [
35 | { id: 1, name: 'Laptop', price: 1000, description: 'Powerful laptop' },
36 | { id: 2, name: 'Phone', price: 500, description: 'Smart phone' },
37 | ]
38 |
39 | const result = query(products, {
40 | search: {
41 | value: 'laptop',
42 | keys: ['']
43 | // ^|
44 | }
45 | })
46 | ```
47 |
48 | The popover for the empty string will show: `"id" | "name" | "price" | "description"`.
49 |
50 | ## Filtering
51 |
52 | Filter keys are also type-safe, including for nested properties:
53 |
54 | ```ts twoslash
55 | // @noErrors
56 | import { query } from '@chronicstone/array-query'
57 |
58 | const employees = [
59 | { id: 1, name: 'Alice', department: { name: 'IT', location: 'New York' } },
60 | { id: 2, name: 'Bob', department: { name: 'HR', location: 'London' } },
61 | ]
62 |
63 | const result = query(employees, {
64 | filter: [
65 | { key: '' }
66 | // ^|
67 | ]
68 | })
69 | ```
70 |
71 | ## Complex Nested Structures
72 |
73 | ArrayQuery handles complex nested structures with ease:
74 |
75 | ```typescript twoslash
76 | // @noErrors
77 | import { query } from '@chronicstone/array-query'
78 |
79 | const data = [
80 | {
81 | id: 1,
82 | info: {
83 | personal: { name: 'Alice', age: 30 },
84 | professional: { title: 'Developer', skills: ['JavaScript', 'TypeScript'] }
85 | },
86 | contacts: [
87 | { type: 'email', value: 'alice@example.com' },
88 | { type: 'phone', value: '123-456-7890' }
89 | ]
90 | }
91 | ]
92 |
93 | const result = query(data, {
94 | sort: { key: '', dir: 'asc' },
95 | // ^|
96 | })
97 | ```
98 |
99 | These type-safe queries ensure that you're always using valid property paths, reducing errors and improving the developer experience when working with ArrayQuery.
100 |
--------------------------------------------------------------------------------
/test/fixtures/pagination.fixture.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "title": "Basic pagination - first page",
4 | "data": [
5 | { "id": 1, "name": "Item 1" },
6 | { "id": 2, "name": "Item 2" },
7 | { "id": 3, "name": "Item 3" },
8 | { "id": 4, "name": "Item 4" },
9 | { "id": 5, "name": "Item 5" }
10 | ],
11 | "query": {
12 | "page": 1,
13 | "limit": 2
14 | },
15 | "result": {
16 | "rows": [
17 | { "id": 1, "name": "Item 1" },
18 | { "id": 2, "name": "Item 2" }
19 | ],
20 | "totalPages": 3,
21 | "totalRows": 5
22 | }
23 | },
24 | {
25 | "title": "Pagination - middle page",
26 | "data": [
27 | { "id": 1, "name": "Item 1" },
28 | { "id": 2, "name": "Item 2" },
29 | { "id": 3, "name": "Item 3" },
30 | { "id": 4, "name": "Item 4" },
31 | { "id": 5, "name": "Item 5" }
32 | ],
33 | "query": {
34 | "page": 2,
35 | "limit": 2
36 | },
37 | "result": {
38 | "rows": [
39 | { "id": 3, "name": "Item 3" },
40 | { "id": 4, "name": "Item 4" }
41 | ],
42 | "totalPages": 3,
43 | "totalRows": 5
44 | }
45 | },
46 | {
47 | "title": "Pagination - last page",
48 | "data": [
49 | { "id": 1, "name": "Item 1" },
50 | { "id": 2, "name": "Item 2" },
51 | { "id": 3, "name": "Item 3" },
52 | { "id": 4, "name": "Item 4" },
53 | { "id": 5, "name": "Item 5" }
54 | ],
55 | "query": {
56 | "page": 3,
57 | "limit": 2
58 | },
59 | "result": {
60 | "rows": [
61 | { "id": 5, "name": "Item 5" }
62 | ],
63 | "totalPages": 3,
64 | "totalRows": 5
65 | }
66 | },
67 | {
68 | "title": "Pagination - page out of range (too high)",
69 | "data": [
70 | { "id": 1, "name": "Item 1" },
71 | { "id": 2, "name": "Item 2" },
72 | { "id": 3, "name": "Item 3" }
73 | ],
74 | "query": {
75 | "page": 5,
76 | "limit": 2
77 | },
78 | "result": {
79 | "rows": [],
80 | "totalPages": 2,
81 | "totalRows": 3
82 | }
83 | },
84 | {
85 | "title": "Pagination - limit larger than dataset",
86 | "data": [
87 | { "id": 1, "name": "Item 1" },
88 | { "id": 2, "name": "Item 2" },
89 | { "id": 3, "name": "Item 3" }
90 | ],
91 | "query": {
92 | "page": 1,
93 | "limit": 10
94 | },
95 | "result": {
96 | "rows": [
97 | { "id": 1, "name": "Item 1" },
98 | { "id": 2, "name": "Item 2" },
99 | { "id": 3, "name": "Item 3" }
100 | ],
101 | "totalPages": 1,
102 | "totalRows": 3
103 | }
104 | },
105 | {
106 | "title": "Pagination - empty dataset",
107 | "data": [],
108 | "query": {
109 | "page": 1,
110 | "limit": 5
111 | },
112 | "result": {
113 | "rows": [],
114 | "totalPages": 0,
115 | "totalRows": 0
116 | }
117 | },
118 | {
119 | "title": "Pagination - limit of 1",
120 | "data": [
121 | { "id": 1, "name": "Item 1" },
122 | { "id": 2, "name": "Item 2" },
123 | { "id": 3, "name": "Item 3" }
124 | ],
125 | "query": {
126 | "page": 2,
127 | "limit": 1
128 | },
129 | "result": {
130 | "rows": [
131 | { "id": 2, "name": "Item 2" }
132 | ],
133 | "totalPages": 3,
134 | "totalRows": 3
135 | }
136 | }
137 | ]
138 |
--------------------------------------------------------------------------------
/docs/.vitepress/config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitepress'
2 | import { transformerTwoslash } from '@shikijs/vitepress-twoslash'
3 | import Unocss from 'unocss/vite'
4 |
5 | // https://vitepress.dev/reference/site-config
6 | export default defineConfig({
7 | title: 'Array-Query',
8 | description: 'Documentation of Array-Query library',
9 | sitemap: {
10 | hostname: 'https://typed-xlsx.vercel.app',
11 | },
12 | markdown: {
13 | theme: {
14 | light: 'github-light',
15 | dark: 'github-dark',
16 | },
17 |
18 | codeTransformers: [
19 | transformerTwoslash(),
20 | ],
21 | },
22 | lastUpdated: true,
23 | ignoreDeadLinks: true,
24 | cleanUrls: true,
25 | router: {
26 | prefetchLinks: true,
27 | },
28 | titleTemplate: 'Array-Query | :title',
29 | themeConfig: {
30 | logo: '/images/logo.svg',
31 | editLink: {
32 | pattern: 'https://github.com/ChronicStone/array-ql/edit/main/docs/:path',
33 | text: 'Edit this page on GitHub',
34 | },
35 | search: { provider: 'local' },
36 | nav: [
37 | { text: 'Home', link: '/' },
38 | { text: 'Documentation', link: '/getting-started/introduction' },
39 | ],
40 | socialLinks: [
41 | { icon: 'github', link: 'https://github.com/ChronicStone/array-ql' },
42 | ],
43 | footer: {
44 | message: 'Released under the MIT License.',
45 | copyright: 'Copyright © 2024-present Cyprien THAO',
46 | },
47 | sidebar: [
48 | {
49 | text: 'Getting Started',
50 | items: [
51 | { text: 'Introduction', link: '/getting-started/introduction' },
52 | { text: 'Installation', link: '/getting-started/installation' },
53 | ],
54 | },
55 | {
56 | text: 'Features',
57 | items: [
58 | { text: 'Type-Safe Queries', link: '/features/type-safety' },
59 | { text: 'Pagination', link: '/features/pagination' },
60 | { text: 'Sorting', link: '/features/sorting' },
61 | { text: 'Searching', link: '/features/searching' },
62 | { text: 'Filtering', link: '/features/filtering' },
63 | ],
64 | },
65 | {
66 | text: 'Filter Match Modes',
67 | items: [
68 | { text: 'contains', link: '/filter-match-modes/contains' },
69 | { text: 'between', link: '/filter-match-modes/between' },
70 | { text: 'equals', link: '/filter-match-modes/equals' },
71 | { text: 'notEquals', link: '/filter-match-modes/not-equals' },
72 | { text: 'greaterThan', link: '/filter-match-modes/greater-than' },
73 | { text: 'greaterThanOrEqual', link: '/filter-match-modes/greater-than-or-equal' },
74 | { text: 'lessThan', link: '/filter-match-modes/less-than' },
75 | { text: 'lessThanOrEqual', link: '/filter-match-modes/less-than-or-equal' },
76 | { text: 'exists', link: '/filter-match-modes/exists' },
77 | { text: 'arrayLength', link: '/filter-match-modes/array-length' },
78 | { text: 'objectMatch', link: '/filter-match-modes/object-match' },
79 | ],
80 | },
81 | // {
82 | // text: 'API Reference',
83 | // items: [
84 | // { text: 'Query Function', link: '/api-reference/query-function' },
85 | // { text: 'QueryParams', link: '/api-reference/query-params' },
86 | // { text: 'QueryFilter', link: '/api-reference/query-filter' },
87 | // { text: 'FilterMatchMode', link: '/api-reference/filter-match-mode' },
88 | // ],
89 | // },
90 | ],
91 |
92 | },
93 | vite: {
94 | plugins: [
95 | Unocss({}),
96 | ],
97 | },
98 | })
99 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Array-Query
2 |
3 | [![npm version][npm-version-src]][npm-version-href]
4 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
5 | [![bundle][bundle-src]][bundle-href]
6 | [![JSDocs][jsdocs-src]][jsdocs-href]
7 | [![License][license-src]][license-href]
8 |
9 | > ### **Query JavaScript arrays with ORM-like syntax, benefiting from excellent type-safety & developer experience.**
10 |
11 | ## Key Features:
12 |
13 | - 🛠 **Type-safe Querying:** Design your queries with strong typing (typed sort / search / filter keys & output)
14 | - 📄 **Pagination:** Easily paginate through large datasets with built-in support.
15 | - 🔎 **Full-text Search:** Perform comprehensive searches across multiple fields in your data.
16 | - 🧭 **Advanced Filtering:** Apply complex filters with various match modes for precise data retrieval. Supports logical grouping, nested conditions, and array matching.
17 | - 🔢 **Flexible Sorting:** Order results based on any field, with support for multiple sort criteria.
18 | - 🚀 **Lightweight and Fast:** Queries stay super fast, even with large datasets.
19 | - 🧩 **Zero Dependencies**: Completely self-contained with no external dependencies, ensuring a small bundle cost
20 |
21 | ## INSTALLATION
22 |
23 | ```bash
24 | // npm
25 | npm install @chronicstone/array-query
26 |
27 | // yarn
28 | yarn add @chronicstone/array-query
29 |
30 | // pnpm
31 | pnpm add @chronicstone/array-query
32 |
33 | // bun
34 | bun add @chronicstone/array-query
35 | ```
36 |
37 | ## USAGE EXAMPLE
38 |
39 | ```ts
40 | import { query } from '@chronicstone/array-query'
41 |
42 | const users = [
43 | { id: 1, fullName: 'John Doe', email: 'john@example.com', age: 30, status: 'active', roles: ['admin'], createdAt: '2023-01-01' },
44 | { id: 2, fullName: 'Jane Smith', email: 'jane@example.com', age: 28, status: 'inactive', roles: ['user'], createdAt: '2023-02-15' },
45 | { id: 3, fullName: 'Bob Johnson', email: 'bob@example.com', age: 35, status: 'active', roles: ['user', 'manager'], createdAt: '2023-03-20' },
46 | // ... more users
47 | ]
48 |
49 | const { totalRows, totalPages, rows } = query(users, {
50 | // Pagination
51 | page: 1,
52 | limit: 10,
53 |
54 | // Sorting
55 | sort: [
56 | { key: 'age', dir: 'desc' },
57 | { key: 'fullName', dir: 'asc' }
58 | ],
59 |
60 | // Searching
61 | search: {
62 | value: 'john',
63 | keys: ['fullName', 'email']
64 | },
65 |
66 | // Filtering
67 | filter: [
68 | { key: 'status', matchMode: 'equals', value: 'active' },
69 | { key: 'age', matchMode: 'greaterThan', value: 25 },
70 | ]
71 | })
72 | ```
73 |
74 | This example demonstrates pagination, multi-field sorting, full-text searching, and complex filtering with nested conditions and array field matching.
75 |
76 | ## License
77 |
78 | [MIT](./LICENSE) License © 2023-PRESENT [Cyprien THAO](https://github.com/ChronicStone)
79 |
80 |
81 |
82 | [npm-version-src]: https://img.shields.io/npm/v/@chronicstone/array-query?style=flat&colorA=080f12&colorB=1fa669
83 | [npm-version-href]: https://npmjs.com/package/@chronicstone/array-query
84 | [npm-downloads-src]: https://img.shields.io/npm/dm/@chronicstone/array-query?style=flat&colorA=080f12&colorB=1fa669
85 | [npm-downloads-href]: https://npmjs.com/package/@chronicstone/array-query
86 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/@chronicstone/array-query?style=flat&colorA=080f12&colorB=1fa669&label=minzip
87 | [bundle-href]: https://bundlephobia.com/result?p=@chronicstone/array-query
88 | [license-src]: https://img.shields.io/github/license/ChronicStone/array-ql.svg?style=flat&colorA=080f12&colorB=1fa669
89 | [license-href]: https://github.com/ChronicStone/array-ql/blob/main/LICENSE
90 | [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
91 | [jsdocs-href]: https://www.jsdocs.io/package/@chronicstone/array-query
92 |
--------------------------------------------------------------------------------
/test/fixtures/sorting.fixture.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "title": "Sort by single field ascending",
4 | "data": [
5 | { "id": 3, "name": "Charlie", "age": 30 },
6 | { "id": 1, "name": "Alice", "age": 25 },
7 | { "id": 2, "name": "Bob", "age": 35 }
8 | ],
9 | "query": {
10 | "sort": { "key": "name", "dir": "asc" }
11 | },
12 | "result": {
13 | "rows": [
14 | { "id": 1, "name": "Alice", "age": 25 },
15 | { "id": 2, "name": "Bob", "age": 35 },
16 | { "id": 3, "name": "Charlie", "age": 30 }
17 | ]
18 | }
19 | },
20 | {
21 | "title": "Sort by single field descending",
22 | "data": [
23 | { "id": 3, "name": "Charlie", "age": 30 },
24 | { "id": 1, "name": "Alice", "age": 25 },
25 | { "id": 2, "name": "Bob", "age": 35 }
26 | ],
27 | "query": {
28 | "sort": { "key": "age", "dir": "desc" }
29 | },
30 | "result": {
31 | "rows": [
32 | { "id": 2, "name": "Bob", "age": 35 },
33 | { "id": 3, "name": "Charlie", "age": 30 },
34 | { "id": 1, "name": "Alice", "age": 25 }
35 | ]
36 | }
37 | },
38 | {
39 | "title": "Sort by multiple fields",
40 | "data": [
41 | { "id": 1, "name": "Alice", "age": 30 },
42 | { "id": 2, "name": "Bob", "age": 25 },
43 | { "id": 3, "name": "Charlie", "age": 30 },
44 | { "id": 4, "name": "David", "age": 25 }
45 | ],
46 | "query": {
47 | "sort": [
48 | { "key": "age", "dir": "asc" },
49 | { "key": "name", "dir": "desc" }
50 | ]
51 | },
52 | "result": {
53 | "rows": [
54 | { "id": 4, "name": "David", "age": 25 },
55 | { "id": 2, "name": "Bob", "age": 25 },
56 | { "id": 3, "name": "Charlie", "age": 30 },
57 | { "id": 1, "name": "Alice", "age": 30 }
58 | ]
59 | }
60 | },
61 | {
62 | "title": "Sort by nested field",
63 | "data": [
64 | { "id": 1, "name": "John", "scores": { "math": 85, "science": 92 } },
65 | { "id": 2, "name": "Jane", "scores": { "math": 90, "science": 88 } },
66 | { "id": 3, "name": "Bob", "scores": { "math": 78, "science": 95 } }
67 | ],
68 | "query": {
69 | "sort": { "key": "scores.math", "dir": "desc" }
70 | },
71 | "result": {
72 | "rows": [
73 | { "id": 2, "name": "Jane", "scores": { "math": 90, "science": 88 } },
74 | { "id": 1, "name": "John", "scores": { "math": 85, "science": 92 } },
75 | { "id": 3, "name": "Bob", "scores": { "math": 78, "science": 95 } }
76 | ]
77 | }
78 | },
79 | {
80 | "title": "Sort with null values - asc (nulls first)",
81 | "data": [
82 | { "id": 1, "name": "Alice", "age": 30 },
83 | { "id": 2, "name": "Bob", "age": null },
84 | { "id": 3, "name": "Charlie", "age": 25 }
85 | ],
86 | "query": {
87 | "sort": { "key": "age", "dir": "asc" }
88 | },
89 | "result": {
90 | "rows": [
91 | { "id": 2, "name": "Bob", "age": null },
92 | { "id": 3, "name": "Charlie", "age": 25 },
93 | { "id": 1, "name": "Alice", "age": 30 }
94 | ]
95 | }
96 | },
97 | {
98 | "title": "Sort with undefined values - desc (undefined last)",
99 | "data": [
100 | { "id": 1, "name": "Alice", "age": 30 },
101 | { "id": 2, "name": "Bob" },
102 | { "id": 3, "name": "Charlie", "age": 25 }
103 | ],
104 | "query": {
105 | "sort": { "key": "age", "dir": "desc" }
106 | },
107 | "result": {
108 | "rows": [
109 | { "id": 1, "name": "Alice", "age": 30 },
110 | { "id": 3, "name": "Charlie", "age": 25 },
111 | { "id": 2, "name": "Bob" }
112 | ]
113 | }
114 | },
115 | {
116 | "title": "Sort with mix of types (numbers and strings)",
117 | "data": [
118 | { "id": 1, "value": 5 },
119 | { "id": 2, "value": "10" },
120 | { "id": 3, "value": "3" },
121 | { "id": 4, "value": 15 }
122 | ],
123 | "query": {
124 | "sort": { "key": "value", "dir": "asc", "parser": "number" }
125 | },
126 | "result": {
127 | "rows": [
128 | { "id": 3, "value": "3" },
129 | { "id": 1, "value": 5 },
130 | { "id": 2, "value": "10" },
131 | { "id": 4, "value": 15 }
132 | ]
133 | }
134 | }
135 |
136 | ]
137 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/regex.md:
--------------------------------------------------------------------------------
1 | # Regex Match Mode
2 |
3 | The regex match mode allows you to filter data using regular expressions. This powerful feature enables complex pattern matching across your dataset.
4 |
5 | ## Basic Usage
6 |
7 | To use the regex match mode, set the `matchMode` to `'regex'` in your filter configuration:
8 |
9 | ```ts twoslash
10 | import { query } from '@chronicstone/array-query'
11 |
12 | const data = [
13 | { id: 1, email: 'john@example.com' },
14 | { id: 2, email: 'jane@test.com' },
15 | { id: 3, email: 'bob@example.org' }
16 | ]
17 |
18 | const result = query(data, {
19 | filter: [
20 | { key: 'email', matchMode: 'regex', value: '@example\\.(com|org)' }
21 | ]
22 | })
23 | ```
24 |
25 | This query will return all items where the email matches the pattern `@example.com` or `@example.org`.
26 |
27 | ## Flags
28 |
29 | You can modify the behavior of the regex matching by providing flags. Flags are specified using the `params` option:
30 |
31 | ```ts twoslash
32 | import { query } from '@chronicstone/array-query'
33 |
34 | const data = [
35 | { id: 1, name: 'John Doe' },
36 | { id: 2, name: 'jane smith' },
37 | { id: 3, name: 'Bob Johnson' }
38 | ]
39 |
40 | const result = query(data, {
41 | filter: [
42 | {
43 | key: 'name',
44 | matchMode: 'regex',
45 | value: '^j.*',
46 | params: { flags: 'i' }
47 | }
48 | ]
49 | })
50 | ```
51 |
52 | ## Raw RegExp
53 |
54 | Instead of defining the regex pattern as a string, you can also pass a regular expression object directly:
55 |
56 | ```ts twoslash
57 | import { query } from '@chronicstone/array-query'
58 |
59 | const data = [
60 | { id: 1, name: 'John Doe' },
61 | { id: 2, name: 'jane smith' },
62 | { id: 3, name: 'Bob Johnson' }
63 | ]
64 |
65 | const result = query(data, {
66 | filter: [
67 | {
68 | key: 'name',
69 | matchMode: 'regex',
70 | value: /^j.*/i
71 | }
72 | ]
73 | })
74 | ```
75 |
76 | 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).
77 |
78 | ## Examples
79 |
80 | ### Case-insensitive Matching
81 |
82 | ```ts twoslash
83 | import { query } from '@chronicstone/array-query'
84 |
85 | const data = [
86 | { id: 1, name: 'John Doe' },
87 | { id: 2, name: 'jane smith' },
88 | { id: 3, name: 'Bob Johnson' }
89 | ]
90 |
91 | const result = query(data, {
92 | filter: [
93 | {
94 | key: 'name',
95 | matchMode: 'regex',
96 | value: '^j.*',
97 | params: { flags: 'i' }
98 | }
99 | ]
100 | })
101 | ```
102 |
103 | This will match both "John Doe" and "jane smith".
104 |
105 | ### Multi-line Matching
106 |
107 | ```ts twoslash
108 | import { query } from '@chronicstone/array-query'
109 |
110 | const data = [
111 | { id: 1, description: 'First line\nSecond line' },
112 | { id: 2, description: 'One line only' },
113 | { id: 3, description: 'First line\nLast line' }
114 | ]
115 |
116 | const result = query(data, {
117 | filter: [
118 | {
119 | key: 'description',
120 | matchMode: 'regex',
121 | value: '^Last',
122 | params: { flags: 'm' }
123 | }
124 | ]
125 | })
126 | ```
127 |
128 | This will match the item with id 3, where "Last" appears at the start of a line.
129 |
130 | ## Combining with Other Filters
131 |
132 | You can combine regex filters with other filter types:
133 |
134 | ```typescript
135 | const result = query(data, {
136 | filter: [
137 | {
138 | key: 'name',
139 | matchMode: 'regex',
140 | value: '^j.*',
141 | params: { flags: 'i' }
142 | },
143 | { key: 'age', matchMode: 'greaterThan', value: 28 }
144 | ]
145 | })
146 | ```
147 |
148 | This will find items where the name starts with 'j' (case-insensitive) AND the age is greater than 28.
149 |
150 | ## Performance Considerations
151 |
152 | 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.
153 |
154 | ## Escaping Special Characters
155 |
156 | Remember to properly escape special regex characters in your pattern strings. For example, to match a literal period, use `\\.` instead of `.`.
157 |
158 | ## Further Reading
159 |
160 | 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).
161 |
--------------------------------------------------------------------------------
/docs/features/searching.md:
--------------------------------------------------------------------------------
1 | # Searching
2 |
3 | ArrayQuery provides a powerful searching capability that allows you to perform full-text searches across multiple fields in your data, including deeply nested fields and array fields.
4 |
5 | ## How Searching Works
6 |
7 | The search feature in ArrayQuery operates based on the `SearchOptions` interface:
8 |
9 | ```ts twoslash
10 | interface SearchOptions {
11 | value: string
12 | keys: string[] | Array<{ key: string, caseSensitive?: boolean }>
13 | caseSensitive?: boolean
14 | }
15 | ```
16 |
17 | - `value`: The search term or phrase
18 | - `keys`: An array of field names to search within, or an array of objects specifying the key and case sensitivity for each field
19 | - `caseSensitive`: A global flag to set case sensitivity for all fields (optional)
20 |
21 | When you apply a search to your query, ArrayQuery will return all items where the specified fields contain the search value.
22 |
23 | ## Basic Usage
24 |
25 | Here's a simple example of how to use the search feature with ArrayQuery:
26 |
27 | ```ts twoslash
28 | import { query } from '@chronicstone/array-query'
29 |
30 | const data = [
31 | { id: 1, title: 'iPhone 12', description: 'Latest Apple smartphone' },
32 | { id: 2, title: 'Samsung Galaxy S21', description: 'Flagship Android device' },
33 | { id: 3, title: 'Google Pixel 5', description: 'Pure Android experience' },
34 | { id: 4, title: 'OnePlus 9', description: 'Flagship killer smartphone' }
35 | ]
36 |
37 | const result = query(data, {
38 | search: {
39 | value: 'android',
40 | keys: ['title', 'description']
41 | }
42 | })
43 | ```
44 |
45 | This query will return the following result:
46 |
47 | ```ts
48 | [
49 | { id: 2, title: 'Samsung Galaxy S21', description: 'Flagship Android device' },
50 | { id: 3, title: 'Google Pixel 5', description: 'Pure Android experience' }
51 | ]
52 | ```
53 |
54 | ## Case Sensitivity
55 |
56 | You can control case sensitivity in two ways:
57 |
58 | 1. Globally for all fields:
59 |
60 | ```ts twoslash
61 | import { query } from '@chronicstone/array-query'
62 |
63 | const data = [
64 | { id: 1, title: 'iPhone 12', description: 'Latest Apple smartphone' },
65 | { id: 2, title: 'Samsung Galaxy S21', description: 'Flagship Android device' },
66 | { id: 3, title: 'Google Pixel 5', description: 'Pure Android experience' },
67 | { id: 4, title: 'OnePlus 9', description: 'Flagship killer smartphone' }
68 | ]
69 |
70 | const result = query(data, {
71 | search: {
72 | value: 'Android',
73 | keys: ['title', 'description'],
74 | caseSensitive: true
75 | }
76 | })
77 | ```
78 |
79 | 2. Per field:
80 |
81 | ```ts twoslash
82 | import { query } from '@chronicstone/array-query'
83 |
84 | const data = [
85 | { id: 1, title: 'iPhone 12', description: 'Latest Apple smartphone' },
86 | { id: 2, title: 'Samsung Galaxy S21', description: 'Flagship Android device' },
87 | { id: 3, title: 'Google Pixel 5', description: 'Pure Android experience' },
88 | { id: 4, title: 'OnePlus 9', description: 'Flagship killer smartphone' }
89 | ]
90 |
91 | const result = query(data, {
92 | search: {
93 | value: 'Android',
94 | keys: [
95 | { key: 'title', caseSensitive: true },
96 | { key: 'description' }
97 | ]
98 | }
99 | })
100 | ```
101 |
102 | In the second example, the search will be case-sensitive for the 'title' field but case-insensitive for the 'description' field.
103 |
104 | ## Searching Deeply Nested Fields
105 |
106 | ArrayQuery supports searching in deeply nested fields using dot notation:
107 |
108 | ```ts twoslash
109 | import { query } from '@chronicstone/array-query'
110 |
111 | const data = [
112 | {
113 | id: 1,
114 | name: 'John Doe',
115 | contact: {
116 | email: 'john@example.com',
117 | address: {
118 | city: 'New York',
119 | country: 'USA'
120 | }
121 | }
122 | },
123 | {
124 | id: 2,
125 | name: 'Jane Smith',
126 | contact: {
127 | email: 'jane@example.com',
128 | address: {
129 | city: 'London',
130 | country: 'UK'
131 | }
132 | }
133 | }
134 | ]
135 |
136 | const result = query(data, {
137 | search: {
138 | value: 'New York',
139 | keys: ['contact.address.city']
140 | }
141 | })
142 | ```
143 |
144 | This will return all items where the city in the nested address object matches 'New York'.
145 |
146 | ## Searching Array Fields
147 |
148 | ArrayQuery can also search within array fields:
149 |
150 | ```ts twoslash
151 | import { query } from '@chronicstone/array-query'
152 |
153 | const data = [
154 | { id: 1, name: 'John Doe', tags: ['developer', 'javascript', 'typescript'] },
155 | { id: 2, name: 'Jane Smith', tags: ['designer', 'ui', 'ux'] },
156 | { id: 3, name: 'Bob Johnson', tags: ['manager', 'agile', 'scrum'] }
157 | ]
158 |
159 | const result = query(data, {
160 | search: {
161 | value: 'script',
162 | keys: ['tags']
163 | }
164 | })
165 | ```
166 |
167 | This will return all items where any of the tags contain 'script', matching both 'javascript' and 'typescript' in this case.
168 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type Primitive = string | number | symbol
2 | export type GenericObject = Record
3 |
4 | export type NestedPaths = T extends Array
5 | ? `${NestedPaths}`
6 | : T extends object
7 | ? {
8 | [K in keyof T & (string | number)]: K extends string
9 | ? `${K}` | `${K}.${NestedPaths}`
10 | : never;
11 | }[keyof T & (string | number)]
12 | : never
13 |
14 | export type NestedPathsForType = T extends Array
15 | ? NestedPathsForType
16 | : T extends object
17 | ? {
18 | [K in keyof T & (string | number)]: K extends string
19 | ? T[K] extends P
20 | ? `${K}` | `${K}.${NestedPathsForType}`
21 | : T[K] extends object
22 | ? `${K}.${NestedPathsForType}`
23 | : string
24 | : string;
25 | }[keyof T & (string | number)]
26 | : string
27 |
28 | export type Operator = 'AND' | 'OR' | (() => 'AND' | 'OR')
29 |
30 | export type FilterMatchMode =
31 | | 'contains'
32 | | 'between'
33 | | 'equals'
34 | | 'notEquals'
35 | | 'greaterThan'
36 | | 'greaterThanOrEqual'
37 | | 'lessThan'
38 | | 'lessThanOrEqual'
39 | | 'exists'
40 | | 'regex'
41 | | 'arrayLength'
42 | | 'objectMatch'
43 |
44 | export type NonObjectMatchMode = Exclude
45 | export type ComparatorMatchMode = Extract
46 | export type RegexMatchMode = Extract
47 | export interface ComparatorParams {
48 | dateMode?: boolean
49 | }
50 |
51 | export interface RegexParams {
52 | flags?: string
53 | }
54 | export interface ObjectMapFilterParams {
55 | operator: 'AND' | 'OR'
56 | properties: Array<{
57 | key: string
58 | matchMode: Exclude
59 | }>
60 | transformFilterValue?: (value: any) => any
61 | matchPropertyAtIndex?: boolean
62 | applyAtRoot?: boolean
63 | }
64 |
65 | export type MatchModeCore = ({
66 | matchMode: Exclude
67 | } | {
68 | matchMode: ComparatorMatchMode
69 | params?: ComparatorParams
70 | } | {
71 | matchMode: 'regex'
72 | params?: RegexParams
73 | } | {
74 | matchMode: 'objectMatch'
75 | params: ObjectMapFilterParams | ((value: any) => ObjectMapFilterParams)
76 | })
77 |
78 | export type QueryFilter = {
79 | key: Paths
80 | value: any
81 | operator?: Operator
82 | condition?: () => boolean
83 | } & MatchModeCore
84 |
85 | export interface QueryFilterGroup {
86 | operator: Operator
87 | filters: QueryFilter[]
88 | condition?: () => boolean
89 | }
90 |
91 | export type FilterOptions = Array> | Array> | {
92 | groups: Array>
93 | operator: Operator
94 | }
95 |
96 | export interface SearchOptions {
97 | value: string
98 | keys: Paths[] | Array<{ key: Paths, caseSensitive?: boolean }>
99 | caseSensitive?: boolean
100 | }
101 |
102 | export interface SortOption {
103 | key: Paths
104 | dir?: 'asc' | 'desc'
105 | parser?: 'number' | 'boolean' | 'string' | ((value: any) => string | number | boolean | null | undefined)
106 | }
107 |
108 | export interface QueryParams<
109 | T extends GenericObject = GenericObject,
110 | Paths extends NestedPaths = NestedPaths,
111 | PrimitivePath extends string = NestedPathsForType,
112 | > {
113 | sort?: SortOption | Array>
114 | search?: SearchOptions
115 | filter?: FilterOptions
116 | limit?: number
117 | page?: number
118 | }
119 |
120 | export type QueryResult> = P extends { limit: number } ? { totalRows: number, totalPages: number, rows: T[], unpaginatedRows: T[] } : T[]
121 |
122 | export interface MatchModeProcessorMap {
123 | equals: (f: { value: any, filter: any }) => boolean
124 | notEquals: (f: { value: any, filter: any }) => boolean
125 | exists: (f: { value: any, filter: any }) => boolean
126 | contains: (f: { value: any, filter: any }) => boolean
127 | greaterThan: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean
128 | greaterThanOrEqual: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean
129 | lessThan: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean
130 | lessThanOrEqual: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean
131 | between: (f: { value: any, filter: any, params?: ComparatorParams }) => boolean
132 | arrayLength: (f: { value: any, filter: any }) => boolean
133 | objectMatch: (f: { value: any, filter: any, params: ObjectMapFilterParams, index?: number }) => boolean
134 | regex: (f: { value: any, filter: any, params?: RegexParams }) => boolean
135 | }
136 |
--------------------------------------------------------------------------------
/src/query.ts:
--------------------------------------------------------------------------------
1 | import type { FilterOptions, GenericObject, QueryFilter, QueryFilterGroup, QueryParams, QueryResult } from './types'
2 | import { getObjectProperty, getOperator, processFilterWithLookup, processSearchQuery } from './utils'
3 |
4 | export function query>(
5 | data: T[],
6 | params: P,
7 | ): QueryResult {
8 | let result = lazyQuery(data, params)
9 | result = lazySortedQuery(result, params.sort)
10 | return paginateQuery(result, params)
11 | }
12 |
13 | function* lazyQuery(data: T[], params: QueryParams): Generator {
14 | for (const item of data) {
15 | if (matchesSearchAndFilters(item, params)) {
16 | yield item
17 | }
18 | }
19 | }
20 |
21 | function matchesSearchAndFilters(item: T, params: QueryParams): boolean {
22 | return matchesSearch(item, params.search) && matchesFilters(item, params.filter)
23 | }
24 |
25 | function matchesSearch(item: T, search?: QueryParams['search']): boolean {
26 | if (!search || !search.value)
27 | return true
28 |
29 | return search.keys.some((key) => {
30 | const field = typeof key === 'string' ? key : key.key
31 | const caseSensitive = typeof key === 'string' ? (search.caseSensitive ?? false) : key.caseSensitive ?? false
32 | return processSearchQuery({ key: field, caseSensitive, object: item, value: search.value })
33 | })
34 | }
35 |
36 | function matchesFilters(item: T, filters?: FilterOptions): boolean {
37 | const _filters = ((typeof filters === 'object' && ('groups' in filters) ? filters.groups : filters) ?? [])
38 | .filter(filter => filter.condition?.() ?? true)
39 | if (!_filters || _filters.length === 0)
40 | return true
41 | const isGroup = _filters.every(filter => 'filters' in filter)
42 | const groupOperator = getOperator(typeof filters === 'object' && 'operator' in filters ? filters.operator : 'OR')
43 | const method = isGroup ? (groupOperator === 'AND' ? 'every' : 'some') : 'every'
44 | return _filters[method]((group: QueryFilter | QueryFilterGroup) => {
45 | const groupFilters = ('filters' in group ? group.filters : [group])
46 | .filter(filter => filter.condition?.() ?? true)
47 | if (groupFilters.length === 0)
48 | return true
49 |
50 | const op = 'filters' in group ? group.operator : 'OR'
51 | return groupFilters[op === 'AND' ? 'every' : 'some']((filter: QueryFilter) => {
52 | const value = getObjectProperty(item, filter.key)
53 | const operator = getOperator(filter.operator)
54 | const params = (!('params' in filter) ? null : typeof filter.params === 'function' ? filter.params(filter.value) : filter.params) ?? null
55 | return processFilterWithLookup({
56 | type: filter.matchMode,
57 | params,
58 | operator,
59 | value,
60 | filter: filter.value,
61 | })
62 | })
63 | })
64 | }
65 |
66 | function* lazySortedQuery(
67 | data: Iterable,
68 | sortOptions?: QueryParams['sort'],
69 | ): Generator {
70 | if (!sortOptions) {
71 | yield * data
72 | return
73 | }
74 |
75 | const sortArray = Array.isArray(sortOptions) ? sortOptions : [sortOptions]
76 | const buffer: T[] = []
77 | const compare = (a: T, b: T) => {
78 | for (const { key, dir, parser } of sortArray) {
79 | const parserHandler = typeof parser === 'function'
80 | ? parser
81 | : (v: any) =>
82 | parser === 'number'
83 | ? Number(v)
84 | : parser === 'boolean'
85 | ? Boolean(v)
86 | : parser === 'string' ? String(v) : v
87 | const aParsed = parserHandler(getObjectProperty(a, key)) ?? null
88 | const bParsed = parserHandler(getObjectProperty(b, key)) ?? null
89 | if (aParsed !== bParsed) {
90 | const comparison = (aParsed < bParsed) ? -1 : 1
91 | return dir === 'asc' ? comparison : -comparison
92 | }
93 | }
94 | return 0
95 | }
96 |
97 | for (const item of data) {
98 | buffer.push(item)
99 | if (buffer.length >= 1000)
100 | break
101 | }
102 |
103 | buffer.sort(compare)
104 |
105 | while (buffer.length > 0) {
106 | yield buffer[0]
107 | buffer.shift()
108 |
109 | for (const item of data) {
110 | let insertIndex = buffer.findIndex(bufferItem => compare(item, bufferItem) < 0)
111 | if (insertIndex === -1) {
112 | insertIndex = buffer.length
113 | }
114 | buffer.splice(insertIndex, 0, item)
115 | if (buffer.length >= 1000)
116 | break
117 | }
118 | }
119 | }
120 |
121 | function paginateQuery>(data: Iterable, params: P): QueryResult {
122 | if (typeof params.limit === 'undefined') {
123 | return Array.from(data) as QueryResult
124 | }
125 |
126 | else {
127 | let rows = Array.from(data)
128 | const totalRows = rows.length
129 | const totalPages = Math.ceil(totalRows / params.limit)
130 | const start = ((params?.page ?? 1) - 1) * params.limit
131 | const end = start + params.limit
132 | rows = rows.slice(start, end)
133 |
134 | return {
135 | totalRows,
136 | totalPages,
137 | rows,
138 | get unpaginatedRows() {
139 | return Array.from(data)
140 | },
141 | } as QueryResult
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --vp-c-white: #ffffff;
3 | --vp-c-black: #000000;
4 |
5 | --vp-c-gray: #8e8e93;
6 |
7 | --vp-c-text-light-1: #000000;
8 | --vp-c-text-light-2: #454545;
9 | --vp-c-text-light-3: #959595;
10 |
11 | --vp-c-text-dark-1: rgba(255, 255, 245, 0.86);
12 | --vp-c-text-dark-2: rgba(235, 235, 245, 0.6);
13 | --vp-c-text-dark-3: rgba(235, 235, 245, 0.38);
14 |
15 | --vp-c-blue: #0384fc; /* primary brand color */
16 | --vp-c-blue-soft: rgba(3, 132, 252, 0.1);
17 | --vp-c-blue-light: #35a0fd; /* lighter version */
18 | --vp-c-blue-lighter: #67bbfe; /* even lighter version */
19 | --vp-c-blue-dark: #026ac9; /* darker version */
20 | --vp-c-blue-darker: #015196; /* even darker version */
21 | }
22 |
23 | /**
24 | * Colors Theme
25 | * -------------------------------------------------------------------------- */
26 |
27 | :root {
28 | --vp-c-bg: #ffffff;
29 |
30 | --vp-c-bg-elv: #ffffff;
31 | --vp-c-bg-elv-up: #ffffff;
32 | --vp-c-bg-elv-down: #f6f6f7;
33 | --vp-c-bg-elv-mute: #f6f6f7;
34 |
35 | --vp-c-bg-soft: #f6f6f7;
36 | --vp-c-bg-soft-up: #ffffff;
37 | --vp-c-bg-soft-down: #e3e3e5;
38 | --vp-c-bg-soft-mute: #e3e3e5;
39 |
40 | --vp-c-bg-alt: #f6f6f7;
41 |
42 | --vp-c-border: rgba(60, 60, 67, 0.29);
43 | --vp-c-divider: rgba(60, 60, 67, 0.12);
44 | --vp-c-gutter: rgba(60, 60, 67, 0.12);
45 |
46 | --vp-c-neutral: var(--vp-c-black);
47 | --vp-c-neutral-inverse: var(--vp-c-white);
48 |
49 | --vp-c-text-1: var(--vp-c-text-light-1);
50 | --vp-c-text-2: var(--vp-c-text-light-2);
51 | --vp-c-text-3: var(--vp-c-text-light-3);
52 |
53 | --vp-c-text-inverse-1: var(--vp-c-text-dark-1);
54 | --vp-c-text-inverse-2: var(--vp-c-text-dark-2);
55 | --vp-c-text-inverse-3: var(--vp-c-text-dark-3);
56 |
57 | --vp-c-text-code: #4a4a4a;
58 |
59 | --vp-c-brand: var(--vp-c-blue);
60 | --vp-c-brand-light: var(--vp-c-blue-light);
61 | --vp-c-brand-lighter: var(--vp-c-blue-lighter);
62 | --vp-c-brand-dark: var(--vp-c-blue-dark);
63 | --vp-c-brand-darker: var(--vp-c-blue-darker);
64 |
65 | --vp-c-mute: #f6f6f7;
66 | --vp-c-mute-light: #f9f9fc;
67 | --vp-c-mute-lighter: #ffffff;
68 | --vp-c-mute-dark: #e3e3e5;
69 | --vp-c-mute-darker: #d7d7d9;
70 |
71 | --vp-c-bg-alt: #ffffff;
72 | --vp-sidebar-bg-color: var(--vp-c-bg-alt);
73 | --vp-code-copy-code-bg: var(--vp-code-block-bg);
74 | --vp-code-copy-code-hover-bg: var(--vp-code-block-bg);
75 | }
76 |
77 | :root {
78 | --vp-font-family-base: -apple-system, BlinkMacSystemFont, segoe ui, Helvetica, Arial, sans-serif, apple color emoji, segoe ui emoji;
79 | --vp-font-family-mono: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
80 | }
81 |
82 |
83 | /**
84 | * Colors: Function
85 | *
86 | * -------------------------------------------------------------------------- */
87 |
88 | :root {
89 | --vp-c-brand-1: var(--vp-c-blue);
90 | --vp-c-brand-2: var(--vp-c-blue-light);
91 | --vp-c-brand-3: var(--vp-c-blue-lighter);
92 | --vp-c-brand-soft: var(--vp-c-blue-soft);
93 | --vp-code-color: var(--vp-c-text-light-2);
94 | }
95 |
96 | /**
97 | * Component: Code
98 | * -------------------------------------------------------------------------- */
99 | :root {
100 | --vp-code-block-color: var(--vp-c-text-1);
101 | --vp-code-block-bg: rgb(246, 248, 250);
102 |
103 | --vp-code-tab-divider: var(--vp-c-text-light-3);
104 | --vp-code-tab-text-color: var(--vp-c-text-light-2);
105 | --vp-code-tab-bg: var(--vp-code-block-bg);
106 | --vp-code-tab-hover-text-color: var(--vp-c-text-light-1);
107 | --vp-code-tab-active-text-color: var(--vp-c-text-light-1);
108 | --vp-code-tab-active-bar-color: var(--vp-c-brand);
109 |
110 | --vp-code-line-highlight-color: rgba(0, 0, 0, 0.1);
111 | }
112 |
113 |
114 | .dark {
115 | --vp-c-bg: #1e1e20;
116 |
117 | --vp-c-bg-elv: #252529;
118 | --vp-c-bg-elv-up: #313136;
119 | --vp-c-bg-elv-down: #1e1e20;
120 | --vp-c-bg-elv-mute: #313136;
121 |
122 | --vp-c-bg-soft: #252529;
123 | --vp-c-bg-soft-up: #313136;
124 | --vp-c-bg-soft-down: #1e1e20;
125 | --vp-c-bg-soft-mute: #313136;
126 |
127 | --vp-c-bg-alt: #161618;
128 |
129 | --vp-c-border: rgba(82, 82, 89, 0.68);
130 | --vp-c-divider: rgba(82, 82, 89, 0.32);
131 | --vp-c-gutter: #000000;
132 |
133 | --vp-c-neutral: var(--vp-c-white);
134 | --vp-c-neutral-inverse: var(--vp-c-black);
135 |
136 | --vp-c-text-1: var(--vp-c-text-dark-1);
137 | --vp-c-text-2: var(--vp-c-text-dark-2);
138 | --vp-c-text-3: var(--vp-c-text-dark-3);
139 |
140 | --vp-c-text-inverse-1: var(--vp-c-text-light-1);
141 | --vp-c-text-inverse-2: var(--vp-c-text-light-2);
142 | --vp-c-text-inverse-3: var(--vp-c-text-light-3);
143 |
144 | --vp-c-mute: #313136;
145 | --vp-c-mute-light: #3a3a3c;
146 | --vp-c-mute-lighter: #505053;
147 | --vp-c-mute-dark: #2c2c30;
148 | --vp-c-mute-darker: #252529;
149 |
150 | --vp-code-block-bg: #2a2a2a;
151 | --vp-code-tab-hover-text-color: var(--vp-c-text-dark-2);
152 | --vp-code-tab-active-text-color: var(--vp-c-text-dark-1);
153 |
154 | --vp-c-text-code: #e8e8e8;
155 |
156 | --vp-code-color: var(--vp-c-text-dark-1);
157 | --vp-code-tab-text-color: var(--vp-c-text-dark-2);
158 | }
159 |
160 | .vp-doc h1,
161 | .vp-doc h2,
162 | .vp-doc h3,
163 | .vp-doc h4,
164 | .vp-doc h5,
165 | .vp-doc h6 {
166 | font-weight: 700;
167 | }
168 |
169 | .title {
170 | font-weight: 700 !important;
171 | }
172 |
173 | .tagline {
174 | max-width: 500px !important;
175 | }
176 |
177 | .DocSearch-Button {
178 | background-color: var(--vp-c-bg-soft);
179 | }
180 |
181 | body {
182 | -webkit-font-smoothing: subpixel-antialiased;
183 | }
184 |
185 | .VPImage.image-src {
186 | max-width: 640px;
187 | }
188 |
189 | @media screen and (max-width: 960px) {
190 | .VPHero.has-image .image {
191 | display: none;
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/docs/features/sorting.md:
--------------------------------------------------------------------------------
1 | # Sorting
2 |
3 | ArrayQuery provides a flexible sorting capability that allows you to order your data based on one or multiple fields. This feature is crucial for organizing and presenting data in a meaningful way.
4 |
5 | ## How Sorting Works
6 |
7 | The sort feature in ArrayQuery operates based on the `SortOptions` interface:
8 |
9 | ```ts twoslash
10 | interface SortOptions {
11 | key: string
12 | dir: 'asc' | 'desc'
13 | }
14 | ```
15 |
16 | - `key`: The field name to sort by
17 | - `dir`: The sort direction ('asc' for ascending, 'desc' for descending)
18 |
19 | You can provide either a single `SortOptions` object or an array of `SortOptions` to sort by multiple fields.
20 |
21 | ## Basic Usage
22 |
23 | Here's a simple example of how to use the sort feature with ArrayQuery:
24 |
25 | ```ts twoslash
26 | import { query } from '@chronicstone/array-query'
27 |
28 | const users = [
29 | { id: 1, name: 'John Doe', age: 30 },
30 | { id: 2, name: 'Jane Smith', age: 25 },
31 | { id: 3, name: 'Bob Johnson', age: 35 }
32 | ]
33 |
34 | const result = query(users, {
35 | sort: { key: 'age', dir: 'asc' }
36 | })
37 | ```
38 |
39 | This query will return the following result:
40 |
41 | ```ts twoslash
42 | [
43 | { id: 2, name: 'Jane Smith', age: 25 },
44 | { id: 1, name: 'John Doe', age: 30 },
45 | { id: 3, name: 'Bob Johnson', age: 35 }
46 | ]
47 | ```
48 |
49 | ## Multi-Field Sorting
50 |
51 | ArrayQuery supports sorting by multiple fields. The sort options are applied in the order they are provided:
52 |
53 | ```ts twoslash
54 | import { query } from '@chronicstone/array-query'
55 |
56 | const users = [
57 | { id: 1, name: 'John Doe', age: 30 },
58 | { id: 2, name: 'Jane Smith', age: 25 },
59 | { id: 3, name: 'Martin Sam', age: 35 },
60 | { id: 4, name: 'Bob Johnson', age: 35 },
61 | { id: 5, name: 'Robert John', age: 28 },
62 | { id: 6, name: 'Alice Williams', age: 28 },
63 | { id: 7, name: 'Emma Jones', age: 32 }
64 | ]
65 |
66 | const result = query(users, {
67 | sort: [
68 | { key: 'age', dir: 'asc' },
69 | { key: 'name', dir: 'asc' }
70 | ]
71 | })
72 | ```
73 |
74 | This will return:
75 |
76 | ```ts twoslash
77 | [
78 | { id: 2, name: 'Jane Smith', age: 25 },
79 | { id: 6, name: 'Alice Williams', age: 28 },
80 | { id: 5, name: 'Robert John', age: 28 },
81 | { id: 1, name: 'John Doe', age: 30 },
82 | { id: 7, name: 'Emma Jones', age: 32 },
83 | { id: 4, name: 'Bob Johnson', age: 35 },
84 | { id: 3, name: 'Martin Sam', age: 35 }
85 | ]
86 | ```
87 |
88 | In this example, the data is first sorted by age in ascending order. For entries with the same age, it then sorts by name in ascending order.
89 |
90 | ## Sorting Nested Fields
91 |
92 | You can sort by nested fields using dot notation:
93 |
94 | ```ts twoslash
95 | import { query } from '@chronicstone/array-query'
96 |
97 | const data = [
98 | { id: 1, name: 'John', scores: { math: 85, science: 92 } },
99 | { id: 2, name: 'Jane', scores: { math: 90, science: 88 } },
100 | { id: 3, name: 'Bob', scores: { math: 78, science: 95 } }
101 | ]
102 |
103 | const result = query(data, {
104 | sort: { key: 'scores.math', dir: 'desc' }
105 | })
106 | ```
107 |
108 | This will sort the data based on the math scores in descending order.
109 |
110 | ## Advanced Sorting with Value Parsing
111 |
112 | The `SortOptions` interface now includes a `parser` option, which allows you to prepare and format values before sorting. This is particularly useful when dealing with mixed data types or when you need to apply custom sorting logic.
113 |
114 | ```typescript
115 | export interface SortOptions {
116 | key: string
117 | dir?: 'asc' | 'desc'
118 | parser?: 'number' | 'boolean' | 'string' | ((value: any) => string | number | boolean | null | undefined)
119 | }
120 | ```
121 |
122 | The `parser` option can be one of the following:
123 |
124 | - A string value: `'number'`, `'boolean'`, or `'string'` for basic type conversion
125 | - A custom function that takes the value and returns a parsed version
126 |
127 | ### Examples
128 |
129 | #### Using Built-in Parsers
130 |
131 | 1. Sorting mixed string and number values as numbers:
132 |
133 | ```ts twoslash
134 | import { query } from '@chronicstone/array-query'
135 |
136 | const data = [
137 | { id: 1, value: '10' },
138 | { id: 2, value: 5 },
139 | { id: 3, value: '15' },
140 | { id: 4, value: '3' }
141 | ]
142 |
143 | const result = query(data, {
144 | sort: { key: 'value', dir: 'asc', parser: 'number' }
145 | })
146 | ```
147 |
148 | Result:
149 | ```ts twoslash
150 | [
151 | { id: 4, value: '3' },
152 | { id: 2, value: 5 },
153 | { id: 1, value: '10' },
154 | { id: 3, value: '15' }
155 | ]
156 | ```
157 |
158 | 2. Sorting boolean values:
159 |
160 | ```ts twoslash
161 | import { query } from '@chronicstone/array-query'
162 |
163 | const data = [
164 | { id: 1, isActive: 'true' },
165 | { id: 2, isActive: false },
166 | { id: 3, isActive: 'false' },
167 | { id: 4, isActive: true }
168 | ]
169 |
170 | const result = query(data, {
171 | sort: { key: 'isActive', dir: 'desc', parser: 'boolean' }
172 | })
173 | ```
174 |
175 | Result:
176 | ```ts twoslash
177 | [
178 | { id: 1, isActive: 'true' },
179 | { id: 4, isActive: true },
180 | { id: 2, isActive: false },
181 | { id: 3, isActive: 'false' }
182 | ]
183 | ```
184 |
185 | #### Using Custom Parser Functions
186 |
187 | 3. Sorting by date strings:
188 |
189 | ```ts twoslash
190 | import { query } from '@chronicstone/array-query'
191 |
192 | const data = [
193 | { id: 1, date: '2023-05-15' },
194 | { id: 2, date: '2023-01-10' },
195 | { id: 3, date: '2023-12-01' }
196 | ]
197 |
198 | const result = query(data, {
199 | sort: {
200 | key: 'date',
201 | dir: 'asc',
202 | parser: value => new Date(value).getTime()
203 | }
204 | })
205 | ```
206 |
207 | Result:
208 | ```ts twoslash
209 | [
210 | { id: 2, date: '2023-01-10' },
211 | { id: 1, date: '2023-05-15' },
212 | { id: 3, date: '2023-12-01' }
213 | ]
214 | ```
215 |
--------------------------------------------------------------------------------
/docs/features/filtering.md:
--------------------------------------------------------------------------------
1 | # Filtering
2 |
3 | ArrayQuery provides a powerful and flexible filtering engine that allows you to apply complex conditions to your data. The filtering system supports a wide range of match modes, nested conditions, and logical grouping with built-in array handling.
4 |
5 | ## Filter Structure
6 |
7 | Filters in ArrayQuery can be either an array of individual filter conditions or an array of filter groups. Here's the updated filter structure:
8 |
9 | ```ts
10 | export type FilterOptions = Array | Array
11 |
12 | interface QueryFilter {
13 | key: string
14 | value: any
15 | operator?: Operator
16 | matchMode: FilterMatchMode
17 | params?: ComparatorParams | ObjectMapFilterParams
18 | }
19 |
20 | interface QueryFilterGroup {
21 | operator: Operator
22 | filters: QueryFilter[]
23 | }
24 |
25 | type Operator = 'AND' | 'OR' | (() => 'AND' | 'OR')
26 | ```
27 |
28 | ## Basic Usage
29 |
30 | Here's a simple example of how to use the filter feature with ArrayQuery:
31 |
32 | ```ts twoslash
33 | import { query } from '@chronicstone/array-query'
34 |
35 | const users = [
36 | { id: 1, name: 'John Doe', age: 30, roles: ['admin', 'user'] },
37 | { id: 2, name: 'Jane Smith', age: 25, roles: ['user'] },
38 | { id: 3, name: 'Bob Johnson', age: 35, roles: ['manager', 'user'] }
39 | ]
40 |
41 | const result = query(users, {
42 | filter: [
43 | { key: 'age', matchMode: 'greaterThan', value: 25 }
44 | ]
45 | })
46 | ```
47 |
48 | This query will return users older than 25.
49 |
50 | ## Advanced Filtering
51 |
52 | ArrayQuery supports complex filtering scenarios, including logical groups and built-in array handling.
53 |
54 | ### Multiple Conditions with Regular Filters
55 |
56 | When you use an array of regular filters, all conditions must be met for a row to be included in the result:
57 |
58 | ```ts twoslash
59 | import { query } from '@chronicstone/array-query'
60 |
61 | const users = [
62 | { id: 1, name: 'John Doe', age: 30, roles: ['admin', 'user'] },
63 | { id: 2, name: 'Jane Smith', age: 25, roles: ['user'] },
64 | { id: 3, name: 'Bob Johnson', age: 35, roles: ['manager', 'user'] }
65 | ]
66 |
67 | const result = query(users, {
68 | filter: [
69 | { key: 'age', matchMode: 'greaterThan', value: 25 },
70 | { key: 'name', matchMode: 'contains', value: 'John' }
71 | ]
72 | })
73 | ```
74 |
75 | This query will return users who are older than 25 AND have 'John' in their name.
76 |
77 | ### Logical Groups
78 |
79 | You can use filter groups to create more complex conditions. When using filter groups, a row will be included if it matches at least one of the groups:
80 |
81 | ```ts twoslash
82 | import { query } from '@chronicstone/array-query'
83 |
84 | const users = [
85 | { id: 1, name: 'John Doe', age: 30, roles: ['admin', 'user'] },
86 | { id: 2, name: 'Jane Smith', age: 25, roles: ['user'] },
87 | { id: 3, name: 'Bob Johnson', age: 35, roles: ['manager', 'user'] },
88 | { id: 4, name: 'Alice Williams', age: 65, roles: ['user'] }
89 | ]
90 |
91 | const result = query(users, {
92 | filter: [
93 | {
94 | operator: 'AND',
95 | filters: [
96 | { key: 'age', matchMode: 'greaterThan', value: 18 },
97 | { key: 'age', matchMode: 'lessThan', value: 45 }
98 | ]
99 | },
100 | {
101 | operator: 'AND',
102 | filters: [
103 | { key: 'age', matchMode: 'greaterThan', value: 60 },
104 | { key: 'age', matchMode: 'lessThan', value: 90 }
105 | ]
106 | }
107 | ]
108 | })
109 | ```
110 |
111 | This example filters users who are either between 18 and 45 years old, OR between 60 and 90 years old. The row will be included if it matches either of these groups.
112 |
113 | ### Built-in Array Handling
114 |
115 | ArrayQuery automatically handles array values in the data. When a filter is applied to a field that contains an array, the filter condition is checked against each element of the array:
116 |
117 | ```ts twoslash
118 | import { query } from '@chronicstone/array-query'
119 |
120 | const users = [
121 | { id: 1, name: 'John Doe', age: 30, roles: ['admin', 'user'] },
122 | { id: 2, name: 'Jane Smith', age: 25, roles: ['user'] },
123 | { id: 3, name: 'Bob Johnson', age: 35, roles: ['manager', 'user'] }
124 | ]
125 |
126 | const result = query(users, {
127 | filter: [
128 | {
129 | key: 'roles',
130 | matchMode: 'equals',
131 | value: 'admin',
132 | operator: 'OR'
133 | }
134 | ]
135 | })
136 | ```
137 |
138 | In this example:
139 | - For a user with `roles: ['admin', 'manager']`, the filter will return true because at least one element ("admin") matches the condition.
140 | - The `operator: "OR"` means that if any element in the array matches, the condition is considered true.
141 | - If `operator: "AND"` was used, all elements in the array would need to match the condition.
142 |
143 | ### Nested Object Filtering
144 |
145 | You can filter based on nested object properties using dot notation:
146 |
147 | ```ts twoslash
148 | import { query } from '@chronicstone/array-query'
149 |
150 | const users = [
151 | { id: 1, name: 'John Doe', age: 30, address: { city: 'New York', country: 'USA' } },
152 | { id: 2, name: 'Jane Smith', age: 25, address: { city: 'London', country: 'UK' } },
153 | { id: 3, name: 'Bob Johnson', age: 35, address: { city: 'Paris', country: 'France' } }
154 | ]
155 |
156 | const result = query(users, {
157 | filter: [
158 | { key: 'address.city', matchMode: 'equals', value: 'New York' }
159 | ]
160 | })
161 | ```
162 |
163 | ## Match Modes
164 |
165 | ArrayQuery supports various match modes for different types of comparisons. Each match mode has its own dedicated documentation page for detailed usage. The available match modes include:
166 |
167 | - 'contains'
168 | - 'between'
169 | - 'equals'
170 | - 'notEquals'
171 | - 'greaterThan'
172 | - 'greaterThanOrEqual'
173 | - 'lessThan'
174 | - 'lessThanOrEqual'
175 | - 'exists'
176 | - 'arrayLength'
177 | - 'regex'
178 | - 'objectMatch'
179 |
--------------------------------------------------------------------------------
/test/fixtures/search.fixture.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "title": "Basic single field search",
4 | "data": [
5 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
6 | { "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
7 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
8 | ],
9 | "query": {
10 | "search": {
11 | "value": "john",
12 | "keys": ["name"]
13 | }
14 | },
15 | "result": {
16 | "rows": [
17 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
18 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
19 |
20 | ]
21 | }
22 | },
23 | {
24 | "title": "Multi-field search",
25 | "data": [
26 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
27 | { "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
28 | { "id": 3, "name": "Bob Thomas", "email": "bobjohn@example.com" }
29 | ],
30 | "query": {
31 | "search": {
32 | "value": "john",
33 | "keys": ["name", "email"]
34 | }
35 | },
36 | "result": {
37 | "rows": [
38 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
39 | { "id": 3, "name": "Bob Thomas", "email": "bobjohn@example.com" }
40 | ]
41 | }
42 | },
43 | {
44 | "title": "Case-sensitive search",
45 | "data": [
46 | { "id": 1, "name": "JOHN Doe", "email": "JOHN@example.com" },
47 | { "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
48 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
49 | ],
50 | "query": {
51 | "search": {
52 | "value": "JOHN",
53 | "keys": ["name"],
54 | "caseSensitive": true
55 | }
56 | },
57 | "result": {
58 | "rows": [
59 | { "id": 1, "name": "JOHN Doe", "email": "JOHN@example.com" }
60 | ]
61 | }
62 | },
63 | {
64 | "title": "Partial match search",
65 | "data": [
66 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
67 | { "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
68 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
69 | ],
70 | "query": {
71 | "search": {
72 | "value": "oh",
73 | "keys": ["name", "email"]
74 | }
75 | },
76 | "result": {
77 | "rows": [
78 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
79 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
80 |
81 | ]
82 | }
83 | },
84 | {
85 | "title": "Search with no results",
86 | "data": [
87 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
88 | { "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
89 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
90 | ],
91 | "query": {
92 | "search": {
93 | "value": "xyz",
94 | "keys": ["name", "email"]
95 | }
96 | },
97 | "result": {
98 | "rows": []
99 | }
100 | },
101 | {
102 | "title": "Search in nested fields",
103 | "data": [
104 | { "id": 1, "name": "John Doe", "contact": { "email": "john@example.com" } },
105 | { "id": 2, "name": "Jane Smith", "contact": { "email": "jane@example.com" } },
106 | { "id": 3, "name": "Bob Johnson", "contact": { "email": "bob@example.com" } }
107 | ],
108 | "query": {
109 | "search": {
110 | "value": "john",
111 | "keys": ["contact.email"]
112 | }
113 | },
114 | "result": {
115 | "rows": [
116 | { "id": 1, "name": "John Doe", "contact": { "email": "john@example.com" } }
117 | ]
118 | }
119 | },
120 | {
121 | "title": "Search with empty value",
122 | "data": [
123 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
124 | { "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
125 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
126 | ],
127 | "query": {
128 | "search": {
129 | "value": "",
130 | "keys": ["name", "email"]
131 | }
132 | },
133 | "result": {
134 | "rows": [
135 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
136 | { "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
137 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
138 | ]
139 | }
140 | },
141 | {
142 | "title": "Search with number fields",
143 | "data": [
144 | { "id": 1, "name": "John Doe", "age": 30 },
145 | { "id": 2, "name": "Jane Smith", "age": 25 },
146 | { "id": 3, "name": "Bob Johnson", "age": 35 }
147 | ],
148 | "query": {
149 | "search": {
150 | "value": "30",
151 | "keys": ["name", "age"]
152 | }
153 | },
154 | "result": {
155 | "rows": [
156 | { "id": 1, "name": "John Doe", "age": 30 }
157 | ]
158 | }
159 | },
160 | {
161 | "title": "Search with boolean fields",
162 | "data": [
163 | { "id": 1, "name": "John Doe", "isActive": true },
164 | { "id": 2, "name": "Jane Smith", "isActive": false },
165 | { "id": 3, "name": "Bob Johnson", "isActive": true }
166 | ],
167 | "query": {
168 | "search": {
169 | "value": "true",
170 | "keys": ["name", "isActive"]
171 | }
172 | },
173 | "result": {
174 | "rows": [
175 | { "id": 1, "name": "John Doe", "isActive": true },
176 | { "id": 3, "name": "Bob Johnson", "isActive": true }
177 | ]
178 | }
179 | },
180 | {
181 | "title": "Search with array fields",
182 | "data": [
183 | { "id": 1, "name": "John Doe", "tags": ["developer", "javascript"] },
184 | { "id": 2, "name": "Jane Smith", "tags": ["designer", "ui"] },
185 | { "id": 3, "name": "Bob Johnson", "tags": ["manager", "agile"] }
186 | ],
187 | "query": {
188 | "search": {
189 | "value": "javascript",
190 | "keys": ["name", "tags"]
191 | }
192 | },
193 | "result": {
194 | "rows": [
195 | { "id": 1, "name": "John Doe", "tags": ["developer", "javascript"] }
196 | ]
197 | }
198 | }
199 | ]
200 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { FilterMatchMode, GenericObject, MatchModeProcessorMap, Operator } from './types'
2 |
3 | export function getObjectProperty(object: Record, key: string) {
4 | return key.split('.').reduce((o, i) => o?.[i], object)
5 | }
6 |
7 | export const ValueChecker = {
8 | string: (value: any): value is string => typeof value === 'string',
9 | number: (value: any): value is number => typeof value === 'number',
10 | boolean: (value: any): value is boolean => typeof value === 'boolean',
11 | strNum: (value: any): value is string | number => typeof value === 'string' || typeof value === 'number',
12 | strNumBool: (value: any): value is string | number | boolean => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean',
13 | strNumBoolNull: (value: any): value is string | number | boolean | null => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null,
14 | object: (value: any): value is GenericObject => {
15 | return typeof value === 'object' && value !== null
16 | },
17 | }
18 |
19 | export const MatchModeProcessor: MatchModeProcessorMap = {
20 | equals: ({ value, filter }) =>
21 | ValueChecker.strNumBoolNull(filter)
22 | && ValueChecker.strNumBoolNull(value)
23 | && filter === value,
24 | notEquals: ({ value, filter }) =>
25 | ValueChecker.strNumBoolNull(filter)
26 | && ValueChecker.strNumBoolNull(value)
27 | && filter !== value,
28 | exists: ({ value, filter }) => filter ? typeof value !== 'undefined' : typeof value === 'undefined',
29 | contains: ({ value, filter }) => value?.includes(filter),
30 | greaterThan: ({ value, filter, params }) => params?.dateMode ? new Date(value) > new Date(filter) : value > filter,
31 | greaterThanOrEqual: ({ value, filter, params }) => {
32 | return params?.dateMode ? new Date(value) >= new Date(filter) : value >= filter
33 | },
34 | lessThan: ({ value, filter, params }) => params?.dateMode ? new Date(value) < new Date(filter) : value < filter,
35 | lessThanOrEqual: ({ value, filter, params }) => {
36 | return params?.dateMode ? new Date(value) <= new Date(filter) : value <= filter
37 | },
38 | between: ({ value, filter, params }) => {
39 | return params?.dateMode ? new Date(value) >= new Date(filter[0]) && new Date(value) <= new Date(filter[1]) : value >= filter[0] && value <= filter[1]
40 | },
41 | regex: ({ value, filter, params }) =>
42 | typeof value === 'string' && new RegExp(filter, params?.flags ?? '').test(value),
43 | arrayLength: ({ value, filter }) => Array.isArray(value) && value.length === filter,
44 | objectMatch: ({ value, filter, params, index }) => {
45 | const properties = typeof index !== 'undefined' && params.matchPropertyAtIndex ? [params.properties[index]] : params.properties
46 |
47 | return properties[params.operator === 'AND' ? 'every' : 'some' as const](property => MatchModeProcessor[property.matchMode]({
48 | value: getObjectProperty(value, property.key),
49 | filter: getObjectProperty(filter, property.key),
50 | params: {} as any,
51 | }))
52 | },
53 | }
54 |
55 | export function validateBetweenPayload(payload: any) {
56 | return Array.isArray(payload) && payload.length === 2 && payload.every((i: any) => !Array.isArray(i))
57 | }
58 |
59 | export function parseSearchValue(value: any, caseSensitive: boolean): string {
60 | return (caseSensitive ? value?.toString() : value?.toString()?.toLowerCase?.()) ?? ''
61 | }
62 |
63 | export function processSearchQuery(params: { key: string, object: Record, value: string, caseSensitive: boolean }): boolean {
64 | const { key, object, value, caseSensitive } = params
65 | const keys = key.split('.')
66 |
67 | let current: any = object
68 | for (let i = 0; i < keys.length; i++) {
69 | if (Array.isArray(current))
70 | return current.some(item => processSearchQuery({ key: keys.slice(i).join('.'), object: item, value, caseSensitive }))
71 |
72 | else if (current && Object.prototype.hasOwnProperty.call(current, keys[i]))
73 | current = current[keys[i]]
74 |
75 | else
76 | return false
77 | }
78 |
79 | if (Array.isArray(current))
80 | return current.some(element => parseSearchValue(element, caseSensitive).includes(parseSearchValue(value, caseSensitive)))
81 |
82 | else
83 | return parseSearchValue(current, caseSensitive).includes(parseSearchValue(value, caseSensitive)) ?? false
84 | }
85 |
86 | export function processFilterWithLookup<
87 | T extends FilterMatchMode,
88 | P = Parameters[0],
89 | >(params: {
90 | type: FilterMatchMode
91 | operator: 'AND' | 'OR'
92 | value: any
93 | filter: any
94 | params: P extends { params: infer U } ? U : P extends { params?: infer U } ? U : null
95 | lookupFrom?: 'value' | 'filter'
96 | }) {
97 | if (!Array.isArray(params.filter) || (params.type === 'between' && validateBetweenPayload(params.filter))) {
98 | return Array.isArray(params.value)
99 | ? params.value.some(value =>
100 | MatchModeProcessor[params.type]({
101 | params: params.params as any,
102 | value,
103 | filter: params.filter,
104 | }),
105 | )
106 | : MatchModeProcessor[params.type]({ params: params.params as any, value: params.value, filter: params.filter })
107 | }
108 |
109 | else if (params.operator === 'AND') {
110 | return Array.isArray(params.filter) && params.filter.every((filter, index) => {
111 | if (Array.isArray(params.value)) {
112 | return params.value.some(value =>
113 | MatchModeProcessor[params.type]({
114 | params: params.params as any,
115 | value,
116 | filter,
117 | index,
118 | }),
119 | )
120 | }
121 | else {
122 | return MatchModeProcessor[params.type]({ params: params.params as any, value: params.value, filter, index })
123 | }
124 | })
125 | }
126 |
127 | else if (params.operator === 'OR') {
128 | return Array.isArray(params.filter) && params.filter.some((filter, index) =>
129 | Array.isArray(params.value)
130 | ? params.value.some(value =>
131 | MatchModeProcessor[params.type]({
132 | params: params.params as any,
133 | value,
134 | filter,
135 | index,
136 | }),
137 | )
138 | : MatchModeProcessor[params.type]({ params: params.params as any, value: params.value, filter, index }),
139 | )
140 | }
141 |
142 | return false
143 | }
144 |
145 | export function getOperator(operator?: Operator) {
146 | return (typeof operator === 'function' ? operator() : operator) ?? 'OR'
147 | }
148 |
149 | export function omit>(object: T, keys: K) {
150 | const _result = { ...object }
151 | for (const key of keys) delete _result[key]
152 | return _result as Omit
153 | }
154 |
--------------------------------------------------------------------------------
/docs/filter-match-modes/object-match.md:
--------------------------------------------------------------------------------
1 | # Object Match Mode
2 |
3 | The 'objectMatch' mode provides powerful nested object filtering capabilities, including the ability to filter arrays of objects.
4 |
5 | ## Basic Usage
6 |
7 | ```ts twoslash
8 | import { query } from '@chronicstone/array-query'
9 |
10 | const data = [
11 | { id: 1, name: 'John', account: { type: 'premium', status: 'active', details: { level: 3 } } },
12 | { id: 2, name: 'Jane', account: { type: 'basic', status: 'inactive', details: { level: 1 } } },
13 | { id: 3, name: 'Bob', account: { type: 'premium', status: 'inactive', details: { level: 2 } } }
14 | ]
15 |
16 | const result = query(data, {
17 | filter: [
18 | {
19 | key: 'account',
20 | matchMode: 'objectMatch',
21 | value: { type: 'premium', status: 'active' },
22 | params: {
23 | operator: 'AND',
24 | properties: [
25 | { key: 'type', matchMode: 'equals' },
26 | { key: 'status', matchMode: 'equals' }
27 | ]
28 | }
29 | }
30 | ]
31 | })
32 | ```
33 |
34 | Output:
35 | ```ts twoslash
36 | [
37 | { id: 1, name: 'John', account: { type: 'premium', status: 'active', details: { level: 3 } } }
38 | ]
39 | ```
40 |
41 | This example demonstrates the basic usage. It filters objects where the 'account' has both 'type' equal to 'premium' AND 'status' equal to 'active'.
42 |
43 | ## Using Different Match Modes
44 |
45 | You can use different match modes for each property:
46 |
47 | ```ts twoslash
48 | import { query } from '@chronicstone/array-query'
49 |
50 | const data = [
51 | { id: 1, name: 'John', account: { type: 'premium', status: 'active', details: { level: 3 } } },
52 | { id: 2, name: 'Jane', account: { type: 'basic', status: 'inactive', details: { level: 1 } } },
53 | { id: 3, name: 'Bob', account: { type: 'premium', status: 'inactive', details: { level: 2 } } }
54 | ]
55 |
56 | const result = query(data, {
57 | filter: [
58 | {
59 | key: 'account',
60 | matchMode: 'objectMatch',
61 | value: { type: 'premium', details: { level: 2 } },
62 | params: {
63 | operator: 'AND',
64 | properties: [
65 | { key: 'type', matchMode: 'equals' },
66 | { key: 'details.level', matchMode: 'greaterThan' }
67 | ]
68 | }
69 | }
70 | ]
71 | })
72 | ```
73 |
74 | Output:
75 | ```ts twoslash
76 | [
77 | { id: 1, name: 'John', account: { type: 'premium', status: 'active', details: { level: 3 } } }
78 | ]
79 | ```
80 |
81 | This example uses 'equals' for 'type' and 'greaterThan' for 'details.level'.
82 |
83 | ## Filtering Arrays of Objects
84 |
85 | ### Case 1: ALL objects in the array match ALL conditions
86 |
87 | ```ts twoslash
88 | import { query } from '@chronicstone/array-query'
89 |
90 | const data = [
91 | { id: 1, name: 'John', orders: [{ id: 1, total: 100 }, { id: 2, total: 200 }] },
92 | { id: 2, name: 'Jane', orders: [{ id: 3, total: 150 }, { id: 4, total: 300 }] },
93 | { id: 3, name: 'Bob', orders: [{ id: 5, total: 50 }, { id: 6, total: 250 }] }
94 | ]
95 |
96 | const result = query(data, {
97 | filter: [
98 | {
99 | key: 'orders',
100 | matchMode: 'objectMatch',
101 | value: { total: 50 },
102 | operator: 'AND',
103 | params: {
104 | operator: 'AND',
105 | properties: [
106 | { key: 'total', matchMode: 'greaterThan' }
107 | ]
108 | }
109 | }
110 | ]
111 | })
112 | ```
113 |
114 | Output:
115 | ```ts twoslash
116 | [
117 | { id: 1, name: 'John', orders: [{ id: 1, total: 100 }, { id: 2, total: 200 }] },
118 | { id: 2, name: 'Jane', orders: [{ id: 3, total: 150 }, { id: 4, total: 300 }] }
119 | ]
120 | ```
121 |
122 | This example returns items where ALL orders have a total greater than 50.
123 |
124 | ### Case 2: ALL objects in the array match SOME of the conditions
125 |
126 | ```ts twoslash
127 | import { query } from '@chronicstone/array-query'
128 |
129 | const data = [
130 | { id: 1, name: 'John', orders: [{ id: 1, total: 100, status: 'completed' }, { id: 2, total: 200, status: 'pending' }] },
131 | { id: 2, name: 'Jane', orders: [{ id: 3, total: 150, status: 'completed' }, { id: 4, total: 300, status: 'completed' }] },
132 | { id: 3, name: 'Bob', orders: [{ id: 5, total: 50, status: 'pending' }, { id: 6, total: 250, status: 'completed' }] }
133 | ]
134 |
135 | const result = query(data, {
136 | filter: [
137 | {
138 | key: 'orders',
139 | matchMode: 'objectMatch',
140 | value: { total: 100, status: 'completed' },
141 | operator: 'AND',
142 | params: {
143 | operator: 'OR',
144 | properties: [
145 | { key: 'total', matchMode: 'greaterThan' },
146 | { key: 'status', matchMode: 'equals' }
147 | ]
148 | }
149 | }
150 | ]
151 | })
152 | ```
153 |
154 | Output:
155 | ```ts twoslash
156 | [
157 | { id: 1, name: 'John', orders: [{ id: 1, total: 100, status: 'completed' }, { id: 2, total: 200, status: 'pending' }] },
158 | { id: 2, name: 'Jane', orders: [{ id: 3, total: 150, status: 'completed' }, { id: 4, total: 300, status: 'completed' }] }
159 | ]
160 | ```
161 |
162 | This example returns items where ALL orders either have a total greater than 100 OR have a status of 'completed'.
163 |
164 | ### Case 3: SOME objects in the array match ALL conditions
165 |
166 | ```ts twoslash
167 | import { query } from '@chronicstone/array-query'
168 |
169 | const data = [
170 | { id: 1, name: 'John', orders: [{ id: 1, total: 100, status: 'completed' }, { id: 2, total: 200, status: 'pending' }] },
171 | { id: 2, name: 'Jane', orders: [{ id: 3, total: 150, status: 'completed' }, { id: 4, total: 300, status: 'completed' }] },
172 | { id: 3, name: 'Bob', orders: [{ id: 5, total: 50, status: 'pending' }, { id: 6, total: 250, status: 'completed' }] }
173 | ]
174 |
175 | const result = query(data, {
176 | filter: [
177 | {
178 | key: 'orders',
179 | matchMode: 'objectMatch',
180 | value: { total: 200, status: 'completed' },
181 | operator: 'OR',
182 | params: {
183 | operator: 'AND',
184 | properties: [
185 | { key: 'total', matchMode: 'greaterThan' },
186 | { key: 'status', matchMode: 'equals' }
187 | ]
188 | }
189 | }
190 | ]
191 | })
192 | ```
193 |
194 | Output:
195 | ```ts twoslash
196 | [
197 | { id: 2, name: 'Jane', orders: [{ id: 3, total: 150, status: 'completed' }, { id: 4, total: 300, status: 'completed' }] }
198 | ]
199 | ```
200 |
201 | This example returns items where at least one order has a total greater than 200 AND a status of 'completed'.
202 |
203 | ## Using applyAtRoot
204 |
205 | The `applyAtRoot` option allows you to apply the filter to the root object instead of a nested property:
206 |
207 | ```ts twoslash
208 | // @errors: 2322
209 | import { query } from '@chronicstone/array-query'
210 |
211 | const data = [
212 | { id: 1, name: 'John', age: 30, status: 'active' },
213 | { id: 2, name: 'Jane', age: 25, status: 'inactive' },
214 | { id: 3, name: 'Bob', age: 35, status: 'active' }
215 | ]
216 |
217 | const result = query(data, {
218 | filter: [
219 | {
220 | key: '',
221 | matchMode: 'objectMatch',
222 | value: { age: 30, status: 'active' },
223 | params: {
224 | operator: 'AND',
225 | properties: [
226 | { key: 'age', matchMode: 'greaterThanOrEqual' },
227 | { key: 'status', matchMode: 'equals' }
228 | ],
229 | applyAtRoot: true
230 | }
231 | }
232 | ]
233 | })
234 | ```
235 |
236 | Output:
237 | ```ts twoslash
238 | [
239 | { id: 1, name: 'John', age: 30, status: 'active' },
240 | { id: 3, name: 'Bob', age: 35, status: 'active' }
241 | ]
242 | ```
243 |
244 | This example applies the filter directly to the root objects in the array, matching those with age >= 30 and status 'active'.
245 |
--------------------------------------------------------------------------------
/test/fixtures/filtering.fixture.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "title": "Contains match mode",
4 | "data": [
5 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
6 | { "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
7 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
8 | ],
9 | "query": {
10 | "filter": [
11 | { "key": "name", "matchMode": "contains", "value": "oh" }
12 | ]
13 | },
14 | "result": {
15 | "rows": [
16 | { "id": 1, "name": "John Doe", "email": "john@example.com" },
17 | { "id": 3, "name": "Bob Johnson", "email": "bob@example.com" }
18 |
19 | ]
20 | }
21 | },
22 | {
23 | "title": "Between match mode with numbers",
24 | "data": [
25 | { "id": 1, "name": "John", "age": 25 },
26 | { "id": 2, "name": "Jane", "age": 30 },
27 | { "id": 3, "name": "Bob", "age": 35 }
28 | ],
29 | "query": {
30 | "filter": [
31 | { "key": "age", "matchMode": "between", "value": [28, 32] }
32 | ]
33 | },
34 | "result": {
35 | "rows": [
36 | { "id": 2, "name": "Jane", "age": 30 }
37 | ]
38 | }
39 | },
40 | {
41 | "title": "Between match mode with dates",
42 | "data": [
43 | { "id": 1, "name": "John", "joinDate": "2023-01-15" },
44 | { "id": 2, "name": "Jane", "joinDate": "2023-06-20" },
45 | { "id": 3, "name": "Bob", "joinDate": "2023-12-05" }
46 | ],
47 | "query": {
48 | "filter": [
49 | {
50 | "key": "joinDate",
51 | "matchMode": "between",
52 | "value": ["2023-05-01", "2023-08-31"],
53 | "params": { "dateMode": true }
54 | }
55 | ]
56 | },
57 | "result": {
58 | "rows": [
59 | { "id": 2, "name": "Jane", "joinDate": "2023-06-20" }
60 | ]
61 | }
62 | },
63 | {
64 | "title": "Equals match mode",
65 | "data": [
66 | { "id": 1, "name": "John", "status": "active" },
67 | { "id": 2, "name": "Jane", "status": "inactive" },
68 | { "id": 3, "name": "Bob", "status": "active" }
69 | ],
70 | "query": {
71 | "filter": [
72 | { "key": "status", "matchMode": "equals", "value": "active" }
73 | ]
74 | },
75 | "result": {
76 | "rows": [
77 | { "id": 1, "name": "John", "status": "active" },
78 | { "id": 3, "name": "Bob", "status": "active" }
79 | ]
80 | }
81 | },
82 | {
83 | "title": "Not Equals match mode",
84 | "data": [
85 | { "id": 1, "name": "John", "status": "active" },
86 | { "id": 2, "name": "Jane", "status": "inactive" },
87 | { "id": 3, "name": "Bob", "status": "active" }
88 | ],
89 | "query": {
90 | "filter": [
91 | { "key": "status", "matchMode": "notEquals", "value": "active" }
92 | ]
93 | },
94 | "result": {
95 | "rows": [
96 | { "id": 2, "name": "Jane", "status": "inactive" }
97 | ]
98 | }
99 | },
100 | {
101 | "title": "Greater Than match mode",
102 | "data": [
103 | { "id": 1, "name": "John", "age": 25 },
104 | { "id": 2, "name": "Jane", "age": 30 },
105 | { "id": 3, "name": "Bob", "age": 35 }
106 | ],
107 | "query": {
108 | "filter": [
109 | { "key": "age", "matchMode": "greaterThan", "value": 28 }
110 | ]
111 | },
112 | "result": {
113 | "rows": [
114 | { "id": 2, "name": "Jane", "age": 30 },
115 | { "id": 3, "name": "Bob", "age": 35 }
116 | ]
117 | }
118 | },
119 | {
120 | "title": "Greater Than or Equal match mode",
121 | "data": [
122 | { "id": 1, "name": "John", "age": 25 },
123 | { "id": 2, "name": "Jane", "age": 30 },
124 | { "id": 3, "name": "Bob", "age": 35 }
125 | ],
126 | "query": {
127 | "filter": [
128 | { "key": "age", "matchMode": "greaterThanOrEqual", "value": 30 }
129 | ]
130 | },
131 | "result": {
132 | "rows": [
133 | { "id": 2, "name": "Jane", "age": 30 },
134 | { "id": 3, "name": "Bob", "age": 35 }
135 | ]
136 | }
137 | },
138 | {
139 | "title": "Less Than match mode",
140 | "data": [
141 | { "id": 1, "name": "John", "age": 25 },
142 | { "id": 2, "name": "Jane", "age": 30 },
143 | { "id": 3, "name": "Bob", "age": 35 }
144 | ],
145 | "query": {
146 | "filter": [
147 | { "key": "age", "matchMode": "lessThan", "value": 30 }
148 | ]
149 | },
150 | "result": {
151 | "rows": [
152 | { "id": 1, "name": "John", "age": 25 }
153 | ]
154 | }
155 | },
156 | {
157 | "title": "Less Than or Equal match mode",
158 | "data": [
159 | { "id": 1, "name": "John", "age": 25 },
160 | { "id": 2, "name": "Jane", "age": 30 },
161 | { "id": 3, "name": "Bob", "age": 35 }
162 | ],
163 | "query": {
164 | "filter": [
165 | { "key": "age", "matchMode": "lessThanOrEqual", "value": 30 }
166 | ]
167 | },
168 | "result": {
169 | "rows": [
170 | { "id": 1, "name": "John", "age": 25 },
171 | { "id": 2, "name": "Jane", "age": 30 }
172 | ]
173 | }
174 | },
175 | {
176 | "title": "Exists match mode",
177 | "data": [
178 | { "id": 1, "name": "John", "age": 25 },
179 | { "id": 2, "name": "Jane" },
180 | { "id": 3, "name": "Bob", "age": 35 }
181 | ],
182 | "query": {
183 | "filter": [
184 | { "key": "age", "matchMode": "exists", "value": true }
185 | ]
186 | },
187 | "result": {
188 | "rows": [
189 | { "id": 1, "name": "John", "age": 25 },
190 | { "id": 3, "name": "Bob", "age": 35 }
191 | ]
192 | }
193 | },
194 | {
195 | "title": "Array Length match mode",
196 | "data": [
197 | { "id": 1, "name": "John", "skills": ["JavaScript", "TypeScript", "React"] },
198 | { "id": 2, "name": "Jane", "skills": ["Python", "Django"] },
199 | { "id": 3, "name": "Bob", "skills": ["Java", "Spring", "Hibernate", "SQL"] }
200 | ],
201 | "query": {
202 | "filter": [
203 | { "key": "skills", "matchMode": "arrayLength", "value": 3 }
204 | ]
205 | },
206 | "result": {
207 | "rows": [
208 | { "id": 1, "name": "John", "skills": ["JavaScript", "TypeScript", "React"] }
209 | ]
210 | }
211 | },
212 | {
213 | "title": "Regex match mode - basic matching",
214 | "data": [
215 | { "id": 1, "email": "john@example.com" },
216 | { "id": 2, "email": "jane@test.com" },
217 | { "id": 3, "email": "bob@example.org" }
218 | ],
219 | "query": {
220 | "filter": [
221 | { "key": "email", "matchMode": "regex", "value": "@example\\.(com|org)" }
222 | ]
223 | },
224 | "result": {
225 | "rows": [
226 | { "id": 1, "email": "john@example.com" },
227 | { "id": 3, "email": "bob@example.org" }
228 | ]
229 | }
230 | },
231 | {
232 | "title": "Regex match mode - case insensitive",
233 | "data": [
234 | { "id": 1, "name": "John Doe" },
235 | { "id": 2, "name": "jane smith" },
236 | { "id": 3, "name": "Bob Johnson" }
237 | ],
238 | "query": {
239 | "filter": [
240 | {
241 | "key": "name",
242 | "matchMode": "regex",
243 | "value": "^j.*",
244 | "params": { "flags": "i" }
245 | }
246 | ]
247 | },
248 | "result": {
249 | "rows": [
250 | { "id": 1, "name": "John Doe" },
251 | { "id": 2, "name": "jane smith" }
252 | ]
253 | }
254 | },
255 | {
256 | "title": "Regex match mode - case sensitive",
257 | "data": [
258 | { "id": 1, "name": "John Doe" },
259 | { "id": 2, "name": "jane smith" },
260 | { "id": 3, "name": "Bob Johnson" }
261 | ],
262 | "query": {
263 | "filter": [
264 | {
265 | "key": "name",
266 | "matchMode": "regex",
267 | "value": "^j.*"
268 | }
269 | ]
270 | },
271 | "result": {
272 | "rows": [
273 | { "id": 2, "name": "jane smith" }
274 | ]
275 | }
276 | },
277 | {
278 | "title": "Regex match mode - global flag",
279 | "data": [
280 | { "id": 1, "text": "The quick brown fox" },
281 | { "id": 2, "text": "The lazy dog" },
282 | { "id": 3, "text": "Quick foxes are quick" }
283 | ],
284 | "query": {
285 | "filter": [
286 | {
287 | "key": "text",
288 | "matchMode": "regex",
289 | "value": "quick",
290 | "params": { "flags": "g" }
291 | }
292 | ]
293 | },
294 | "result": {
295 | "rows": [
296 | { "id": 1, "text": "The quick brown fox" },
297 | { "id": 3, "text": "Quick foxes are quick" }
298 | ]
299 | }
300 | },
301 | {
302 | "title": "Regex match mode - multiline flag",
303 | "data": [
304 | { "id": 1, "description": "First line\nSecond line" },
305 | { "id": 2, "description": "One line only" },
306 | { "id": 3, "description": "First line\nLast line" }
307 | ],
308 | "query": {
309 | "filter": [
310 | {
311 | "key": "description",
312 | "matchMode": "regex",
313 | "value": "^Last",
314 | "params": { "flags": "m" }
315 | }
316 | ]
317 | },
318 | "result": {
319 | "rows": [
320 | { "id": 3, "description": "First line\nLast line" }
321 | ]
322 | }
323 | },
324 | {
325 | "title": "Regex match mode - combined flags",
326 | "data": [
327 | { "id": 1, "content": "HELLO\nWorld" },
328 | { "id": 2, "content": "hello\nWORLD" },
329 | { "id": 3, "content": "Hi\nthere" }
330 | ],
331 | "query": {
332 | "filter": [
333 | {
334 | "key": "content",
335 | "matchMode": "regex",
336 | "value": "^world",
337 | "params": { "flags": "im" }
338 | }
339 | ]
340 | },
341 | "result": {
342 | "rows": [
343 | { "id": 1, "content": "HELLO\nWorld" },
344 | { "id": 2, "content": "hello\nWORLD" }
345 | ]
346 | }
347 | },
348 | {
349 | "title": "Regex match mode - with other filters",
350 | "data": [
351 | { "id": 1, "name": "John Doe", "age": 30 },
352 | { "id": 2, "name": "Jane Smith", "age": 25 },
353 | { "id": 3, "name": "Bob Johnson", "age": 35 }
354 | ],
355 | "query": {
356 | "filter": [
357 | {
358 | "key": "name",
359 | "matchMode": "regex",
360 | "value": "^j.*",
361 | "params": { "flags": "i" }
362 | },
363 | { "key": "age", "matchMode": "greaterThan", "value": 28 }
364 | ]
365 | },
366 | "result": {
367 | "rows": [
368 | { "id": 1, "name": "John Doe", "age": 30 }
369 | ]
370 | }
371 | },
372 | {
373 | "title": "Object Match mode",
374 | "data": [
375 | { "id": 1, "name": "John", "account": { "type": "premium", "status": "active" } },
376 | { "id": 2, "name": "Jane", "account": { "type": "basic", "status": "inactive" } },
377 | { "id": 3, "name": "Bob", "account": { "type": "premium", "status": "inactive" } }
378 | ],
379 | "query": {
380 | "filter": [
381 | {
382 | "key": "account",
383 | "matchMode": "objectMatch",
384 | "value": { "type": "premium", "status": "active" },
385 | "params": {
386 | "operator": "AND",
387 | "properties": [
388 | { "key": "type", "matchMode": "equals" },
389 | { "key": "status", "matchMode": "equals" }
390 | ]
391 | }
392 | }
393 | ]
394 | },
395 | "result": {
396 | "rows": [
397 | { "id": 1, "name": "John", "account": { "type": "premium", "status": "active" } }
398 | ]
399 | }
400 | },
401 | {
402 | "title": "Object Match - ALL objects in the array match ALL conditions",
403 | "data": [
404 | { "id": 1, "name": "John", "orders": [{ "id": 1, "total": 100 }, { "id": 2, "total": 200 }] },
405 | { "id": 2, "name": "Jane", "orders": [{ "id": 3, "total": 150 }, { "id": 4, "total": 300 }] },
406 | { "id": 3, "name": "Bob", "orders": [{ "id": 5, "total": 50 }, { "id": 6, "total": 250 }] }
407 | ],
408 | "query": {
409 | "filter": [
410 | {
411 | "key": "orders",
412 | "matchMode": "objectMatch",
413 | "value": { "total": 50 },
414 | "operator": "AND",
415 | "params": {
416 | "operator": "AND",
417 | "properties": [
418 | { "key": "total", "matchMode": "greaterThan" }
419 | ]
420 | }
421 | }
422 | ]
423 | },
424 | "result": {
425 | "rows": [
426 | { "id": 1, "name": "John", "orders": [{ "id": 1, "total": 100 }, { "id": 2, "total": 200 }] },
427 | { "id": 2, "name": "Jane", "orders": [{ "id": 3, "total": 150 }, { "id": 4, "total": 300 }] }
428 | ]
429 | }
430 | },
431 | {
432 | "title": "Object Match - ALL objects in the array match SOME of the conditions",
433 | "data": [
434 | { "id": 1, "name": "John", "orders": [{ "id": 1, "total": 100, "status": "completed" }, { "id": 2, "total": 200, "status": "pending" }] },
435 | { "id": 2, "name": "Jane", "orders": [{ "id": 3, "total": 150, "status": "completed" }, { "id": 4, "total": 300, "status": "completed" }] },
436 | { "id": 3, "name": "Bob", "orders": [{ "id": 5, "total": 50, "status": "pending" }, { "id": 6, "total": 250, "status": "completed" }] }
437 | ],
438 | "query": {
439 | "filter": [
440 | {
441 | "key": "orders",
442 | "matchMode": "objectMatch",
443 | "value": { "total": 100, "status": "completed" },
444 | "operator": "AND",
445 | "params": {
446 | "operator": "OR",
447 | "properties": [
448 | { "key": "total", "matchMode": "greaterThan" },
449 | { "key": "status", "matchMode": "equals" }
450 | ]
451 | }
452 | }
453 | ]
454 | },
455 | "result": {
456 | "rows": [
457 | { "id": 1, "name": "John", "orders": [{ "id": 1, "total": 100, "status": "completed" }, { "id": 2, "total": 200, "status": "pending" }] },
458 | { "id": 2, "name": "Jane", "orders": [{ "id": 3, "total": 150, "status": "completed" }, { "id": 4, "total": 300, "status": "completed" }] }
459 | ]
460 | }
461 | },
462 | {
463 | "title": "Object Match - SOME objects in the array match ALL conditions",
464 | "data": [
465 | { "id": 1, "name": "John", "orders": [{ "id": 1, "total": 100, "status": "completed" }, { "id": 2, "total": 200, "status": "pending" }] },
466 | { "id": 2, "name": "Jane", "orders": [{ "id": 3, "total": 150, "status": "completed" }, { "id": 4, "total": 300, "status": "completed" }] },
467 | { "id": 3, "name": "Bob", "orders": [{ "id": 5, "total": 50, "status": "pending" }, { "id": 6, "total": 250, "status": "completed" }] }
468 | ],
469 | "query": {
470 | "filter": [
471 | {
472 | "key": "orders",
473 | "matchMode": "objectMatch",
474 | "value": { "total": 200, "status": "completed" },
475 | "operator": "OR",
476 | "params": {
477 | "operator": "AND",
478 | "properties": [
479 | { "key": "total", "matchMode": "greaterThan" },
480 | { "key": "status", "matchMode": "equals" }
481 | ]
482 | }
483 | }
484 | ]
485 | },
486 | "result": {
487 | "rows": [
488 | { "id": 2, "name": "Jane", "orders": [{ "id": 3, "total": 150, "status": "completed" }, { "id": 4, "total": 300, "status": "completed" }] }
489 | ]
490 | }
491 | },
492 | {
493 | "title": "Combined filters with AND logic",
494 | "data": [
495 | { "id": 1, "name": "John", "age": 30, "status": "active" },
496 | { "id": 2, "name": "Jane", "age": 25, "status": "inactive" },
497 | { "id": 3, "name": "Bob", "age": 35, "status": "active" }
498 | ],
499 | "query": {
500 | "filter": [
501 | { "key": "age", "matchMode": "greaterThan", "value": 25 },
502 | { "key": "status", "matchMode": "equals", "value": "active" }
503 | ]
504 | },
505 | "result": {
506 | "rows": [
507 | { "id": 1, "name": "John", "age": 30, "status": "active" },
508 | { "id": 3, "name": "Bob", "age": 35, "status": "active" }
509 | ]
510 | }
511 | },
512 | {
513 | "title": "Combined filters with OR logic",
514 | "data": [
515 | { "id": 1, "name": "John", "age": 30, "status": "active" },
516 | { "id": 2, "name": "Jane", "age": 25, "status": "inactive" },
517 | { "id": 3, "name": "Bob", "age": 35, "status": "active" }
518 | ],
519 | "query": {
520 | "filter": [
521 | {
522 | "operator": "OR",
523 | "filters": [
524 | { "key": "age", "matchMode": "lessThan", "value": 30 },
525 | { "key": "status", "matchMode": "equals", "value": "active" }
526 | ]
527 | }
528 | ]
529 | },
530 | "result": {
531 | "rows": [
532 | { "id": 1, "name": "John", "age": 30, "status": "active" },
533 | { "id": 2, "name": "Jane", "age": 25, "status": "inactive" },
534 | { "id": 3, "name": "Bob", "age": 35, "status": "active" }
535 | ]
536 | }
537 | }
538 | ]
539 |
--------------------------------------------------------------------------------