Skip to content

Commit 77ce45c

Browse files
committed
feat: 🎸 add initial codebase
1 parent 24b848f commit 77ce45c

32 files changed

+10293
-8
lines changed

README.md

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,111 @@ data model and syntax.
66

77
JSON Expressions are JIT compiled to efficient machine code.
88

9-
Example `1 + 2` JSON Expression:
9+
JSON Expression is a simple JSON DSL, which allows to write expressions and
10+
evaluate expressions.
1011

11-
```json
12-
["+", 1, 2]
12+
For example, the following expression
13+
14+
```js
15+
['+', 1, 2]; // 1 + 2
16+
```
17+
18+
evaluates to 3.
19+
20+
21+
## Usage
22+
23+
`json-expression` library can immediately evaluate expressions or it can
24+
compile an efficient expression to a function, which will execute about
25+
an order of magnitude faster.
26+
27+
Evaluating expression immediately as-is.
28+
29+
```ts
30+
import {evaluate} from '@jsonjoy.com/json-expression';
31+
32+
const expression = ['+', 1, ['$', '/foo']];
33+
const data = {foo: 2};
34+
35+
evaluate(expression, {data}); // 3
36+
```
37+
38+
Pre-compiling expression to an optimized function.
39+
40+
```ts
41+
import {JsonExpressionCodegen} from '@jsonjoy.com/json-expression';
42+
43+
const expression = ['+', 1, ['$', '/foo']];
44+
const codegen = new JsonExpressionCodegen({expression});
45+
const fn = codegen.run().compile();
46+
const data = {foo: 2};
47+
48+
fn({data}); // 3
49+
```
50+
51+
52+
## Documentation
53+
54+
`json-expression` library supports few dozen operators, see full list in `Expr`
55+
type [here](./types.ts).
56+
57+
Parsing rules:
58+
59+
1. JSON Expression is a valid JSON value.
60+
2. All expressions are JSON arrays, which start with a string which specifies
61+
the operator and remaining array elements are operands. For example, the
62+
"get" operator fetches some value from supplied data using JSON
63+
Pointer:`["get", "/some/path"]`.
64+
3. All other values are treated as literals. Except for arrays, which need to
65+
be enclosed in square brackets. For example, to specify an empty array, you
66+
box your array in square brackets: `[[]]`. This evaluates to an empty array
67+
JSON value `[]`.
68+
69+
70+
## Use Cases
71+
72+
Consider you application receives a stream of JSON Cloud Events, like this:
73+
74+
```js
75+
{
76+
"specversion" : "1.0",
77+
"type" : "com.example.someevent",
78+
"source" : "/mycontext",
79+
"subject": null,
80+
"id" : "C234-1234-1234",
81+
"time" : "2018-04-05T17:31:00Z",
82+
"comexampleextension1" : "value",
83+
"comexampleothervalue" : 5,
84+
"datacontenttype" : "application/json",
85+
"data" : {
86+
"appinfoA" : "abc",
87+
"appinfoB" : 123,
88+
"appinfoC" : true
89+
}
90+
}
91+
```
92+
93+
You could write and compile a JSON Expression to efficiently filter out events
94+
you are interested in, for example your expression could look like this:
95+
96+
```js
97+
[
98+
'and',
99+
['==', ['$', '/specversion'], '1.0'],
100+
['starts', ['$', '/type'], 'com.example.'],
101+
['in', ['$', '/datacontenttype'], [['application/octet-stream', 'application/json']]],
102+
['==', ['$', '/data/appinfoA'], 'abc'],
103+
];
104+
```
105+
106+
107+
## Benchmark
108+
109+
```
110+
node benchmarks/json-expression/main.js
111+
json-joy/json-expression JsonExpressionCodegen x 14,557,786 ops/sec ±0.09% (100 runs sampled), 69 ns/op
112+
json-joy/json-expression JsonExpressionCodegen with codegen x 170,098 ops/sec ±0.13% (101 runs sampled), 5879 ns/op
113+
json-joy/json-expression evaluate x 864,956 ops/sec ±0.10% (101 runs sampled), 1156 ns/op
114+
json-logic-js x 821,799 ops/sec ±0.18% (99 runs sampled), 1217 ns/op
115+
Fastest is json-joy/json-expression JsonExpressionCodegen
13116
```

package.json

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@
4242
"lint:fix": "biome lint --apply ./src",
4343
"clean": "npx rimraf@6.0.1 lib typedocs coverage gh-pages yarn-error.log",
4444
"build": "tsc --project tsconfig.build.json --module commonjs --target es2020 --outDir lib",
45-
"test": "vitest ./src",
46-
"coverage": "vitest run --coverage",
45+
"jest": "jest",
46+
"test": "jest --maxWorkers 7",
47+
"test:all": "yarn lint && yarn test && yarn build:all && yarn test:cli:pointer && yarn test:cli:patch && yarn test:cli:pack && yarn demo:json-patch",
48+
"test:ci": "yarn jest --maxWorkers 3 --no-cache",
49+
"coverage": "yarn test --collectCoverage",
4750
"typedoc": "npx typedoc@0.25.13 --tsconfig tsconfig.build.json",
4851
"build:pages": "npx rimraf@6.0.1 gh-pages && mkdir -p gh-pages && cp -r typedocs/* gh-pages && cp -r coverage gh-pages/coverage",
4952
"deploy:pages": "gh-pages -d gh-pages",
@@ -53,16 +56,31 @@
5356
"tslib": "2"
5457
},
5558
"dependencies": {
59+
"@jsonjoy.com/json-pointer": "^1.0.0",
5660
"@jsonjoy.com/util": "^1.3.0"
5761
},
5862
"devDependencies": {
5963
"@biomejs/biome": "^1.9.3",
6064
"@types/benchmark": "^2.1.5",
61-
"@vitest/coverage-v8": "^2.1.2",
65+
"@types/jest": "^29.5.12",
6266
"benchmark": "^2.1.4",
6367
"config-galore": "^1.0.0",
68+
"jest": "^29.7.0",
69+
"ts-jest": "^29.1.2",
6470
"tslib": "^2.7.0",
65-
"typescript": "^5.6.2",
66-
"vitest": "^2.1.2"
71+
"typescript": "^5.6.2"
72+
},
73+
"jest": {
74+
"moduleFileExtensions": [
75+
"ts",
76+
"js"
77+
],
78+
"transform": {
79+
"^.+\\.ts$": "ts-jest"
80+
},
81+
"transformIgnorePatterns": [
82+
".*/node_modules/.*"
83+
],
84+
"testRegex": ".*/(__tests__|__jest__|demo)/.*\\.(test|spec)\\.ts$"
6785
}
6886
}

src/Vars.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {get} from '@jsonjoy.com/json-pointer/lib/get';
2+
import {toPath} from '@jsonjoy.com/json-pointer/lib/util';
3+
import {validateJsonPointer} from '@jsonjoy.com/json-pointer/lib/validate';
4+
5+
export class Vars {
6+
protected readonly vars: Map<string, unknown> = new Map();
7+
8+
constructor(public readonly env: unknown) {
9+
this.env = env;
10+
}
11+
12+
public get(name: string): unknown {
13+
if (!name) return this.env;
14+
return this.vars.get(name);
15+
}
16+
17+
public set(name: string, value: unknown): void {
18+
if (!name) throw new Error('Invalid varname.');
19+
this.vars.set(name, value);
20+
}
21+
22+
public has(name: string): boolean {
23+
if (!name) return true;
24+
return this.vars.has(name);
25+
}
26+
27+
public del(name: string): boolean {
28+
if (!name) throw new Error('Invalid varname.');
29+
return this.vars.delete(name);
30+
}
31+
32+
public find(name: string, pointer: string): unknown {
33+
const data = this.get(name);
34+
validateJsonPointer(pointer);
35+
const path = toPath(pointer);
36+
return get(data, path);
37+
}
38+
}

src/__bench__/main.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/* tslint:disable no-console */
2+
3+
// npx ts-node src/json-expression/__bench__/main.ts
4+
5+
import * as Benchmark from 'benchmark';
6+
import {JsonExpressionCodegen} from '../codegen';
7+
import {Expr} from '../types';
8+
import {evaluate} from '../evaluate';
9+
import {operatorsMap} from '../operators';
10+
import {Vars} from '../Vars';
11+
const jsonLogic = require('json-logic-js');
12+
13+
const json = {
14+
specversion: '1.0',
15+
type: 'com.example.someevent',
16+
source: '/mycontext',
17+
subject: null,
18+
id: 'C234-1234-1234',
19+
time: '2018-04-05T17:31:00Z',
20+
comexampleextension1: 'value',
21+
comexampleothervalue: 5,
22+
datacontenttype: 'application/json',
23+
data: {
24+
appinfoA: 'abc',
25+
appinfoB: 123,
26+
appinfoC: true,
27+
},
28+
};
29+
30+
const expression: Expr = [
31+
'and',
32+
['==', ['get', '/specversion'], '1.0'],
33+
['starts', ['get', '/type'], 'com.example.'],
34+
['in', ['get', '/datacontenttype'], [['application/octet-stream', 'application/json']]],
35+
['==', ['$', '/data/appinfoA'], 'abc'],
36+
];
37+
38+
const jsonLogicExpression = {
39+
and: [
40+
{'==': [{var: 'specversion'}, '1.0']},
41+
{'==': [{substr: [{var: 'type'}, 0, 12]}, 'com.example.']},
42+
{in: [{var: 'datacontenttype'}, ['application/octet-stream', 'application/json']]},
43+
{'==': [{var: 'data.appinfoA'}, 'abc']},
44+
],
45+
};
46+
47+
const codegen = new JsonExpressionCodegen({expression, operators: operatorsMap});
48+
const fn = codegen.run().compile();
49+
50+
const suite = new Benchmark.Suite();
51+
suite
52+
.add(`json-joy/json-expression JsonExpressionCodegen`, () => {
53+
fn({vars: new Vars(json)});
54+
})
55+
.add(`json-joy/json-expression JsonExpressionCodegen with codegen`, () => {
56+
const codegen = new JsonExpressionCodegen({expression, operators: operatorsMap});
57+
const fn = codegen.run().compile();
58+
fn({vars: new Vars(json)});
59+
})
60+
.add(`json-joy/json-expression evaluate`, () => {
61+
evaluate(expression, {vars: new Vars(json)});
62+
})
63+
.add(`json-logic-js`, () => {
64+
jsonLogic.apply(jsonLogicExpression, json);
65+
})
66+
.on('cycle', (event: any) => {
67+
console.log(String(event.target) + `, ${Math.round(1000000000 / event.target.hz)} ns/op`);
68+
})
69+
.on('complete', () => {
70+
console.log('Fastest is ' + suite.filter('fastest').map('name'));
71+
})
72+
.run();

src/__tests__/codegen.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {Vars} from '../Vars';
2+
import {JsonExpressionCodegen} from '../codegen';
3+
import {operatorsMap} from '../operators';
4+
import {Expr, JsonExpressionCodegenContext} from '../types';
5+
import {jsonExpressionCodegenTests} from './jsonExpressionCodegenTests';
6+
import {jsonExpressionEvaluateTests} from './jsonExpressionEvaluateTests';
7+
import {jsonExpressionUnitTests} from './jsonExpressionUnitTests';
8+
9+
const check = (
10+
expression: Expr,
11+
expected: unknown,
12+
data: unknown = null,
13+
options: JsonExpressionCodegenContext = {},
14+
) => {
15+
const codegen = new JsonExpressionCodegen({
16+
...options,
17+
expression,
18+
operators: operatorsMap,
19+
});
20+
const fn = codegen.run().compile();
21+
const result = fn(new Vars(data));
22+
expect(result).toStrictEqual(expected);
23+
};
24+
25+
jsonExpressionUnitTests(check);
26+
jsonExpressionCodegenTests(check);
27+
jsonExpressionEvaluateTests(check);

src/__tests__/evaluate.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {Vars} from '../Vars';
2+
import {evaluate} from '../evaluate';
3+
import {Expr, JsonExpressionCodegenContext} from '../types';
4+
import {jsonExpressionCodegenTests} from './jsonExpressionCodegenTests';
5+
import {jsonExpressionEvaluateTests} from './jsonExpressionEvaluateTests';
6+
import {jsonExpressionUnitTests} from './jsonExpressionUnitTests';
7+
8+
const check = (
9+
expression: Expr,
10+
expected: unknown,
11+
data: unknown = null,
12+
options: JsonExpressionCodegenContext = {},
13+
) => {
14+
const res = evaluate(expression, {...options, vars: new Vars(data)});
15+
expect(res).toStrictEqual(expected);
16+
};
17+
18+
jsonExpressionUnitTests(check);
19+
jsonExpressionEvaluateTests(check);
20+
jsonExpressionCodegenTests(check, {skipOperandArityTests: true});

src/__tests__/impure.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {Vars} from '../Vars';
2+
import {JsonExpressionCodegen} from '../codegen';
3+
import {operatorsMap} from '../operators';
4+
import {Expr, JsonExpressionCodegenContext} from '../types';
5+
6+
const compile = (expression: Expr, options: JsonExpressionCodegenContext = {}) => {
7+
const codegen = new JsonExpressionCodegen({
8+
...options,
9+
expression,
10+
operators: operatorsMap,
11+
});
12+
const fn = codegen.run().compile();
13+
return (data: unknown) => fn(new Vars(data));
14+
};
15+
16+
test('can execute expression twice with different inputs', () => {
17+
const fn = compile(['+', 1, ['$', '']]);
18+
expect(fn(2)).toBe(3);
19+
expect(fn(3)).toBe(4);
20+
});
21+
22+
test('constant expression is collapsed', () => {
23+
const fn = compile(['+', 1, 2]);
24+
expect(fn(2)).toBe(3);
25+
expect(fn(3)).toBe(3);
26+
});
27+
28+
test('linked in dependencies are linked only once', () => {
29+
const fn = compile(['/', ['/', ['$', ''], 2], 3]);
30+
expect(fn(24)).toBe(4);
31+
// Check that "slash" function is linked only once.
32+
});

0 commit comments

Comments
 (0)