├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── tests ├── serialize.test.ts ├── deserializeValue.test.ts ├── stringify.test.ts ├── parse.test.ts ├── map.test.ts ├── splitTree.test.ts ├── deserialize.test.ts ├── joinTree.test.ts └── ungroupValues.test.ts ├── index.d.ts.map ├── package.json ├── README.md ├── .github └── workflows │ └── deploy.yml ├── index.d.ts └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["index.js"], 3 | "compilerOptions": { 4 | // Tells TypeScript to read JS files, as 5 | // normally they are ignored as source files 6 | "allowJs": true, 7 | // Generate d.ts files 8 | "declaration": true, 9 | // This compiler run should 10 | // only output d.ts files 11 | "emitDeclarationOnly": true, 12 | // go to js file when using IDE functions like 13 | // "Go to Definition" in VSCode 14 | "declarationMap": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 7 | overrides: [ 8 | { 9 | env: { 10 | node: true, 11 | }, 12 | files: [".eslintrc.{js,cjs}"], 13 | parserOptions: { 14 | sourceType: "script", 15 | }, 16 | }, 17 | ], 18 | parser: "@typescript-eslint/parser", 19 | parserOptions: { 20 | ecmaVersion: "latest", 21 | sourceType: "module", 22 | }, 23 | plugins: ["@typescript-eslint"], 24 | } 25 | -------------------------------------------------------------------------------- /tests/serialize.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest" 2 | import { deserialize, serialize } from "../index.js" 3 | 4 | describe("parses examples", () => { 5 | test.each([ 6 | `Name eq 'Jacob' and Age eq 30`, 7 | `Name eq 'Jacob' or Name eq 'John'`, 8 | `Name eq 'Jacob' and (Age eq 30 or Age eq 40)`, 9 | `Name eq 'Jacob' and ((Age eq 30 or Age eq 40) and (Name eq 'John' or Name eq 'Jacob'))`, 10 | `(Name eq 'Jacob' and Age eq 30) or ((Age eq 40 and Name eq 'John') or Name eq 'Jacob')`, 11 | ])("%s", (test) => { 12 | expect(serialize(deserialize(test)!)).toEqual(test) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /tests/deserializeValue.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest" 2 | import { deserializeValue } from "../index.js" 3 | 4 | test("returns null for empty input", () => { 5 | expect(deserializeValue("")).toBeNull() 6 | }) 7 | 8 | test("returns number for numeric input", () => { 9 | expect(deserializeValue("4")).toEqual(4) 10 | expect(deserializeValue("-4")).toEqual(-4) 11 | }) 12 | 13 | test("returns string for string input", () => { 14 | expect(deserializeValue("'John'")).toEqual("John") 15 | }) 16 | 17 | test("returns boolean for boolean input", () => { 18 | expect(deserializeValue("true")).toEqual(true) 19 | expect(deserializeValue("false")).toEqual(false) 20 | }) 21 | 22 | test("returns date for date input", () => { 23 | expect(deserializeValue("2021-01-01")).toEqual(new Date("2021-01-01")) 24 | }) 25 | -------------------------------------------------------------------------------- /index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;GAEG;AACH,oCAFW,MAAM,GAAG,IAAI,cA+GvB;AAED;;;;GAIG;AACH,wCAFW,MAAM,oCA8BhB;AAED;;;;GAIG;AACH,sDAHW,cAAc,UAAU,CAAC,0FAqEnC;AAED;;;;GAIG;AACH,uCAFW,QAAQ,OAAO,MAAM,EAAE,QAAQ,OAAO,kBAAkB,EAAE,iBAAiB,CAAC,CAAC,CAAC,CAAC,uBAMzF;AAED;;;;;GAKG;AACH,sCAHW,MAAM,iBAAiB,CAAC,aACxB,eAAe,gBAYzB;AAID;;;;GAIG;AACH,8BAHW,MAAM,yBAKhB;AAED;;;;GAIG;AACH,iCAHW,MAAM,4BAKhB;AAED;;;;GAIG;AACH,+BAHW,MAAM,kBAKhB;AAED;;;;;GAKG;AACH,gCAHW,MAAM,GAAG,IAAI,+FAiBvB;AAED;;;;;;GAMG;AACH,yCALW,MAAM,iBAAiB,CAAC;IAEE,QAAQ,GAAlC,eAAe;IACW,WAAW,GAArC,eAAe;WAMzB;AAED;;;;GAIG;AACH,sCAHW,UAAU,GACR,MAAM,CA0ClB;AACD;;;;GAIG;AACH,sCAJW,UAAU,GAAG,IAAI,YACjB,eAAe,GACb,UAAU,EAAE,CAWxB;AAED;;;;GAIG;AACH,sCAJW,UAAU,EAAE,YACZ,eAAe,GACb,UAAU,CAYtB;;aAtZa,UAAU,GAAG,MAAM;cACnB,QAAQ;WACR,UAAU,GAAG,WAAW,uBAAuB,CAAC;;;aAGhD,MAAM;cACN,kBAAkB;YAClB,QAAQ,WAAW,uBAAuB,CAAC,EAAE,UAAU,CAAC,EAAE;;8BAE3D,KAAK,GAAG,IAAI;iCACZ,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI;uBACvC,eAAe,GAAG,kBAAkB"} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odata-qs", 3 | "version": "0.1.0", 4 | "description": "An OData compliant querystring parser", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "vitest", 9 | "build": "tsc" 10 | }, 11 | "exports": { 12 | ".": "./index.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/jacobparis/odata-qs.git" 17 | }, 18 | "keywords": [ 19 | "odata" 20 | ], 21 | "author": "Jacob Paris", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/jacobparis/odata-qs/issues" 25 | }, 26 | "homepage": "https://github.com/jacobparis/odata-qs#readme", 27 | "devDependencies": { 28 | "@typescript-eslint/eslint-plugin": "^6.7.3", 29 | "@typescript-eslint/parser": "^6.7.3", 30 | "eslint": "^8.50.0", 31 | "pkg-pr-new": "^0.0.39", 32 | "typescript": "^5.2.2", 33 | "vitest": "^0.34.4" 34 | }, 35 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 36 | } 37 | -------------------------------------------------------------------------------- /tests/stringify.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { type GroupedExpression, stringify } from "../index.js"; 3 | 4 | describe("stringify examples", () => { 5 | test("null", () => { 6 | const groupedValues: Array = [ 7 | { 8 | subject: "Name", 9 | operator: "eq", 10 | values: [""], 11 | }, 12 | ]; 13 | 14 | expect(stringify(groupedValues)).toEqual(null); 15 | }) 16 | test("Name eq 'Jacob' or Name eq 'John'", () => { 17 | const groupedValues: Array = [ 18 | { 19 | subject: "Name", 20 | operator: "eq", 21 | values: ["Jacob", "John"], 22 | }, 23 | { 24 | subject: "Age", 25 | operator: "eq", 26 | values: [""], 27 | }, 28 | 29 | ]; 30 | 31 | expect(stringify(groupedValues)).toEqual("Name eq 'Jacob' or Name eq 'John'"); 32 | }); 33 | 34 | test("Name eq 'Jacob' and Eyes eq 'Blue'", () => { 35 | const groupedValues: Array = [ 36 | { 37 | subject: "Name", 38 | operator: "eq", 39 | values: ["Jacob", "John"], 40 | }, 41 | { 42 | subject: "Age", 43 | operator: "eq", 44 | values: [""], 45 | }, 46 | { 47 | subject: "Eyes", 48 | operator: "eq", 49 | values: ["Blue"], 50 | }, 51 | ]; 52 | 53 | expect(stringify(groupedValues)).toEqual("(Name eq 'Jacob' or Name eq 'John') and Eyes eq 'Blue'"); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest" 2 | import { parse } from "../index.js" 3 | 4 | describe("parses examples", () => { 5 | test(`Name eq 'Jacob' and Age eq 30`, (test) => { 6 | const tree = parse(test.task.name) 7 | expect(tree).toEqual({ 8 | Age: { 9 | eq: { 10 | operator: "eq", 11 | subject: "Age", 12 | values: [30], 13 | }, 14 | }, 15 | Name: { 16 | eq: { 17 | operator: "eq", 18 | subject: "Name", 19 | values: ["Jacob"], 20 | }, 21 | }, 22 | }) 23 | }) 24 | 25 | test(`Name eq 'Jacob' or Name eq 'John'`, (test) => { 26 | const tree = parse(test.task.name) 27 | expect(tree).toEqual({ 28 | Name: { 29 | eq: { 30 | operator: "eq", 31 | subject: "Name", 32 | values: ["Jacob", "John"], 33 | }, 34 | }, 35 | }) 36 | }) 37 | 38 | test(`Name eq 'a' or Name eq 'b' or Name eq 'c' or Name eq 'd'`, (test) => { 39 | const tree = parse(test.task.name) 40 | expect(tree).toEqual({ 41 | Name: { 42 | eq: { 43 | operator: "eq", 44 | subject: "Name", 45 | values: ["a", "b", "c", "d"], 46 | }, 47 | }, 48 | }) 49 | }) 50 | 51 | test(`Name eq 'Jacob' and (Age eq 30 or Age eq 40)`, (test) => { 52 | const tree = parse(test.task.name) 53 | expect(tree).toEqual({ 54 | Age: { 55 | eq: { 56 | operator: "eq", 57 | subject: "Age", 58 | values: [30, 40], 59 | }, 60 | }, 61 | Name: { 62 | eq: { 63 | operator: "eq", 64 | subject: "Name", 65 | values: ["Jacob"], 66 | }, 67 | }, 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # odata-qs 2 | 3 | An OData compliant querystring parser and serializer 4 | 5 | ## Usage 6 | 7 | ```js 8 | import { parse } from "odata-qs" 9 | 10 | const query = parse( 11 | `(name eq 'Jacob' or name eq 'John') and age gt 18 and age lt 65`, 12 | "and" 13 | ) 14 | 15 | { 16 | age: { 17 | gt: { 18 | operator: "gt", 19 | subject: "age", 20 | values: [18], 21 | }, 22 | lt: { 23 | operator: "lt", 24 | subject: "age", 25 | values: [65], 26 | }, 27 | }, 28 | name: { 29 | eq: { 30 | operator: "eq", 31 | subject: "name", 32 | values: ["Jacob", "John"], 33 | }, 34 | }, 35 | } 36 | ``` 37 | 38 | If you want a type-safe result, you can pass a third argument as an array of allowed subjects. If the query contains a subject that isn't in the array, it will throw an error at runtime, and during development you'll get full intellisense support. 39 | 40 | ```js 41 | import { parse } from "odata-qs" 42 | const filter = parse( 43 | `(name eq 'Jacob' or name eq 'John') and age gt 18 and age lt 65`, 44 | "and", 45 | ["name", "age"] 46 | ) 47 | filter.name // ✅ 48 | filter.age // ✅ 49 | filter.foo // Property 'foo' does not exist on type Record<'name' | 'age', …> 50 | ``` 51 | 52 | Serializing a filter expects a structured object with subject, operator, and value properties. 53 | 54 | ```ts 55 | import { serialize } from "odata-qs" 56 | serialize({ 57 | subject: "name", 58 | operator: "eq", 59 | value: "Jacob", 60 | }) // name eq 'Jacob' 61 | 62 | serialize({ 63 | subject: { 64 | subject: "name", 65 | operator: "eq", 66 | value: "Jacob", 67 | }, 68 | operator: "or", 69 | value: { 70 | subject: "name", 71 | operator: "eq", 72 | value: "John", 73 | }, 74 | }) // name eq 'Jacob' or name eq 'John' 75 | ``` 76 | -------------------------------------------------------------------------------- /tests/map.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest" 2 | import { deserialize, getMap, getValuesFromMap, splitTree } from "../index.js" 3 | 4 | describe("parses examples", () => { 5 | test(`Name eq 'Jacob' and Age eq 30`, (test) => { 6 | const tree = deserialize(test.task.name) 7 | const split = splitTree(tree, "and") 8 | expect(getValuesFromMap(getMap(split))).toEqual([ 9 | { 10 | subject: "Name", 11 | operator: "eq", 12 | values: ["Jacob"], 13 | }, 14 | { 15 | subject: "Age", 16 | operator: "eq", 17 | values: [30], 18 | }, 19 | ]) 20 | }) 21 | 22 | test(`Name eq 'Jacob' or Name eq 'John'`, (test) => { 23 | const tree = deserialize(test.task.name) 24 | const split = splitTree(tree, "or") 25 | expect(getValuesFromMap(getMap(split))).toEqual([ 26 | { 27 | subject: "Name", 28 | operator: "eq", 29 | values: ["Jacob", "John"], 30 | }, 31 | ]) 32 | }) 33 | 34 | test(`Name eq 'Jacob' and Age eq 30 and Age eq 40`, (test) => { 35 | const tree = deserialize(test.task.name) 36 | const split = splitTree(tree, "and") 37 | expect(getValuesFromMap(getMap(split))).toEqual([ 38 | { 39 | subject: "Name", 40 | operator: "eq", 41 | values: ["Jacob"], 42 | }, 43 | { 44 | subject: "Age", 45 | operator: "eq", 46 | values: [30, 40], 47 | }, 48 | ]) 49 | }) 50 | 51 | test(`Name eq 'Jacob' and (Age eq 30 or Age eq 40)`, (test) => { 52 | const tree = deserialize(test.task.name) 53 | const split = splitTree(tree, "and") 54 | expect(getValuesFromMap(getMap(split))).toEqual([ 55 | { 56 | subject: "Name", 57 | operator: "eq", 58 | values: ["Jacob"], 59 | }, 60 | { 61 | subject: "Age", 62 | operator: "eq", 63 | values: [30, 40], 64 | }, 65 | ]) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /tests/splitTree.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest" 2 | import { deserialize, splitTree } from "../index.js" 3 | 4 | describe("parses examples", () => { 5 | test(`Name eq 'Jacob'`, (test) => { 6 | const tree = deserialize(test.task.name) 7 | expect(splitTree(tree, "and")).toEqual([ 8 | { 9 | subject: "Name", 10 | operator: "eq", 11 | value: "Jacob", 12 | }, 13 | ]) 14 | }) 15 | 16 | test(`Name eq 'Jacob' and Age eq 30`, (test) => { 17 | const tree = deserialize(test.task.name) 18 | expect(splitTree(tree, "and")).toEqual([ 19 | { 20 | subject: "Name", 21 | operator: "eq", 22 | value: "Jacob", 23 | }, 24 | { 25 | subject: "Age", 26 | operator: "eq", 27 | value: 30, 28 | }, 29 | ]) 30 | }) 31 | 32 | test(`Name eq 'Jacob' or Name eq 'John'`, (test) => { 33 | const tree = deserialize(test.task.name) 34 | expect(splitTree(tree, "or")).toEqual([ 35 | { 36 | subject: "Name", 37 | operator: "eq", 38 | value: "Jacob", 39 | }, 40 | { 41 | subject: "Name", 42 | operator: "eq", 43 | value: "John", 44 | }, 45 | ]) 46 | }) 47 | 48 | test(`Name eq 'Jacob' and (Age eq 30 or Age eq 40)`, (test) => { 49 | const tree = deserialize(test.task.name) 50 | expect(splitTree(tree, "and")).toEqual([ 51 | { 52 | subject: "Name", 53 | operator: "eq", 54 | value: "Jacob", 55 | }, 56 | { 57 | subject: { 58 | subject: "Age", 59 | operator: "eq", 60 | value: 30, 61 | }, 62 | operator: "or", 63 | value: { 64 | subject: "Age", 65 | operator: "eq", 66 | value: 40, 67 | }, 68 | }, 69 | ]) 70 | }) 71 | 72 | test(`Name eq 'a' or Name eq 'b' or Name eq 'c' or Name eq 'd'`, (test) => { 73 | const tree = deserialize(test.task.name) 74 | expect(splitTree(tree, "or")).toEqual([ 75 | { 76 | subject: "Name", 77 | operator: "eq", 78 | value: "a", 79 | }, 80 | { 81 | subject: "Name", 82 | operator: "eq", 83 | value: "b", 84 | }, 85 | { 86 | subject: "Name", 87 | operator: "eq", 88 | value: "c", 89 | }, 90 | { 91 | subject: "Name", 92 | operator: "eq", 93 | value: "d", 94 | }, 95 | ]) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | # Add these permissions at the top level 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Run tests 25 | run: npm test 26 | 27 | build: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - name: Checkout code 32 | uses: actions/checkout@v4 33 | 34 | - run: corepack enable 35 | - uses: actions/setup-node@v4 36 | with: 37 | node-version: 20 38 | cache: "npm" 39 | 40 | - name: Install dependencies 41 | run: npm ci 42 | 43 | - name: Build 44 | run: npm run build 45 | 46 | - name: Increment package version 47 | if: github.ref == 'refs/heads/main' 48 | run: | 49 | npm version minor --no-git-tag-version 50 | echo "NEW_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV 51 | 52 | - name: Push changes 53 | if: github.ref == 'refs/heads/main' 54 | run: | 55 | git config user.name github-actions 56 | git config user.email github-actions@github.com 57 | git add package.json 58 | git commit -m "[skip ci]: bump version to ${{ env.NEW_VERSION }}" 59 | git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} HEAD:main 60 | 61 | - name: Publish to pkg.pr.new 62 | if: github.ref != 'refs/heads/main' 63 | run: npx pkg-pr-new publish 64 | 65 | - name: Create GitHub Release 66 | uses: actions/create-release@v1 67 | if: github.ref == 'refs/heads/main' 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | tag_name: v${{ env.NEW_VERSION }} 72 | release_name: Release v${{ env.NEW_VERSION }} 73 | draft: false 74 | prerelease: false 75 | 76 | - name: Set up .npmrc 77 | if: github.ref == 'refs/heads/main' 78 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc 79 | 80 | - name: Publish to npm 81 | if: github.ref == 'refs/heads/main' 82 | run: npm publish 83 | env: 84 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 85 | -------------------------------------------------------------------------------- /tests/deserialize.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest" 2 | import { deserialize } from "../index.js" 3 | 4 | test("returns null for empty input", () => { 5 | expect(deserialize("")).toBeNull() 6 | }) 7 | 8 | test("throws error for invalid input", () => { 9 | expect(() => deserialize("invalid input")).toThrow() 10 | expect(() => deserialize("eq")).toThrow() 11 | expect(() => deserialize("Jacob")).toThrow() 12 | }) 13 | 14 | test.each(["eq", "ne", "gt", "ge", "lt", "le"])( 15 | "supports %s operator", 16 | (operator) => { 17 | const result = deserialize(`Age ${operator} 30`) 18 | expect(result?.operator).toEqual(operator) 19 | } 20 | ) 21 | 22 | test("fails when logical operators are used as comparison", () => { 23 | expect(() => deserialize("Age and 30")).toThrow() 24 | expect(() => deserialize("Age or 30")).toThrow() 25 | }) 26 | 27 | describe("parses examples", () => { 28 | test(`Age eq 30`, (test) => { 29 | expect(deserialize(test.task.name)).toEqual({ 30 | subject: "Age", 31 | operator: "eq", 32 | value: 30, 33 | }) 34 | }) 35 | 36 | test(`Name eq 'Jacob'`, (test) => { 37 | expect(deserialize(test.task.name)).toEqual({ 38 | subject: "Name", 39 | operator: "eq", 40 | value: "Jacob", 41 | }) 42 | }) 43 | 44 | test(`Name eq 'Jacob' and Age eq 30`, (test) => { 45 | expect(deserialize(test.task.name)).toEqual({ 46 | subject: { 47 | subject: "Name", 48 | operator: "eq", 49 | value: "Jacob", 50 | }, 51 | operator: "and", 52 | value: { 53 | subject: "Age", 54 | operator: "eq", 55 | value: 30, 56 | }, 57 | }) 58 | }) 59 | 60 | test(`Name eq 'Jacob' or Age eq 30`, (test) => { 61 | expect(deserialize(test.task.name)).toEqual({ 62 | subject: { 63 | subject: "Name", 64 | operator: "eq", 65 | value: "Jacob", 66 | }, 67 | operator: "or", 68 | value: { 69 | subject: "Age", 70 | operator: "eq", 71 | value: 30, 72 | }, 73 | }) 74 | }) 75 | 76 | test(`Name eq 'Jacob' and (Age eq 30 or Age eq 40)`, (test) => { 77 | expect(deserialize(test.task.name)).toEqual({ 78 | subject: { 79 | subject: "Name", 80 | operator: "eq", 81 | value: "Jacob", 82 | }, 83 | operator: "and", 84 | value: { 85 | subject: { 86 | subject: "Age", 87 | operator: "eq", 88 | value: 30, 89 | }, 90 | operator: "or", 91 | value: { 92 | subject: "Age", 93 | operator: "eq", 94 | value: 40, 95 | }, 96 | }, 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /tests/joinTree.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest" 2 | import { Expression, joinTree } from "../index.js" 3 | 4 | describe("parses examples", () => { 5 | test(`Name eq 'Jacob'`, () => { 6 | const split: Array = [ 7 | { 8 | subject: "Name", 9 | operator: "eq", 10 | value: "Jacob", 11 | }, 12 | ] 13 | 14 | expect(joinTree(split, "and")).toEqual({ 15 | subject: "Name", 16 | operator: "eq", 17 | value: "Jacob", 18 | }) 19 | }) 20 | 21 | test(`Name eq 'Jacob' and Age eq 30`, () => { 22 | const split: Array = [ 23 | { 24 | subject: "Name", 25 | operator: "eq", 26 | value: "Jacob", 27 | }, 28 | { 29 | subject: "Age", 30 | operator: "eq", 31 | value: 30, 32 | }, 33 | ] 34 | 35 | expect(joinTree(split, "and")).toEqual({ 36 | subject: { 37 | subject: "Name", 38 | operator: "eq", 39 | value: "Jacob", 40 | }, 41 | operator: "and", 42 | value: { 43 | subject: "Age", 44 | operator: "eq", 45 | value: 30, 46 | }, 47 | }) 48 | }) 49 | 50 | test(`Name eq 'Jacob' or Name eq 'John'`, () => { 51 | const split: Array = [ 52 | { 53 | subject: "Name", 54 | operator: "eq", 55 | value: "Jacob", 56 | }, 57 | { 58 | subject: "Name", 59 | operator: "eq", 60 | value: "John", 61 | }, 62 | ] 63 | 64 | expect(joinTree(split, "or")).toEqual({ 65 | subject: { 66 | subject: "Name", 67 | operator: "eq", 68 | value: "Jacob", 69 | }, 70 | operator: "or", 71 | value: { 72 | subject: "Name", 73 | operator: "eq", 74 | value: "John", 75 | }, 76 | }) 77 | }) 78 | 79 | test(`Name eq 'Jacob' and (Age eq 30 or Age eq 40)`, () => { 80 | const split: Array = [ 81 | { 82 | subject: "Name", 83 | operator: "eq", 84 | value: "Jacob", 85 | }, 86 | { 87 | subject: { 88 | subject: "Age", 89 | operator: "eq", 90 | value: 30, 91 | }, 92 | operator: "or", 93 | value: { 94 | subject: "Age", 95 | operator: "eq", 96 | value: 40, 97 | }, 98 | }, 99 | ] 100 | 101 | expect(joinTree(split, "and")).toEqual({ 102 | subject: { 103 | subject: "Name", 104 | operator: "eq", 105 | value: "Jacob", 106 | }, 107 | operator: "and", 108 | value: { 109 | subject: { 110 | subject: "Age", 111 | operator: "eq", 112 | value: 30, 113 | }, 114 | operator: "or", 115 | value: { 116 | subject: "Age", 117 | operator: "eq", 118 | value: 40, 119 | }, 120 | }, 121 | }) 122 | }) 123 | 124 | test("empty expression", () => { 125 | const split: Array = []; 126 | expect(joinTree(split, "and")).toEqual(null); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} Expression 3 | * @property {Expression | string} subject 4 | * @property {Operator} operator 5 | * @property {Expression | ReturnType} value 6 | 7 | * @typedef {Object} GroupedExpression 8 | * @property {string} subject 9 | * @property {ComparisonOperator} operator 10 | * @property {Exclude, Expression>[]} values 11 | * 12 | * @typedef {'and' | 'or'} LogicalOperator 13 | * @typedef {'eq' | 'gt' | 'lt' | 'ge' | 'le' | 'ne'} ComparisonOperator 14 | * @typedef {LogicalOperator | ComparisonOperator} Operator 15 | */ 16 | /** 17 | * @param {string | null} [input] 18 | */ 19 | export function deserialize(input?: string | null): Expression; 20 | /** 21 | * Deserializes a string value to its corresponding JavaScript type. 22 | * 23 | * @param {string} value - The value to deserialize. 24 | */ 25 | export function deserializeValue(value: string): string | number | boolean | Date; 26 | /** 27 | * @template {string} T 28 | * @param {ReadonlyArray} expressions 29 | * @param {Array} [keys] 30 | */ 31 | export function getMap(expressions: ReadonlyArray, keys?: T[]): Partial>>>; 32 | /** 33 | * Returns an array of all the values in the map. 34 | * 35 | * @param {Partial>>>} tree - The tree of comparison operators. 36 | */ 37 | export function getValuesFromMap(tree: Partial>>>): GroupedExpression[]; 38 | /** 39 | * Ungroups a grouped expression into an array of expressions. 40 | * 41 | * @param {Array} groups - The grouped expression to ungroup. 42 | * @param {LogicalOperator} [operator] - The logical operator to use when joining the expressions. 43 | */ 44 | export function ungroupValues(groups: Array, operator?: LogicalOperator): Expression[]; 45 | /** 46 | * 47 | * @param {string} op 48 | * @returns {op is LogicalOperator} 49 | */ 50 | export function isLogical(op: string): op is LogicalOperator; 51 | /** 52 | * 53 | * @param {string} op 54 | * @returns {op is ComparisonOperator} 55 | */ 56 | export function isComparison(op: string): op is ComparisonOperator; 57 | /** 58 | * 59 | * @param {string} op 60 | * @returns {op is Operator} 61 | */ 62 | export function isOperator(op: string): op is Operator; 63 | /** 64 | * @template T extends Record 65 | * 66 | * @param {string | null} query 67 | * @param {Array} [keys] 68 | */ 69 | export function parse(query: string | null, keys?: T[]): Partial>>>; 70 | /** 71 | * 72 | * @param {Array} groupedValues 73 | * @param {Object} options 74 | * @param {LogicalOperator} [options.operator] 75 | * @param {LogicalOperator} [options.subOperator] 76 | */ 77 | export function stringify(groupedValues: Array, options?: { 78 | operator?: LogicalOperator; 79 | subOperator?: LogicalOperator; 80 | }): string | null; 81 | /** 82 | * 83 | * @param {Expression} expression 84 | * @returns {string} 85 | */ 86 | export function serialize(expression: Expression): string; 87 | /** 88 | * @param {Expression | null} expression 89 | * @param {LogicalOperator} operator 90 | * @returns {Expression[]} 91 | */ 92 | export function splitTree(expression: Expression | null, operator: LogicalOperator): Expression[]; 93 | /** 94 | * @param {Expression[]} expressions 95 | * @param {LogicalOperator} operator 96 | * @returns {Expression} 97 | */ 98 | export function joinTree(expressions: Expression[], operator: LogicalOperator): Expression; 99 | export type Expression = { 100 | subject: Expression | string; 101 | operator: Operator; 102 | value: Expression | ReturnType; 103 | }; 104 | export type GroupedExpression = { 105 | subject: string; 106 | operator: ComparisonOperator; 107 | values: Exclude, Expression>[]; 108 | }; 109 | export type LogicalOperator = 'and' | 'or'; 110 | export type ComparisonOperator = 'eq' | 'gt' | 'lt' | 'ge' | 'le' | 'ne'; 111 | export type Operator = LogicalOperator | ComparisonOperator; 112 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /tests/ungroupValues.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest" 2 | import { GroupedExpression, ungroupValues } from "../index.js" 3 | 4 | describe("parses examples", () => { 5 | test(`Name eq 'Jacob'`, () => { 6 | const groupedValues: Array = [ 7 | { 8 | subject: "Name", 9 | operator: "eq", 10 | values: ["Jacob"], 11 | }, 12 | ] 13 | 14 | expect(ungroupValues(groupedValues, "and")).toEqual([ 15 | { 16 | subject: "Name", 17 | operator: "eq", 18 | value: "Jacob", 19 | }, 20 | ]) 21 | }) 22 | 23 | test(`Name eq 'Jacob' and Age eq 30`, () => { 24 | const groupedValues: Array = [ 25 | { 26 | subject: "Name", 27 | operator: "eq", 28 | values: ["Jacob"], 29 | }, 30 | { 31 | subject: "Age", 32 | operator: "eq", 33 | values: [30], 34 | }, 35 | ] 36 | 37 | expect(ungroupValues(groupedValues, "and")).toEqual([ 38 | { 39 | subject: "Name", 40 | operator: "eq", 41 | value: "Jacob", 42 | }, 43 | { 44 | subject: "Age", 45 | operator: "eq", 46 | value: 30, 47 | }, 48 | ]) 49 | }) 50 | 51 | test(`Name eq 'Jacob' and Name eq 'John'`, () => { 52 | const groupedValues: Array = [ 53 | { 54 | subject: "Name", 55 | operator: "eq", 56 | values: ["Jacob", "John"], 57 | }, 58 | ] 59 | 60 | expect(ungroupValues(groupedValues, "and")).toEqual([ 61 | { 62 | subject: { 63 | subject: "Name", 64 | operator: "eq", 65 | value: "Jacob", 66 | }, 67 | operator: "and", 68 | value: { 69 | subject: "Name", 70 | operator: "eq", 71 | value: "John", 72 | }, 73 | }, 74 | ]) 75 | }) 76 | 77 | test(`Name eq 'Jacob' or Name eq 'John'`, () => { 78 | const groupedValues: Array = [ 79 | { 80 | subject: "Name", 81 | operator: "eq", 82 | values: ["Jacob", "John"], 83 | }, 84 | ] 85 | 86 | expect(ungroupValues(groupedValues, "or")).toEqual([ 87 | { 88 | subject: { 89 | subject: "Name", 90 | operator: "eq", 91 | value: "Jacob", 92 | }, 93 | operator: "or", 94 | value: { 95 | subject: "Name", 96 | operator: "eq", 97 | value: "John", 98 | }, 99 | }, 100 | ]) 101 | }) 102 | 103 | test(`Name eq 'Jacob' or Name eq 'John' or Name eq 'Jingleheimer'`, () => { 104 | const groupedValues: Array = [ 105 | { 106 | subject: "Name", 107 | operator: "eq", 108 | values: ["Jacob", "John", "Jingleheimer"], 109 | }, 110 | ] 111 | 112 | expect(ungroupValues(groupedValues, "or")).toEqual([ 113 | { 114 | subject: { 115 | subject: "Name", 116 | operator: "eq", 117 | value: "Jacob", 118 | }, 119 | operator: "or", 120 | value: { 121 | subject: { 122 | subject: "Name", 123 | operator: "eq", 124 | value: "John", 125 | }, 126 | operator: "or", 127 | value: { 128 | subject: "Name", 129 | operator: "eq", 130 | value: "Jingleheimer", 131 | }, 132 | }, 133 | }, 134 | ]) 135 | }) 136 | 137 | test(`Name eq 'Jacob' and (Age eq 30 or Age eq 40)`, () => { 138 | const groupedValues: Array = [ 139 | { 140 | subject: "Name", 141 | operator: "eq", 142 | values: ["Jacob"], 143 | }, 144 | { 145 | subject: "Age", 146 | operator: "eq", 147 | values: [30, 40], 148 | }, 149 | ] 150 | 151 | expect(ungroupValues(groupedValues, "or")).toEqual([ 152 | { 153 | subject: "Name", 154 | operator: "eq", 155 | value: "Jacob", 156 | }, 157 | { 158 | subject: { 159 | subject: "Age", 160 | operator: "eq", 161 | value: 30, 162 | }, 163 | operator: "or", 164 | value: { 165 | subject: "Age", 166 | operator: "eq", 167 | value: 40, 168 | }, 169 | }, 170 | ]) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} Expression 3 | * @property {Expression | string} subject 4 | * @property {Operator} operator 5 | * @property {Expression | ReturnType} value 6 | 7 | * @typedef {Object} GroupedExpression 8 | * @property {string} subject 9 | * @property {ComparisonOperator} operator 10 | * @property {Exclude, Expression>[]} values 11 | * 12 | * @typedef {'and' | 'or'} LogicalOperator 13 | * @typedef {'eq' | 'gt' | 'lt' | 'ge' | 'le' | 'ne'} ComparisonOperator 14 | * @typedef {LogicalOperator | ComparisonOperator} Operator 15 | */ 16 | 17 | /** 18 | * @param {string | null} [input] 19 | */ 20 | export function deserialize(input) { 21 | if (!input) return null 22 | 23 | /** @type {Map} */ 24 | const substitutions = new Map() 25 | 26 | return parseFragment(input) 27 | 28 | /** 29 | * @param {string} input 30 | * @returns {Expression} 31 | */ 32 | function parseFragment(input) { 33 | const matchSub = input.match(/^Sub_(\d+)$/) 34 | if (matchSub) { 35 | const matchingSub = substitutions.get(input) 36 | if (!matchingSub) { 37 | throw new Error("Symbol not found") 38 | } 39 | 40 | return matchingSub 41 | } 42 | 43 | if (input.includes("(")) { 44 | let filter = input 45 | while (filter.includes("(")) { 46 | let i = 0 47 | let leftParenIndex = 0 48 | let isInsideQuotes = false 49 | 50 | for (i = 0; i <= filter.length; i++) { 51 | if (i === filter.length) { 52 | throw new Error("Unmatched parens") 53 | } 54 | 55 | const cursor = filter[i] 56 | if (cursor === "'") { 57 | isInsideQuotes = !isInsideQuotes 58 | continue 59 | } 60 | 61 | if (isInsideQuotes) { 62 | continue 63 | } 64 | 65 | if (cursor === "(") { 66 | leftParenIndex = i 67 | continue 68 | } 69 | 70 | if (cursor === ")") { 71 | const filterSubstring = filter.substring(leftParenIndex + 1, i) 72 | const key = `Sub_${substitutions.size}` 73 | substitutions.set(key, parseFragment(filterSubstring)) 74 | // Replace the filterSubstring with the Symbol 75 | filter = [ 76 | filter.substring(0, leftParenIndex), 77 | key.toString(), 78 | filter.substring(i + 1), 79 | ].join("") 80 | break 81 | } 82 | } 83 | } 84 | 85 | return parseFragment(filter) 86 | } 87 | 88 | const matchAnd = input.match(/^(?.*?) and (?.*)$/) 89 | if (matchAnd) { 90 | const groups = matchAnd.groups 91 | 92 | return { 93 | subject: parseFragment(groups.left), 94 | operator: "and", 95 | value: parseFragment(groups.right), 96 | } 97 | } 98 | 99 | const matchOr = input.match(/^(?.*?) or (?.*)$/) 100 | if (matchOr) { 101 | const groups = matchOr.groups 102 | 103 | return { 104 | subject: parseFragment(groups.left), 105 | operator: "or", 106 | value: parseFragment(groups.right), 107 | } 108 | } 109 | 110 | const matchOp = input.match( 111 | /(?\w*) (?eq|gt|lt|ge|le|ne) (?datetimeoffset'(.*)'|'(.*)'|[0-9]*)/ 112 | ) 113 | if (matchOp) { 114 | const groups = matchOp.groups 115 | const operator = groups.operator 116 | if (!isComparison(operator)) { 117 | throw new Error(`Invalid operator: ${operator}`) 118 | } 119 | 120 | return { 121 | subject: groups.subject, 122 | operator: operator, 123 | value: deserializeValue(groups.value), 124 | } 125 | } 126 | 127 | throw new Error(`Invalid filter string: ${input}`) 128 | } 129 | } 130 | 131 | /** 132 | * Deserializes a string value to its corresponding JavaScript type. 133 | * 134 | * @param {string} value - The value to deserialize. 135 | */ 136 | export function deserializeValue(value) { 137 | if (value.startsWith("'") && value.endsWith("'")) { 138 | return value.substring(1, value.length - 1) 139 | } 140 | 141 | // support integers, negative numbers 142 | if (/^-?\d+$/.test(value)) { 143 | return Number(value) 144 | } 145 | 146 | // support booleans 147 | if (value === "true") { 148 | return true 149 | } 150 | 151 | if (value === "false") { 152 | return false 153 | } 154 | 155 | // support ISO 8601 date 156 | const match = value.match(/^(?\d{4})-(?\d{2})-(?\d{2})$/) 157 | if (match && match.groups) { 158 | const { year, month, day } = match.groups 159 | const date = new Date(`${year}-${month}-${day}`) 160 | return date 161 | } 162 | 163 | return null 164 | } 165 | 166 | /** 167 | * @template {string} T 168 | * @param {ReadonlyArray} expressions 169 | * @param {Array} [keys] 170 | */ 171 | export function getMap(expressions, keys) { 172 | /** 173 | * @param {string | number | symbol} value 174 | * @returns {value is T} 175 | */ 176 | function isKey(value) { 177 | if (typeof value !== "string") return false 178 | if (!keys) return true 179 | return keys.includes(value) 180 | } 181 | 182 | return expressions 183 | .map((expression) => { 184 | if (isLogical(expression.operator)) { 185 | const expressions = splitTree(expression, expression.operator) 186 | 187 | const uniqueSubjects = new Set(expressions.map((e) => e.subject)) 188 | if (uniqueSubjects.size !== 1) { 189 | throw new Error("Cannot map logical operator with multiple subjects") 190 | } 191 | 192 | const uniqueOperators = new Set(expressions.map((e) => e.operator)) 193 | if (uniqueOperators.size !== 1) { 194 | throw new Error("Cannot map logical operator with multiple operators") 195 | } 196 | 197 | return { 198 | subject: expression.subject.subject, 199 | operator: expression.subject.operator, 200 | values: expressions.map((e) => e.value), 201 | } 202 | } 203 | 204 | return { 205 | subject: expression.subject, 206 | operator: expression.operator, 207 | values: [expression.value], 208 | } 209 | }) 210 | .reduce((acc, cur) => { 211 | /** @type {T} */ 212 | const subject = cur.subject 213 | 214 | if (!isKey(subject)) { 215 | throw new Error(`Subject "${subject}" does not match ${keys}`) 216 | } 217 | 218 | if (!acc[subject]) { 219 | acc[subject] = {} 220 | } 221 | 222 | if (!acc[subject][cur.operator]) { 223 | acc[subject][cur.operator] = { 224 | subject: subject, 225 | operator: cur.operator, 226 | values: [...cur.values], 227 | } 228 | } 229 | 230 | // TODO: This could probably be optimized 231 | acc[subject][cur.operator].values = Array.from( 232 | new Set(acc[subject][cur.operator].values.concat(cur.values)) 233 | ) 234 | 235 | return acc 236 | }, /** @type {Partial>>>} */ ({})) 237 | } 238 | 239 | /** 240 | * Returns an array of all the values in the map. 241 | * 242 | * @param {Partial>>>} tree - The tree of comparison operators. 243 | */ 244 | export function getValuesFromMap(tree) { 245 | return Object.values(tree).reduce((acc, cur) => { 246 | return acc.concat(Object.values(cur)) 247 | }, /** @type {GroupedExpression[]} */ ([])) 248 | } 249 | 250 | /** 251 | * Ungroups a grouped expression into an array of expressions. 252 | * 253 | * @param {Array} groups - The grouped expression to ungroup. 254 | * @param {LogicalOperator} [operator] - The logical operator to use when joining the expressions. 255 | */ 256 | export function ungroupValues(groups, operator = "or") { 257 | return groups.filter(group => group.values.some(value => 258 | value !== null && value !== undefined && value !== '' 259 | )).map((group) => { 260 | const expressions = group.values.map((value) => ({ 261 | subject: group.subject, 262 | operator: group.operator, 263 | value, 264 | })) 265 | 266 | return joinTree(expressions, operator) 267 | }) 268 | } 269 | const logicalOperators = ["and", "or"] 270 | const comparisonOperators = ["eq", "gt", "ge", "lt", "le", "ne"] 271 | 272 | /** 273 | * 274 | * @param {string} op 275 | * @returns {op is LogicalOperator} 276 | */ 277 | export function isLogical(op) { 278 | return logicalOperators.includes(op) 279 | } 280 | 281 | /** 282 | * 283 | * @param {string} op 284 | * @returns {op is ComparisonOperator} 285 | */ 286 | export function isComparison(op) { 287 | return comparisonOperators.includes(op) 288 | } 289 | 290 | /** 291 | * 292 | * @param {string} op 293 | * @returns {op is Operator} 294 | */ 295 | export function isOperator(op) { 296 | return isLogical(op) || isComparison(op) 297 | } 298 | 299 | /** 300 | * @template T extends Record 301 | * 302 | * @param {string | null} query 303 | * @param {Array} [keys] 304 | */ 305 | export function parse(query, keys) { 306 | if (!query) return {} 307 | 308 | const deserialized = deserialize(query) 309 | 310 | const operator = isLogical(deserialized.operator) 311 | ? deserialized.operator 312 | : "and" 313 | 314 | const tree = splitTree(deserialized, operator) 315 | 316 | const map = getMap(tree, keys) 317 | 318 | return map 319 | } 320 | 321 | /** 322 | * 323 | * @param {Array} groupedValues 324 | * @param {Object} options 325 | * @param {LogicalOperator} [options.operator] 326 | * @param {LogicalOperator} [options.subOperator] 327 | */ 328 | export function stringify(groupedValues, options = {}) { 329 | const ungrouped = ungroupValues(groupedValues, options.subOperator || "or") 330 | const joined = joinTree(ungrouped, options.operator || "and") 331 | return joined ? serialize(joined) : null 332 | } 333 | 334 | /** 335 | * 336 | * @param {Expression} expression 337 | * @returns {string} 338 | */ 339 | export function serialize(expression) { 340 | const subject = 341 | typeof expression.subject === "string" 342 | ? expression.subject 343 | : cleanSerialize(expression.subject) 344 | 345 | if (!expression.value) { 346 | throw new Error("Invalid expression value") 347 | } 348 | 349 | if (typeof expression.value === "string") { 350 | return `${subject} ${expression.operator} '${expression.value}'` 351 | } 352 | 353 | if (typeof expression.value === "number") { 354 | return `${subject} ${expression.operator} ${expression.value}` 355 | } 356 | 357 | if (typeof expression.value === "boolean") { 358 | return `${subject} ${expression.operator} ${expression.value}` 359 | } 360 | 361 | if (expression.value instanceof Date) { 362 | return `${subject} ${ 363 | expression.operator 364 | } '${expression.value.toISOString()}'` 365 | } 366 | 367 | return `${subject} ${expression.operator} ${cleanSerialize(expression.value)}` 368 | 369 | /** 370 | * 371 | * @param {Expression} expression 372 | * @returns {string} 373 | */ 374 | function cleanSerialize(expression) { 375 | return isLogical(expression.operator) 376 | ? `(${serialize(expression)})` 377 | : serialize(expression) 378 | } 379 | } 380 | /** 381 | * @param {Expression | null} expression 382 | * @param {LogicalOperator} operator 383 | * @returns {Expression[]} 384 | */ 385 | export function splitTree(expression, operator) { 386 | if (!expression) return [] 387 | if (expression.operator === operator) { 388 | return [ 389 | ...splitTree(expression.subject, operator), 390 | ...splitTree(expression.value, operator), 391 | ] 392 | } 393 | return [expression] 394 | } 395 | 396 | /** 397 | * @param {Expression[]} expressions 398 | * @param {LogicalOperator} operator 399 | * @returns {Expression | null} 400 | */ 401 | export function joinTree(expressions, operator) { 402 | if (expressions.length === 0) { 403 | return null 404 | } 405 | 406 | if (expressions.length === 1) { 407 | return expressions[0] 408 | } 409 | 410 | const [first, ...rest] = expressions 411 | return { 412 | subject: first, 413 | operator, 414 | value: joinTree(rest, operator), 415 | } 416 | } 417 | --------------------------------------------------------------------------------