diff --git a/package.json b/package.json index 4eebca38c..f0455b986 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "packages/bruno-cli", "packages/bruno-tauri", "packages/bruno-schema", + "packages/bruno-query", "packages/bruno-js", "packages/bruno-lang", "packages/bruno-testbench", @@ -14,15 +15,18 @@ ], "devDependencies": { "@faker-js/faker": "^7.6.0", + "@jest/globals": "^29.2.0", "@playwright/test": "^1.27.1", "jest": "^29.2.0", - "randomstring": "^1.2.2" + "randomstring": "^1.2.2", + "ts-jest": "^29.0.5" }, "scripts": { "dev:web": "npm run dev --workspace=packages/bruno-app", "build:web": "npm run build --workspace=packages/bruno-app", "prettier:web": "npm run prettier --workspace=packages/bruno-app", "dev:electron": "npm run dev --workspace=packages/bruno-electron", + "build:bruno-query": "npm run build --workspace=packages/bruno-query", "build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs", "build:chrome-extension": "./scripts/build-chrome-extension.sh", "build:electron": "./scripts/build-electron.sh", @@ -32,4 +36,4 @@ "overrides": { "rollup": "3.2.5" } -} +} \ No newline at end of file diff --git a/packages/bruno-query/.gitignore b/packages/bruno-query/.gitignore new file mode 100644 index 000000000..f6eabff32 --- /dev/null +++ b/packages/bruno-query/.gitignore @@ -0,0 +1,22 @@ +# dependencies +node_modules +yarn.lock +pnpm-lock.yaml +package-lock.json +.pnp +.pnp.js + +# testing +coverage + +# production +dist + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/packages/bruno-query/jest.config.js b/packages/bruno-query/jest.config.js new file mode 100644 index 000000000..b413e106d --- /dev/null +++ b/packages/bruno-query/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; \ No newline at end of file diff --git a/packages/bruno-query/package.json b/packages/bruno-query/package.json new file mode 100644 index 000000000..18f356095 --- /dev/null +++ b/packages/bruno-query/package.json @@ -0,0 +1,32 @@ +{ + "name": "@usebruno/query", + "version": "0.1.0", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src", + "package.json" + ], + "scripts": { + "clean": "rimraf dist", + "test": "jest", + "prebuild": "npm run clean", + "build": "rollup -c", + "prepack": "npm run test && npm run build" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^23.0.2", + "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-typescript": "^9.0.2", + "rollup": "3.2.5", + "rollup-plugin-dts": "^5.0.0", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-terser": "^7.0.2", + "typescript": "^4.8.4" + }, + "overrides": { + "rollup": "3.2.5" + } +} \ No newline at end of file diff --git a/packages/bruno-query/readme.md b/packages/bruno-query/readme.md new file mode 100644 index 000000000..aeb709c65 --- /dev/null +++ b/packages/bruno-query/readme.md @@ -0,0 +1,29 @@ +# bruno-query + +Bruno query with deep navigation, filter and map support + +Easy array navigation +```js +get(data, 'customer.orders.items.amount') +``` +Deep navigation .. double dots +```js +get(data, '..items.amount') +``` +Array indexing +```js +get(data, '..items[0].amount') +``` +Array filtering [?] with corresponding filter function +```js +get(data, '..items[?].amount', i => i.amount > 20) +``` +Array mapping [?] with corresponding mapper function +```js +get(data, '..items[?].amount', i => i.amount + 10) +``` + +### Publish to Npm Registry +```bash +npm publish --access=public +``` \ No newline at end of file diff --git a/packages/bruno-query/rollup.config.js b/packages/bruno-query/rollup.config.js new file mode 100644 index 000000000..67dc6b113 --- /dev/null +++ b/packages/bruno-query/rollup.config.js @@ -0,0 +1,40 @@ +const { nodeResolve } = require("@rollup/plugin-node-resolve"); +const commonjs = require("@rollup/plugin-commonjs"); +const typescript = require("@rollup/plugin-typescript"); +const dts = require("rollup-plugin-dts"); +const { terser } = require("rollup-plugin-terser"); +const peerDepsExternal = require('rollup-plugin-peer-deps-external'); + +const packageJson = require("./package.json"); + +module.exports = [ + { + input: "src/index.ts", + output: [ + { + file: packageJson.main, + format: "cjs", + sourcemap: true, + }, + { + file: packageJson.module, + format: "esm", + sourcemap: true, + }, + ], + plugins: [ + peerDepsExternal(), + nodeResolve({ + extensions: ['.css'] + }), + commonjs(), + typescript({ tsconfig: "./tsconfig.json" }), + terser() + ] + }, + { + input: "dist/esm/index.d.ts", + output: [{ file: "dist/index.d.ts", format: "esm" }], + plugins: [dts.default()], + } +]; \ No newline at end of file diff --git a/packages/bruno-query/src/index.ts b/packages/bruno-query/src/index.ts new file mode 100644 index 000000000..5c29c510e --- /dev/null +++ b/packages/bruno-query/src/index.ts @@ -0,0 +1,134 @@ +/** + * If value is an array returns the deeply flattened array, otherwise value + */ +function normalize(value: any) { + if (!Array.isArray(value)) return value; + + const values = [] as any[]; + + value.forEach(item => { + const value = normalize(item); + if (value != null) { + values.push(...Array.isArray(value) ? value : [value]); + } + }); + + return values.length ? values : undefined; +} + +/** + * Gets value of a prop from source. + * + * If source is an array get value for each item. + * + * If deep is true then recursively gets values for prop in nested objects. + * + * Once a value if found will not recurese further into that value. + */ +function getValue(source: any, prop: string, deep = false): any { + if (typeof source !== 'object') return; + + let value; + + if (Array.isArray(source)) { + value = source.map(item => getValue(item, prop, deep)); + } else { + value = source[prop]; + if (deep) { + value = [value]; + for (const [key, item] of Object.entries(source)) { + if (key !== prop && typeof item === 'object') { + value.push(getValue(source[key], prop, deep)); + } + } + } + } + + return normalize(value); +} + +type PredicateOrMapper = (obj: any) => any; + +/** + * Apply filter on source array or object + * + * If the filter returns a non boolean non null value it is treated as a mapped value + */ +function filterOrMap(source: any, fun: PredicateOrMapper) { + const isArray = Array.isArray(source); + const list = isArray ? source : [source]; + const result = [] as any[]; + for (const item of list) { + if (item == null) continue; + const value = fun(item); + if (value === true) { + result.push(item); // predicate + } else if (value != null && value !== false) { + result.push(value); // mapper + } + } + return isArray ? result : result[0]; +} + +/** + * Getter with deep navigation, filter and map support + * + * 1. Easy array navigation + * ```js + * get(data, 'customer.orders.items.amount') + * ``` + * 2. Deep navigation .. double dots + * ```js + * get(data, '..items.amount') + * ``` + * 3. Array indexing + * ```js + * get(data, '..items[0].amount') + * ``` + * 4. Array filtering [?] with corresponding filter function + * ```js + * get(data, '..items[?].amount', i => i.amount > 20) + * ``` + * 5. Array mapping [?] with corresponding mapper function + * ```js + * get(data, '..items[?].amount', i => i.amount + 10) + * ``` + */ +export function get(source: any, path: string, ...fns: PredicateOrMapper[]) { + const paths = path + .replace(/\s+/g, '') + .split(/(\.{1,2}|\[\?\]|\[\d+\])/g) // ["..", "items", "[?]", ".", "amount", "[0]" ] + .filter(s => s.length > 0) + .map(str => { + str = str.replace(/\[|\]/g, ''); + const index = parseInt(str); + return isNaN(index) ? str : index; + }); + + let index = 0, lookbehind = '' as string | number, funIndex = 0; + + while (source != null && index < paths.length) { + const token = paths[index++]; + + switch (true) { + case token === "..": + case token === ".": + break; + case token === "?": + const fun = fns[funIndex++]; + if (fun == null) + throw new Error(`missing function for ${lookbehind}`); + source = filterOrMap(source, fun); + break; + case typeof token === 'number': + source = source[token]; + break; + default: + source = getValue(source, token as string, lookbehind === ".."); + } + + lookbehind = token; + } + + return source; +} \ No newline at end of file diff --git a/packages/bruno-query/tests/index.spec.ts b/packages/bruno-query/tests/index.spec.ts new file mode 100644 index 000000000..10ef027a8 --- /dev/null +++ b/packages/bruno-query/tests/index.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from '@jest/globals'; + +import { get } from '../src/index'; + +const data = { + customer: { + address: { + city: "bangalore" + }, + orders: [ + { + id: "order-1", + items: [ + { id: 1, amount: 10 }, + { id: 2, amount: 20 }, + ] + }, + { + id: "order-2", + items: [ + { id: 3, amount: 30, }, + { id: 4, amount: 40 } + ] + } + ] + }, +}; + +describe("get", () => { + it.each([ + ["customer.address.city", "bangalore"], + ["customer.orders.items.amount", [10, 20, 30, 40]], + ["customer.orders.items.amount[0]", 10], + ["..items.amount", [10, 20, 30, 40]], + ["..amount", [10, 20, 30, 40]], + ["..items.amount[0]", 10], + ["..items[0].amount", 10], + ["..items[5].amount", undefined], // invalid index + ["..id", ["order-1", 1, 2, "order-2", 3, 4]], // all ids + ["customer.orders.foo", undefined], + ["..customer.foo", undefined], + ["..address", [{ city: "bangalore" }]], // .. will return array + ["..address[0]", { city: "bangalore" }], + ])("%s should be %j", (expr, result) => { + expect(get(data, expr)).toEqual(result); + }); + + // filter and map + it.each([ + ["..items[?].amount", [40], (i: any) => i.amount > 30], // [?] filter + ["..items..amount[?][0]", 40, (amt: number) => amt > 30], + ["..items..amount[0][?]", undefined, (amt: number) => amt > 30], // filter on single value + ["..items..amount[?]", [11, 21, 31, 41], (amt: number) => amt + 1], // [?] mapper + ["..items..amount[0][?]", 11, (amt: number) => amt + 1], // [?] map on single value + ])("%s should be %j %s", (expr, result, filter) => { + expect(get(data, expr, filter)).toEqual(result); + }); + +}); diff --git a/packages/bruno-query/tsconfig.json b/packages/bruno-query/tsconfig.json new file mode 100644 index 000000000..96998f7a1 --- /dev/null +++ b/packages/bruno-query/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES6", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "jsx": "react", + "module": "ESNext", + "declaration": true, + "declarationDir": "types", + "sourceMap": true, + "outDir": "dist", + "moduleResolution": "node", + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": [ + "dist", + "node_modules", + "tests" + ], +} \ No newline at end of file