Merge pull request #91 from ajaishankar/feature/get-supercharged

res.get : deep object navigation and filtering
This commit is contained in:
Anoop M D 2023-02-20 19:35:10 +05:30 committed by GitHub
commit 9cfb54ee9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 391 additions and 8 deletions

View File

@ -15,6 +15,10 @@ jobs:
node-version: 16
- name: Install dependencies
run: npm i --legacy-peer-deps
- name: Test Package bruno-query
run: npm run test --workspace=packages/bruno-query
- name: Build Package bruno-query
run: npm run build --workspace=packages/bruno-query
- name: Test Package bruno-lang
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema

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",
@ -32,4 +36,4 @@
"overrides": {
"rollup": "3.2.5"
}
}
}

View File

@ -21,6 +21,7 @@
"lodash": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "3.3.4",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"@usebruno/query": "0.1.0"
}
}
}

View File

@ -1,4 +1,5 @@
const jsonQuery = require('json-query');
const { get } = require("@usebruno/query");
const JS_KEYWORDS = `
break case catch class const continue debugger default delete do
@ -60,9 +61,8 @@ const evaluateJsExpression = (expression, context) => {
};
const createResponseParser = (response = {}) => {
const res = (expr) => {
const output = jsonQuery(expr, { data: response.data });
return output ? output.value : null;
const res = (expr, ...fns) => {
return get(response.data, expr, ...fns);
};
res.status = response.status;
@ -70,6 +70,11 @@ const createResponseParser = (response = {}) => {
res.headers = response.headers;
res.body = response.data;
res.jq = (expr) => {
const output = jsonQuery(expr, { data: response.data });
return output ? output.value : null;
};
return res;
};

View File

@ -1,4 +1,5 @@
const { evaluateJsExpression, internalExpressionCache: cache } = require("../src/utils");
const { describe, it, expect } = require("@jest/globals");
const { evaluateJsExpression, internalExpressionCache: cache, createResponseParser } = require("../src/utils");
describe("utils", () => {
describe("expression evaluation", () => {
@ -112,4 +113,28 @@ describe("utils", () => {
expect(result).toBe(startTime);
});
});
describe("response parser", () => {
const res = createResponseParser({
status: 200,
data: {
order: {
items: [
{ id: 1, amount: 10 },
{ id: 2, amount: 20 }
]
}
}
});
it("should default to bruno query", () => {
const value = res("..items[?].amount[0]", i => i.amount > 10);
expect(value).toBe(20);
});
it("should allow json-query", () => {
const value = res.jq("order.items[amount > 10].amount");
expect(value).toBe(20);
});
});
});

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