Skip to content

Commit fd2d03a

Browse files
authored
Merge pull request #645 from streamich/object-expressions
Object JSON Expression operators
2 parents 8360031 + 9eb2035 commit fd2d03a

File tree

9 files changed

+371
-7
lines changed

9 files changed

+371
-7
lines changed

src/json-expression/__tests__/codegen.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ const check = (
1818
operators: operatorsMap,
1919
});
2020
const fn = codegen.run().compile();
21-
// console.log(codegen.generate().js);
22-
// console.log(fn.toString());
2321
const result = fn({vars: new Vars(data)});
2422
expect(result).toStrictEqual(expected);
2523
};

src/json-expression/__tests__/jsonExpressionUnitTests.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,6 +1979,88 @@ export const jsonExpressionUnitTests = (
19791979
);
19801980
});
19811981
});
1982+
1983+
describe('o.set', () => {
1984+
test('can set an object property', () => {
1985+
check(['o.set', {}, 'foo', 'bar'], {foo: 'bar'});
1986+
});
1987+
1988+
test('can set two properties, one computed', () => {
1989+
const expression: Expr = ['o.set', {}, 'foo', 'bar', 'baz', ['+', ['$', ''], 3]];
1990+
check(
1991+
expression,
1992+
{
1993+
foo: 'bar',
1994+
baz: 5,
1995+
},
1996+
2,
1997+
);
1998+
});
1999+
2000+
test('can retrieve object from input', () => {
2001+
const expression: Expr = ['o.set', ['$', '/obj'], 'foo', 123];
2002+
check(
2003+
expression,
2004+
{
2005+
type: 'the-obj',
2006+
foo: 123,
2007+
},
2008+
{
2009+
obj: {
2010+
type: 'the-obj',
2011+
},
2012+
},
2013+
);
2014+
});
2015+
2016+
test('can compute prop from expression', () => {
2017+
const expression: Expr = ['o.set', {a: 'b'}, ['.', ['$', '/name'], '_test'], ['+', 5, 5]];
2018+
check(
2019+
expression,
2020+
{
2021+
a: 'b',
2022+
Mac_test: 10,
2023+
},
2024+
{
2025+
name: 'Mac',
2026+
},
2027+
);
2028+
});
2029+
2030+
test('cannot set __proto__ prop', () => {
2031+
const expression: Expr = ['o.set', {a: 'b'}, '__proto__', ['$', '/name']];
2032+
expect(() =>
2033+
check(
2034+
expression,
2035+
{
2036+
a: 'b',
2037+
__proto__: 'Mac',
2038+
},
2039+
{
2040+
name: 'Mac',
2041+
},
2042+
),
2043+
).toThrow(new Error('PROTO_KEY'));
2044+
});
2045+
});
2046+
2047+
describe('o.del', () => {
2048+
test('can delete an object property', () => {
2049+
check(['o.del', {foo: 'bar', baz: 'qux'}, 'foo', 'bar'], {baz: 'qux'});
2050+
});
2051+
2052+
test('object can be an expression', () => {
2053+
check(['o.del', ['$', ''], 'a', 'c', 'd'], {b: 2}, {a: 1, b: 2, c: 3});
2054+
});
2055+
2056+
test('prop can be an expression', () => {
2057+
check(['o.del', {a: 1, b: 2, c: 3}, ['$', '']], {a: 1, c: 3}, 'b');
2058+
});
2059+
2060+
test('object and prop can be an expression', () => {
2061+
check(['o.del', ['$', '/o'], ['$', '/p']], {a: 1, c: 3}, {o: {a: 1, b: 2, c: 3}, p: 'b'});
2062+
});
2063+
});
19822064
});
19832065

19842066
describe('Branching operators', () => {
@@ -2198,4 +2280,24 @@ export const jsonExpressionUnitTests = (
21982280
});
21992281
});
22002282
});
2283+
2284+
describe('JSON Patch operators', () => {
2285+
describe('jp.add', () => {
2286+
test('can set an object property', () => {
2287+
check(['jp.add', {}, '/foo', 'bar'], {foo: 'bar'});
2288+
});
2289+
2290+
test('can set two properties, one computed', () => {
2291+
const expression: Expr = ['jp.add', {}, '/foo', 'bar', '/baz', ['+', ['$', ''], 3]];
2292+
check(
2293+
expression,
2294+
{
2295+
foo: 'bar',
2296+
baz: 5,
2297+
},
2298+
2,
2299+
);
2300+
});
2301+
});
2302+
});
22012303
};

