feat(query): bruno-query package

This commit is contained in:
Ajai Shankar 2023-02-19 22:48:34 -06:00
parent 209f30998e
commit 4fdfdaf2cb
9 changed files with 350 additions and 2 deletions

View File

@ -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",

22
packages/bruno-query/.gitignore vendored Normal file
View File

@ -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*

View File

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@ -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"
}
}

View File

@ -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
```

View File

@ -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()],
}
];

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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"
],
}