Skip to content

Commit cd01ecf

Browse files
authored
feat(jmespath): add Expression and utils (#2212)
1 parent f98ef27 commit cd01ecf

File tree

3 files changed

+372
-0
lines changed

3 files changed

+372
-0
lines changed

packages/jmespath/src/Expression.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { TreeInterpreter } from './TreeInterpreter.js';
2+
import type { JSONObject, Node } from './types.js';
3+
4+
/**
5+
* Apply a JMESPath expression to a JSON value.
6+
*/
7+
class Expression {
8+
readonly #expression: Node;
9+
readonly #interpreter: TreeInterpreter;
10+
11+
public constructor(expression: Node, interpreter: TreeInterpreter) {
12+
this.#expression = expression;
13+
this.#interpreter = interpreter;
14+
}
15+
16+
/**
17+
* Evaluate the expression against a JSON value.
18+
*
19+
* @param value The JSON value to apply the expression to.
20+
* @param node The node to visit.
21+
* @returns The result of applying the expression to the value.
22+
*/
23+
public visit(value: JSONObject, node?: Node): JSONObject {
24+
return this.#interpreter.visit(node ?? this.#expression, value);
25+
}
26+
}
27+
28+
export { Expression };
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Node, JSONObject } from './types.js';
2+
3+
// This is a placeholder for the real class. The actual implementation will be added in a subsequent PR.
4+
export class TreeInterpreter {
5+
public iAmAPlaceholder = true;
6+
7+
public visit(_node: Node, _value: JSONObject): JSONObject | null {
8+
return this.iAmAPlaceholder;
9+
}
10+
}

packages/jmespath/src/utils.ts