src/json-expression/codegen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export class JsonExpressionCodegen {
9191

9292
public compile() {
9393
const fn = this.codegen.compile();
94+
// console.log('fn', fn.toString());
9495
return (ctx: types.JsonExpressionExecutionContext) => {
9596
try {
9697
return fn(ctx);

src/json-expression/operators/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {objectOperators} from './object';
1111
import {branchingOperators} from './branching';
1212
import {inputOperators} from './input';
1313
import {bitwiseOperators} from './bitwise';
14+
import {patchOperators} from './patch';
1415

1516
export const operators = [
1617
...arithmeticOperators,
@@ -25,6 +26,7 @@ export const operators = [
2526
...branchingOperators,
2627
...inputOperators,
2728
...bitwiseOperators,
29+
...patchOperators,
2830
];
2931

3032
export const operatorsMap = operatorsToMap(operators);

src/json-expression/operators/object.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
import * as util from '../util';
2-
import {Expression, ExpressionResult} from '../codegen-steps';
2+
import {Expression, ExpressionResult, Literal} from '../codegen-steps';
33
import type * as types from '../types';
44

5+
const validateSetOperandCount = (count: number) => {
6+
if (count < 3) {
7+
throw new Error('Not enough operands for "o.set".');
8+
}
9+
if (count % 2 !== 0) {
10+
throw new Error('Invalid number of operands for "o.set" operand.');
11+
}
12+
};
13+
14+
const validateDelOperandCount = (count: number) => {
15+
if (count < 3) {
16+
throw new Error('Not enough operands for "o.del".');
17+
}
18+
};
19+
520
export const objectOperators: types.OperatorDefinition<any>[] = [
621
[
722
'keys',
@@ -47,4 +62,109 @@ export const objectOperators: types.OperatorDefinition<any>[] = [
4762
return new Expression(js);
4863
},
4964
] as types.OperatorDefinition<types.ExprEntries>,
65+
66+
[
67+
'o.set',
68+
[],
69+
-1,
70+
/**
71+
* Set one or more properties on an object.
72+
*
73+
* ```
74+
* ['o.set', {},
75+
* 'a', 1,
76+
* 'b', ['+', 2, 3],
77+
* ]
78+
* ```
79+
*
80+
* Results in:
81+
*
82+
* ```
83+
* {
84+
* a: 1,
85+
* b: 5,
86+
* }
87+
* ```
88+
*/
89+
(expr: types.ExprObjectSet, ctx) => {
90+
let i = 1;
91+
const length = expr.length;
92+
validateSetOperandCount(length);
93+
const doc = util.asObj(ctx.eval(expr[i++], ctx)) as Record<string, unknown>;
94+
while (i < length) {
95+
const prop = util.str(ctx.eval(expr[i++], ctx)) as string;
96+
if (prop === '__proto__') throw new Error('PROTO_KEY');
97+
const value = ctx.eval(expr[i++], ctx);
98+
doc[prop] = value;
99+
}
100+
return doc;
101+
},
102+
(ctx: types.OperatorCodegenCtx<types.ExprObjectSet>): ExpressionResult => {
103+
const length = ctx.operands.length;
104+
validateSetOperandCount(length + 1);
105+
let i = 0;
106+
let curr = ctx.operands[i++];
107+
if (curr instanceof Literal) {
108+
curr = new Literal(util.asObj(curr.val));
109+
} else if (curr instanceof Expression) {
110+
ctx.link(util.asObj, 'asObj');
111+
curr = new Expression(`asObj(${curr})`);
112+
}
113+
ctx.link(util.str, 'str');
114+
ctx.link(util.objSetRaw, 'objSetRaw');
115+
while (i < length) {
116+
let prop = ctx.operands[i++];
117+
if (prop instanceof Literal) {
118+
prop = new Literal(util.str(prop.val));
119+
} else if (prop instanceof Expression) {
120+
prop = new Expression(`str(${prop})`);
121+
}
122+
const value = ctx.operands[i++];
123+
curr = new Expression(`objSetRaw(${curr}, ${prop}, ${value})`);
124+
}
125+
return curr;
126+
},
127+
] as types.OperatorDefinition<types.ExprObjectSet>,
128+
129+
[
130+
'o.del',
131+
[],
132+
-1,
133+
/**
134+
* Delete one or more properties from an object.
135+
*
136+
* ```
137+
* ['o.del', {}, 'prop1', 'prop2']
138+
* ```
139+
*/
140+
(expr: types.ExprObjectSet, ctx) => {
141+
let i = 1;
142+
const length = expr.length;
143+
validateDelOperandCount(length);
144+
const doc = util.asObj(ctx.eval(expr[i++], ctx)) as Record<string, unknown>;
145+
while (i < length) {
146+
const prop = util.str(ctx.eval(expr[i++], ctx)) as string;
147+
delete doc[prop];
148+
}
149+
return doc;
150+
},
151+
(ctx: types.OperatorCodegenCtx<types.ExprObjectSet>): ExpressionResult => {
152+
const length = ctx.operands.length;
153+
validateDelOperandCount(length + 1);
154+
let i = 0;
155+
let curr = ctx.operands[i++];
156+
ctx.link(util.str, 'str');
157+
ctx.link(util.objDelRaw, 'objDelRaw');
158+
while (i < length) {
159+
let prop = ctx.operands[i++];
160+
if (prop instanceof Literal) {
161+
prop = new Literal(util.str(prop.val));
162+
} else if (prop instanceof Expression) {
163+
prop = new Expression(`str(${prop})`);
164+
}
165+
curr = new Expression(`objDelRaw(${curr}, ${prop})`);
166+
}
167+
return curr;
168+
},
169+
] as types.OperatorDefinition<types.ExprObjectSet>,
50170
];
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {Expression, ExpressionResult} from '../codegen-steps';
2+
import type * as types from '../types';
3+
import {Path, toPath} from '../../json-pointer';
4+
import {JavaScript, JavaScriptLinked, compileClosure} from '@jsonjoy.com/util/lib/codegen';
5+
import {$findRef} from '../../json-pointer/codegen/findRef';
6+
import {find} from '../../json-pointer/find';
7+
8+
const validateAddOperandCount = (count: number) => {
9+
if (count < 3) {
10+
throw new Error('Not enough operands for "jp.add" operand.');
11+
}
12+
if (count % 2 !== 0) {
13+
throw new Error('Invalid number of operands for "jp.add" operand.');
14+
}
15+
};
16+
17+
const validateAddPath = (path: unknown) => {
18+
if (typeof path !== 'string') {
19+
throw new Error('The "path" argument for "jp.add" must be a const string.');
20+
}
21+
};
22+
23+
type AddFn = (doc: unknown, value: unknown) => unknown;
24+
25+
export const $$add = (path: Path): JavaScriptLinked<AddFn> => {
26+
const find = $findRef(path);
27+
const js = /* js */ `
28+
(function(find, path){
29+
return function(doc, value){
30+
var f = find(doc);
31+
var obj = f.obj, key = f.key, val = f.val;
32+
if (!obj) doc = value;
33+
else if (typeof key === 'string') obj[key] = value;
34+
else {
35+
var length = obj.length;
36+
if (key < length) obj.splice(key, 0, value);
37+
else if (key > length) throw new Error('INVALID_INDEX');
38+
else obj.push(value);
39+
}
40+
return doc;
41+
};
42+
})`;
43+
44+
return {
45+
deps: [find] as unknown[],
46+
js: js as JavaScript<(...deps: unknown[]) => AddFn>,
47+
};
48+
};
49+
50+
export const $add = (path: Path): AddFn => compileClosure($$add(path));
51+
52+
export const patchOperators: types.OperatorDefinition<any>[] = [
53+
[
54+
'jp.add',
55+
[],
56+
-1,
57+
/**
58+
* Applies JSON Patch "add" operations to the input value.
59+
*
60+
* ```
61+
* ['add', {},
62+
* '/a', 1,
63+
* '/b', ['+', 2, 3],
64+
* ]
65+
* ```
66+
*
67+
* Results in:
68+
*
69+
* ```
70+
* {
71+
* a: 1,
72+
* b: 5,
73+
* }
74+
* ```
75+
*/
76+
(expr: types.JsonPatchAdd, ctx) => {
77+
let i = 1;
78+
const length = expr.length;
79+
validateAddOperandCount(length);
80+
let doc = ctx.eval(expr[i++], ctx);
81+
while (i < length) {
82+
const path = expr[i++];
83+
validateAddPath(path);
84+
const value = ctx.eval(expr[i++], ctx);
85+
const {obj, key} = find(doc, toPath(path));
86+
if (!obj) doc = value;
87+
else if (typeof key === 'string') (obj as any)[key] = value;
88+
else if (obj instanceof Array) {
89+
const length = obj.length;
90+
if ((key as number) < length) obj.splice(key as number, 0, value);
91+
else if ((key as number) > length) throw new Error('INVALID_INDEX');
92+
else obj.push(value);
93+
}
94+
}
95+
return doc;
96+
},
97+
(ctx: types.OperatorCodegenCtx<types.JsonPatchAdd>): ExpressionResult => {
98+
const expr = ctx.expr;
99+
const length = ctx.operands.length;
100+
validateAddOperandCount(length + 1);
101+
let i = 0;
102+
let curr = ctx.operands[i++];
103+
while (i < length) {
104+
const path = expr[1 + i++];
105+
validateAddPath(path);
106+
const value = ctx.operands[i++];
107+
const addCompiled = $add(toPath(path));
108+
const dAdd = ctx.link(addCompiled);
109+
curr = new Expression(`${dAdd}(${curr}, ${value})`);
110+
}
111+
return curr;
112+
},
113+
] as types.OperatorDefinition<types.JsonPatchAdd>,
114+
];

0 commit comments

Comments
 (0)