+334
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import {
2+
getType,
3+
isIntegerNumber,
4+
isRecord,
5+
isTruthy as isTruthyJS,
6+
isNumber,
7+
} from '@aws-lambda-powertools/commons/typeutils';
8+
import { Expression } from './Expression.js';
9+
import { ArityError, JMESPathTypeError, VariadicArityError } from './errors.js';
10+
11+
/**
12+
* Check if a value is truthy.
13+
*
14+
* In JavaScript, zero is falsy while all other non-zero numbers are truthy.
15+
* In JMESPath however, zero is truthy as well as all other non-zero numbers. For
16+
* this reason we wrap the original isTruthy function from the commons package
17+
* and add a check for numbers.
18+
*
19+
* @param value The value to check
20+
*/
21+
const isTruthy = (value: unknown): boolean => {
22+
if (isNumber(value)) {
23+
return true;
24+
} else {
25+
return isTruthyJS(value);
26+
}
27+
};
28+
29+
/**
30+
* @internal
31+
* Cap a slice range value to the length of an array, taking into account
32+
* negative values and whether the step is negative.
33+
*
34+
* @param arrayLength The length of the array
35+
* @param value The value to cap
36+
* @param isStepNegative Whether the step is negative
37+
*/
38+
const capSliceRange = (
39+
arrayLength: number,
40+
value: number,
41+
isStepNegative: boolean
42+
): number => {
43+
if (value < 0) {
44+
value += arrayLength;
45+
if (value < 0) {
46+
value = isStepNegative ? -1 : 0;
47+
}
48+
} else if (value >= arrayLength) {
49+
value = isStepNegative ? arrayLength - 1 : arrayLength;
50+
}
51+
52+
return value;
53+
};
54+
55+
/**
56+
* Given a start, stop, and step value, the sub elements in an array are extracted as follows:
57+
* * The first element in the extracted array is the index denoted by start.
58+
* * The last element in the extracted array is the index denoted by end - 1.
59+
* * The step value determines how many indices to skip after each element is selected from the array. An array of 1 (the default step) will not skip any indices. A step value of 2 will skip every other index while extracting elements from an array. A step value of -1 will extract values in reverse order from the array.
60+
*
61+
* Slice expressions adhere to the following rules:
62+
* * If a negative start position is given, it is calculated as the total length of the array plus the given start position.
63+
* * If no start position is given, it is assumed to be 0 if the given step is greater than 0 or the end of the array if the given step is less than 0.
64+
* * If a negative stop position is given, it is calculated as the total length of the array plus the given stop position.
65+
* * If no stop position is given, it is assumed to be the length of the array if the given step is greater than 0 or 0 if the given step is less than 0.
66+
* * If the given step is omitted, it it assumed to be 1.
67+
* * If the given step is 0, an invalid-value error MUST be raised (thrown before calling the function)
68+
* * If the element being sliced is not an array, the result is null (returned before calling the function)
69+
* * If the element being sliced is an array and yields no results, the result MUST be an empty array.
70+
*
71+
* @param array The array to slice
72+
* @param start The start index
73+
* @param end The end index
74+
* @param step The step value
75+
*/
76+
const sliceArray = <T>({
77+
array,
78+
start,
79+
end,
80+
step,
81+
}: {
82+
array: T[];
83+
start?: number;
84+
end?: number;
85+
step: number;
86+
}): T[] | null => {
87+
const isStepNegative = step < 0;
88+
const length = array.length;
89+
const defaultStart = isStepNegative ? length - 1 : 0;
90+
const defaultEnd = isStepNegative ? -1 : length;
91+
92+
start = isIntegerNumber(start)
93+
? capSliceRange(length, start, isStepNegative)
94+
: defaultStart;
95+
96+
end = isIntegerNumber(end)
97+
? capSliceRange(length, end, isStepNegative)
98+
: defaultEnd;
99+
100+
const result: T[] = [];
101+
if (step > 0) {
102+
for (let i = start; i < end; i += step) {
103+
result.push(array[i]);
104+
}
105+
} else {
106+
for (let i = start; i > end; i += step) {
107+
result.push(array[i]);
108+
}
109+
}
110+
111+
return result;
112+
};
113+
114+
/**
115+
* Checks if the number of arguments passed to a function matches the expected arity.
116+
* If the number of arguments does not match the expected arity, an ArityError is thrown.
117+
*
118+
* If the function is variadic, then the number of arguments passed to the function must be
119+
* greater than or equal to the expected arity. If the number of arguments passed to the function
120+
* is less than the expected arity, a `VariadicArityError` is thrown.
121+
*
122+
* @param args The arguments passed to the function
123+
* @param argumentsSpecs The expected types for each argument
124+
* @param decoratedFuncName The name of the function being called
125+
* @param variadic Whether the function is variadic
126+
*/
127+
const arityCheck = (
128+
args: unknown[],
129+
argumentsSpecs: Array<Array<string>>,
130+
variadic?: boolean
131+
): void => {
132+
if (variadic) {
133+
if (args.length < argumentsSpecs.length) {
134+
throw new VariadicArityError({
135+
expectedArity: argumentsSpecs.length,
136+
actualArity: args.length,
137+
});
138+
}
139+
} else if (args.length !== argumentsSpecs.length) {
140+
throw new ArityError({
141+
expectedArity: argumentsSpecs.length,
142+
actualArity: args.length,
143+
});
144+
}
145+
};
146+
147+
/**
148+
* Type checks the arguments passed to a function against the expected types.
149+
*
150+
* Type checking at runtime involves checking the top level type,
151+
* and in the case of arrays, potentially checking the types of
152+
* the elements in the array.
153+
*
154+
* If the list of types includes 'any', then the type check is a
155+
* no-op.
156+
*
157+
* If the list of types includes more than one type, then the
158+
* argument is checked against each type in the list. If the
159+
* argument matches any of the types, then the type check
160+
* passes. If the argument does not match any of the types, then
161+
* a JMESPathTypeError is thrown.
162+
*
163+
* @param args The arguments passed to the function
164+
* @param argumentsSpecs The expected types for each argument
165+
*/
166+
const typeCheck = (
167+
args: unknown[],
168+
argumentsSpecs: Array<Array<string>>
169+
): void => {
170+
for (const [index, argumentSpec] of argumentsSpecs.entries()) {
171+
if (argumentSpec[0] === 'any') continue;
172+
typeCheckArgument(args[index], argumentSpec);
173+
}
174+
};
175+
176+
/**
177+
* Type checks an argument against a list of types.
178+
*
179+
* If the list of types includes more than one type, then the
180+
* argument is checked against each type in the list. If the
181+
* argument matches any of the types, then the type check
182+
* passes. If the argument does not match any of the types, then
183+
* a JMESPathTypeError is thrown.
184+
*
185+
* @param arg
186+
* @param argumentSpec
187+
*/
188+
const typeCheckArgument = (arg: unknown, argumentSpec: Array<string>): void => {
189+
let valid = false;
190+
argumentSpec.forEach((type, index) => {
191+
if (valid) return;
192+
valid = checkIfArgumentTypeIsValid(arg, type, index, argumentSpec);
193+
});
194+
};
195+
196+
/**
197+
* Check if the argument is of the expected type.
198+
*
199+
* @param arg The argument to check
200+
* @param type The expected type
201+
* @param index The index of the type we are checking
202+
* @param argumentSpec The list of types to check against
203+
*/
204+
const checkIfArgumentTypeIsValid = (
205+
arg: unknown,
206+
type: string,
207+
index: number,
208+
argumentSpec: string[]
209+
): boolean => {
210+
const hasMoreTypesToCheck = index < argumentSpec.length - 1;
211+
if (type.startsWith('array')) {
212+
if (!Array.isArray(arg)) {
213+
if (hasMoreTypesToCheck) {
214+
return false;
215+
}
216+
throw new JMESPathTypeError({
217+
currentValue: arg,
218+
expectedTypes: argumentSpec,
219+
actualType: getType(arg),
220+
});
221+
}
222+
checkComplexArrayType(arg, type, hasMoreTypesToCheck);
223+
224+
return true;
225+
}
226+
if (type === 'expression') {
227+
checkExpressionType(arg, argumentSpec, hasMoreTypesToCheck);
228+
229+
return true;
230+
} else if (['string', 'number', 'boolean'].includes(type)) {
231+
typeCheckType(arg, type, argumentSpec, hasMoreTypesToCheck);
232+
if (typeof arg === type) return true;
233+
} else if (type === 'object') {
234+
checkObjectType(arg, argumentSpec, hasMoreTypesToCheck);
235+
236+
return true;
237+
}
238+
239+
return false;
240+
};
241+
242+
/**
243+
* Check if the argument is of the expected type.
244+
*
245+
* @param arg The argument to check
246+
* @param type The type to check against
247+
* @param argumentSpec The list of types to check against
248+
* @param hasMoreTypesToCheck Whether there are more types to check
249+
*/
250+
const typeCheckType = (
251+
arg: unknown,
252+
type: string,
253+
argumentSpec: string[],
254+
hasMoreTypesToCheck: boolean
255+
): void => {
256+
if (typeof arg !== type && !hasMoreTypesToCheck) {
257+
throw new JMESPathTypeError({
258+
currentValue: arg,
259+
expectedTypes: argumentSpec,
260+
actualType: getType(arg),
261+
});
262+
}
263+
};
264+
265+
/**
266+
* Check if the argument is an array of complex types.
267+
*
268+
* @param arg The argument to check
269+
* @param type The type to check against
270+
* @param hasMoreTypesToCheck Whether there are more types to check
271+
*/
272+
const checkComplexArrayType = (
273+
arg: unknown[],
274+
type: string,
275+
hasMoreTypesToCheck: boolean
276+
): void => {
277+
if (!type.includes('-')) return;
278+
const arrayItemsType = type.slice(6);
279+
let actualType: string | undefined;
280+
for (const element of arg) {
281+
try {
282+
typeCheckArgument(element, [arrayItemsType]);
283+
actualType = arrayItemsType;
284+
} catch (error) {
285+
if (!hasMoreTypesToCheck || actualType !== undefined) {
286+
throw error;
287+
}
288+
}
289+
}
290+
};
291+
292+
/**
293+
* Check if the argument is an expression.
294+
*
295+
* @param arg The argument to check
296+
* @param type The type to check against
297+
* @param hasMoreTypesToCheck Whether there are more types to check
298+
*/
299+
const checkExpressionType = (
300+
arg: unknown,
301+
type: string[],
302+
hasMoreTypesToCheck: boolean
303+
): void => {
304+
if (!(arg instanceof Expression) && !hasMoreTypesToCheck) {
305+
throw new JMESPathTypeError({
306+
currentValue: arg,
307+
expectedTypes: type,
308+
actualType: getType(arg),
309+
});
310+
}
311+
};
312+
313+
/**
314+
* Check if the argument is an object.
315+
*
316+
* @param arg The argument to check
317+
* @param type The type to check against
318+
* @param hasMoreTypesToCheck Whether there are more types to check
319+
*/
320+
const checkObjectType = (
321+
arg: unknown,
322+
type: string[],
323+
hasMoreTypesToCheck: boolean
324+
): void => {
325+
if (!isRecord(arg) && !hasMoreTypesToCheck) {
326+
throw new JMESPathTypeError({
327+
currentValue: arg,
328+
expectedTypes: type,
329+
actualType: getType(arg),
330+
});
331+
}
332+
};
333+
334+
export { isTruthy, arityCheck, sliceArray, typeCheck, typeCheckArgument };

0 commit comments

Comments
 (0)