mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Evaluate primitive reads off $now and $today natively (no-changelog) (#40945)
This commit is contained in:
parent
b97fe7c7d4
commit
de090cb2a0
|
|
@ -1,5 +1,7 @@
|
|||
import { ExpressionExtensionError } from '../../errors/expression-extension.error';
|
||||
import { ExpressionError } from '../../errors/expression.error';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import type { IWorkflowDataProxyData } from '../../interfaces';
|
||||
import {
|
||||
ARRAY_METHODS,
|
||||
|
|
@ -10,6 +12,8 @@ import {
|
|||
MAX_WORK,
|
||||
NUMBER_METHODS,
|
||||
STRING_METHODS,
|
||||
TIME_MEMBERS,
|
||||
TIME_METHODS,
|
||||
hasOwn,
|
||||
isArray,
|
||||
isObj,
|
||||
|
|
@ -118,6 +122,10 @@ const binaryOps: Record<BinaryOp, (l: any, r: any) => unknown> = Object.freeze({
|
|||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
function evalMember(node: Extract<SimpleNode, { kind: 'member' }>, env: Env): unknown {
|
||||
if (node.object.kind === 'timeRoot') {
|
||||
return evalTimeRead(env.data[node.object.name], TIME_MEMBERS.get(toStr(node.key)));
|
||||
}
|
||||
|
||||
const object = evalNode(node.object, env);
|
||||
|
||||
if (node.optional && (object === null || object === undefined)) {
|
||||
|
|
@ -358,6 +366,10 @@ function evalCall(node: Extract<SimpleNode, { kind: 'call' }>, env: Env): unknow
|
|||
return evalNodeRefCall(receiver, node.method);
|
||||
}
|
||||
|
||||
if (node.receiver.kind === 'timeRoot') {
|
||||
return evalTimeRead(receiver, TIME_METHODS.get(node.method));
|
||||
}
|
||||
|
||||
// An own property shadowing the method (an engine-resolved $parameter value
|
||||
// can carry one) would take precedence in the engines.
|
||||
if (hasOwn(receiver, node.method)) {
|
||||
|
|
@ -429,6 +441,21 @@ function evalNodeRefCall(proxy: unknown, method: string): unknown {
|
|||
return bounded(fn.call(proxy));
|
||||
}
|
||||
|
||||
/**
|
||||
* A captured Luxon getter or zero-argument method on `$now`/`$today`. The
|
||||
* root is the proxy's own DateTime (anything else is the engine's), and the
|
||||
* result is a primitive by allowlist; a non-primitive would mean a patched
|
||||
* prototype, which is also the engine's.
|
||||
*/
|
||||
function evalTimeRead(root: unknown, fn: NativeMethod | undefined): unknown {
|
||||
if (fn === undefined || !DateTime.isDateTime(root)) throw new EngineFallbackError();
|
||||
|
||||
const result = fn.call(root);
|
||||
if (!isPrimitive(result)) throw new EngineFallbackError();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function evalNodeRef(node: Extract<SimpleNode, { kind: 'nodeRef' }>, env: Env): unknown {
|
||||
if (node.ref === 'input') return env.data.$input;
|
||||
if (node.ref === 'legacy') return env.data.$node[node.name];
|
||||
|
|
@ -498,6 +525,8 @@ function evalNode(node: SimpleNode, env: Env): unknown {
|
|||
return env.data[node.name];
|
||||
case 'nodeRef':
|
||||
return evalNodeRef(node, env);
|
||||
case 'timeRoot':
|
||||
return env.data[node.name];
|
||||
case 'undefined':
|
||||
return undefined;
|
||||
case 'member':
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { namedTypes } from 'ast-types';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
// The closed subset grammar, its operator and method allowlists, and the
|
||||
// limits the parser and the evaluator share. See index.ts for the overview.
|
||||
|
|
@ -33,6 +34,7 @@ export type SimpleNode =
|
|||
| { kind: 'nodeRef'; ref: 'input' }
|
||||
| { kind: 'nodeRef'; ref: 'node'; name: string }
|
||||
| { kind: 'nodeRef'; ref: 'legacy'; name: string }
|
||||
| { kind: 'timeRoot'; name: TimeRoot }
|
||||
| { kind: 'undefined' }
|
||||
| { kind: 'member'; object: SimpleNode; key: string | number; optional: boolean }
|
||||
| { kind: 'chain'; expression: SimpleNode }
|
||||
|
|
@ -53,9 +55,9 @@ export type SimpleNode =
|
|||
|
||||
export type Literal = Extract<SimpleNode, { kind: 'literal' }>;
|
||||
|
||||
// Data roots: plain reads off the data proxy. `$now`/`$today` are excluded on
|
||||
// purpose: they are Luxon DateTimes whose methods would make every useful
|
||||
// expression on them a CallExpression anyway.
|
||||
// Data roots: plain reads off the data proxy. `$now`/`$today` are Luxon
|
||||
// DateTimes, which this grammar cannot hold as a value; they are time roots
|
||||
// below, readable only through an allowlisted getter or method.
|
||||
//
|
||||
// Note on $parameter: the proxy resolves nested `=` parameter values by
|
||||
// re-entering resolveSimpleParameterValue - a nested non-simple value simply
|
||||
|
|
@ -85,6 +87,18 @@ export const NODE_REF_MEMBERS = new Set(['item']);
|
|||
export const LEGACY_NODE_REF_MEMBERS = new Set(['json', 'binary']);
|
||||
export const NODE_REF_METHODS = new Set(['first', 'last', 'all']);
|
||||
|
||||
// Time roots: `$now` and `$today`, the proxy's Luxon DateTimes. Like a node
|
||||
// reference, a time root is only ever built as the object of an allowlisted
|
||||
// getter (TIME_MEMBERS) or the receiver of an allowlisted zero-argument
|
||||
// method (TIME_METHODS), each of which yields a primitive; the DateTime
|
||||
// itself never stands alone in the grammar. Both lists stay clear of Intl:
|
||||
// the in-isolate Luxon copy formats and localises differently, so
|
||||
// toFormat(), toLocaleString() and the locale-dependent getters are the
|
||||
// engine's. The proxy builds both roots with the workflow timezone as
|
||||
// Luxon's default zone, which is what the isolates set too.
|
||||
export const TIME_ROOTS = ['$now', '$today'] as const;
|
||||
export type TimeRoot = (typeof TIME_ROOTS)[number];
|
||||
|
||||
// Intrinsics are captured at import so a later patch to a prototype or a
|
||||
// global (a polyfill, a community package) does not change what the native
|
||||
// path runs; the isolates never see such a patch either.
|
||||
|
|
@ -96,6 +110,15 @@ export const clone = structuredClone;
|
|||
|
||||
export type NativeMethod = (this: unknown, ...args: unknown[]) => unknown;
|
||||
|
||||
const captureGetters = (proto: object, names: string[]): ReadonlyMap<string, NativeMethod> => {
|
||||
const getters = new Map<string, NativeMethod>();
|
||||
for (const name of names) {
|
||||
const getter = Object.getOwnPropertyDescriptor(proto, name)?.get;
|
||||
if (typeof getter === 'function') getters.set(name, getter as NativeMethod);
|
||||
}
|
||||
return getters;
|
||||
};
|
||||
|
||||
const captureMethods = (proto: object, names: string[]): ReadonlyMap<string, NativeMethod> => {
|
||||
const methods = new Map<string, NativeMethod>();
|
||||
for (const name of names) {
|
||||
|
|
@ -167,7 +190,49 @@ export const ITERATOR_NATIVES = captureMethods(Array.prototype, [...ITERATOR_MET
|
|||
// A callback parameter may not take one of these names: the body would then
|
||||
// read the parameter where this grammar reads the root. `undefined` is a
|
||||
// legal parameter name in JS; here it is a literal.
|
||||
export const RESERVED_NAMES = new Set<string>([...DATA_ROOTS, '$input', '$node', '$', 'undefined']);
|
||||
export const RESERVED_NAMES = new Set<string>([
|
||||
...DATA_ROOTS,
|
||||
...TIME_ROOTS,
|
||||
'$input',
|
||||
'$node',
|
||||
'$',
|
||||
'undefined',
|
||||
]);
|
||||
|
||||
// A test that mocks luxon leaves DateTime without a prototype; the lists are
|
||||
// then empty and every time root declines.
|
||||
const capturedProto: unknown = Reflect.get(DateTime ?? {}, 'prototype');
|
||||
const dateTimeProto: object =
|
||||
typeof capturedProto === 'object' && capturedProto !== null ? capturedProto : {};
|
||||
|
||||
export const TIME_MEMBERS = captureGetters(dateTimeProto, [
|
||||
'year',
|
||||
'month',
|
||||
'day',
|
||||
'hour',
|
||||
'minute',
|
||||
'second',
|
||||
'millisecond',
|
||||
'weekday',
|
||||
'weekNumber',
|
||||
'quarter',
|
||||
'daysInMonth',
|
||||
'offset',
|
||||
]);
|
||||
|
||||
// Zero-argument only: the one argument these take is an options object,
|
||||
// which the grammar has no literal for. toString() is toISO(). Like
|
||||
// CALLABLE_METHODS, the names stay disjoint from the expression extensions
|
||||
// (plus/minus/format are extensions in the engines, not Luxon's).
|
||||
export const TIME_METHODS = captureMethods(dateTimeProto, [
|
||||
'toMillis',
|
||||
'toSeconds',
|
||||
'toUnixInteger',
|
||||
'toISO',
|
||||
'toISODate',
|
||||
'toISOTime',
|
||||
'toString',
|
||||
]);
|
||||
|
||||
export const CALLABLE_METHODS = new Set([
|
||||
...STRING_METHODS.keys(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export {
|
|||
MAX_RESULT_LENGTH,
|
||||
MAX_STEPS,
|
||||
MAX_WORK,
|
||||
TIME_METHODS,
|
||||
} from './grammar';
|
||||
|
||||
// Fast native evaluation: an in-process interpreter for a closed subset of
|
||||
|
|
@ -21,6 +22,7 @@ export {
|
|||
// The subset is data path access on the data roots (`$json`, `$parameter`,
|
||||
// `$vars`, `$binary`, `$itemIndex`, `$runIndex`) and on node references
|
||||
// (`$('Name').item`, `$('Name').first()`, `$input.item`, `$node['Name'].json`),
|
||||
// primitive reads off the time roots (`$now.toMillis()`, `$today.year`),
|
||||
// literals (array literals of literals included), a fixed set of operators,
|
||||
// calls to a closed allowlist of native string/number/array methods, and the
|
||||
// five array iterators (some/every/find/filter/map) with a one-parameter
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import {
|
|||
NODE_REF_MEMBERS,
|
||||
NODE_REF_METHODS,
|
||||
RESERVED_NAMES,
|
||||
TIME_MEMBERS,
|
||||
TIME_METHODS,
|
||||
TIME_ROOTS,
|
||||
UNARY_OPS,
|
||||
isOneOf,
|
||||
type Literal,
|
||||
|
|
@ -141,6 +144,16 @@ function parseNodeRef(node: AstNode): NodeRef | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
type TimeRef = Extract<SimpleNode, { kind: 'timeRoot' }>;
|
||||
|
||||
// `$now` or `$today` in object/receiver position. As with parseNodeRef, only
|
||||
// the member and call parsers ask, so the DateTime never stands alone.
|
||||
function parseTimeRoot(node: AstNode): TimeRef | null {
|
||||
if (node.type !== 'Identifier' || !isOneOf(TIME_ROOTS, node.name)) return null;
|
||||
|
||||
return { kind: 'timeRoot', name: node.name };
|
||||
}
|
||||
|
||||
// Only static keys are representable. Dynamic keys ($json[$json.key]) need
|
||||
// the sandbox's PrototypeSanitizer, so they stay on the engine. String keys
|
||||
// are vetted at parse time (isSafeObjectProperty), which keeps prototype
|
||||
|
|
@ -160,6 +173,13 @@ function parseMember(node: namedTypes.MemberExpression, parse: ParseChild): Simp
|
|||
return { kind: 'member', object: ref, key, optional: node.optional === true };
|
||||
}
|
||||
|
||||
const time = parseTimeRoot(node.object);
|
||||
if (time !== null) {
|
||||
if (typeof key !== 'string' || !TIME_MEMBERS.has(key)) return null;
|
||||
|
||||
return { kind: 'member', object: time, key, optional: node.optional === true };
|
||||
}
|
||||
|
||||
const object = parse(node.object);
|
||||
if (object === null) return null;
|
||||
|
||||
|
|
@ -242,6 +262,16 @@ function parseCall(
|
|||
return { kind: 'call', receiver: ref, method: property.name, args: [], optional };
|
||||
}
|
||||
|
||||
// `$now.toMillis()` and friends: Luxon's own primitive-returning methods,
|
||||
// no arguments.
|
||||
const time = parseTimeRoot(callee.object);
|
||||
if (time !== null) {
|
||||
if (!TIME_METHODS.has(property.name) || node.arguments.length > 0) return null;
|
||||
|
||||
const optional = node.optional === true || callee.optional === true;
|
||||
return { kind: 'call', receiver: time, method: property.name, args: [], optional };
|
||||
}
|
||||
|
||||
const method = property.name;
|
||||
const isIterator = isOneOf(ITERATOR_METHODS, method);
|
||||
if (!isIterator && !CALLABLE_METHODS.has(method)) return null;
|
||||
|
|
|
|||
|
|
@ -258,6 +258,18 @@ const CANDIDATES = [
|
|||
'={{ $json.item.names?.map(n => n) }}',
|
||||
'={{ $json.item.names.map(n => $json.item.count) }}',
|
||||
'={{ $json.item.names.map(n => n.missing?.deep ?? "d") }}',
|
||||
// time roots, by getter and zero-argument method
|
||||
'={{ $now.toMillis() }}',
|
||||
'={{ $now.toSeconds() }}',
|
||||
'={{ $now.toUnixInteger() }}',
|
||||
'={{ $now.toISO() }}',
|
||||
'={{ $now.toISODate() }}',
|
||||
'={{ $now.toISOTime() }}',
|
||||
'={{ $now.toString() }}',
|
||||
'={{ $now?.toMillis() }}',
|
||||
'={{ $now.year }}',
|
||||
'={{ $now?.year }}',
|
||||
'={{ $today.toISODate() }}',
|
||||
// compound shapes
|
||||
'={{ $json.item.name.trim().toUpperCase() }}',
|
||||
'={{ $json.item.name.toUpperCase().length }}',
|
||||
|
|
|
|||
|
|
@ -330,9 +330,25 @@ export const DECLINED_CORPUS: string[] = [
|
|||
'={{ $input.first().json.item.names.first() }}',
|
||||
"={{ $('__proto__').item.json }}",
|
||||
'={{ $node.constructor.json }}',
|
||||
// Roots outside the data set.
|
||||
// Roots outside the data set, and the time roots outside their allowlists.
|
||||
'={{ $now }}',
|
||||
'={{ $today }}',
|
||||
'={{ [$now] }}',
|
||||
'={{ $json.item.count + $now }}',
|
||||
'={{ $now.toISO({ suppressMilliseconds: true }) }}',
|
||||
'={{ $now.toFormat("yyyy") }}',
|
||||
'={{ $now.toLocaleString() }}',
|
||||
'={{ $now.zoneName }}',
|
||||
'={{ $now.setZone("UTC").toISO() }}',
|
||||
'={{ $now.plus(1, "day").toISO() }}',
|
||||
'={{ $now.minus({ days: 1 }).toISO() }}',
|
||||
'={{ $now.isWeekend() }}',
|
||||
'={{ $now.format("yyyy") }}',
|
||||
'={{ $now.c.year }}',
|
||||
'={{ $now.constructor.name }}',
|
||||
'={{ $now["__proto__"].year }}',
|
||||
'={{ $json.item.names.map($now => $now.toMillis()) }}',
|
||||
'={{ $json.item.names.map($today => 1) }}',
|
||||
'={{ $env.HOME }}',
|
||||
'={{ $execution.id }}',
|
||||
'={{ $prevNode.name }}',
|
||||
|
|
@ -438,8 +454,43 @@ export const ENGINE_DECIDED_CORPUS: string[] = [
|
|||
'={{ $json.item.valueOf }}',
|
||||
];
|
||||
|
||||
// Handled natively, compared to the engine with a clock tolerance: each side
|
||||
// reads its own `DateTime.now()`.
|
||||
export const TIME_CORPUS: string[] = [
|
||||
'={{ $now.toMillis() }}',
|
||||
'={{ $now.toSeconds() }}',
|
||||
'={{ $now.toUnixInteger() }}',
|
||||
'={{ $now.toISO() }}',
|
||||
'={{ $now.toISODate() }}',
|
||||
'={{ $now.toISOTime() }}',
|
||||
'={{ $now.toString() }}',
|
||||
'={{ $now?.toMillis() }}',
|
||||
'={{ $now.toMillis?.() }}',
|
||||
'={{ $now.toMillis() - 60000 }}',
|
||||
'={{ $now.toISO().slice(0, 10) }}',
|
||||
'={{ $now.year }}',
|
||||
'={{ $now.month }}',
|
||||
'={{ $now.day }}',
|
||||
'={{ $now.hour }}',
|
||||
'={{ $now.weekday }}',
|
||||
'={{ $now.weekNumber }}',
|
||||
'={{ $now.quarter }}',
|
||||
'={{ $now.daysInMonth }}',
|
||||
'={{ $now.offset }}',
|
||||
'={{ $now?.year }}',
|
||||
'={{ $now["year"] }}',
|
||||
'={{ $today.toMillis() }}',
|
||||
'={{ $today.toISODate() }}',
|
||||
'={{ $today.toISO() }}',
|
||||
'={{ $today.year }}',
|
||||
'={{ $today.weekday > 5 ? "weekend" : "weekday" }}',
|
||||
'={{ $today.toMillis() < $now.toMillis() }}',
|
||||
'=since {{ $today.toISODate() }}',
|
||||
];
|
||||
|
||||
export const ALL_CORPORA: string[] = [
|
||||
...HANDLED_CORPUS,
|
||||
...TIME_CORPUS,
|
||||
...RUNTIME_BAILOUT_CORPUS,
|
||||
...DECLINED_CORPUS,
|
||||
...ERROR_CORPUS,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
import * as Helpers from './helpers';
|
||||
import { createRunExecutionData } from '../src';
|
||||
import type { IWorkflowDataProxyData } from '../src/interfaces';
|
||||
import { ExpressionExtensions } from '../src/extensions';
|
||||
import {
|
||||
evaluateNatively,
|
||||
isNativelyEvaluable,
|
||||
CALLABLE_METHODS,
|
||||
TIME_METHODS,
|
||||
ITERATOR_METHODS,
|
||||
MAX_RESULT_LENGTH,
|
||||
MAX_STEPS,
|
||||
|
|
@ -22,6 +24,7 @@ import {
|
|||
ERROR_CORPUS,
|
||||
HANDLED_CORPUS,
|
||||
RUNTIME_BAILOUT_CORPUS,
|
||||
TIME_CORPUS,
|
||||
} from './native-evaluation-corpus';
|
||||
|
||||
// The expression corpora live in native-evaluation-corpus.ts.
|
||||
|
|
@ -205,6 +208,54 @@ describe('Expression - fast native evaluation parity', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Each side reads its own clock, so the values are compared as instants
|
||||
// within a minute of each other; everything else must match exactly.
|
||||
describe('time root expressions match the engine result', () => {
|
||||
const asInstant = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const instant = DateTime.fromISO(value.replace(/^since /, ''));
|
||||
return instant.isValid ? instant.toMillis() : null;
|
||||
};
|
||||
|
||||
test.each(TIME_CORPUS)('%s', (expr) => {
|
||||
expect(isNativelyEvaluable(expr.slice(1))).toBe(true);
|
||||
expect(nativeOutcome(expr).handled).toBe(true);
|
||||
|
||||
const engineResult = evaluate(expr, false);
|
||||
const nativeResult = evaluate(expr, true);
|
||||
expect(typeof nativeResult).toBe(typeof engineResult);
|
||||
|
||||
const engineInstant = asInstant(engineResult);
|
||||
const nativeInstant = asInstant(nativeResult);
|
||||
if (engineInstant === null || nativeInstant === null) {
|
||||
expect(nativeResult).toStrictEqual(engineResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Milliseconds, seconds (toSeconds, toUnixInteger), or a calendar
|
||||
// getter, which must match exactly.
|
||||
const tolerance = engineInstant > 1e11 ? 60_000 : engineInstant > 1e8 ? 60 : 0;
|
||||
expect(Math.abs(nativeInstant - engineInstant)).toBeLessThanOrEqual(tolerance);
|
||||
});
|
||||
|
||||
test('a time root that is not a Luxon DateTime hands off to the engine', () => {
|
||||
const dataWith = (value: unknown) => ({ $now: value }) as unknown as IWorkflowDataProxyData;
|
||||
|
||||
expect(evaluateNatively('{{ $now.toMillis() }}', dataWith(new Date()))).toEqual({
|
||||
handled: false,
|
||||
});
|
||||
expect(evaluateNatively('{{ $now.year }}', dataWith({ year: 2026 }))).toEqual({
|
||||
handled: false,
|
||||
});
|
||||
expect(evaluateNatively('{{ $now.year }}', dataWith(DateTime.now()))).toEqual({
|
||||
handled: true,
|
||||
value: DateTime.now().year,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-simple expressions are declined', () => {
|
||||
test.each(DECLINED_CORPUS)('%s', (expr) => {
|
||||
expect(isNativelyEvaluable(expr.slice(1))).toBe(false);
|
||||
|
|
@ -598,7 +649,7 @@ describe('Expression - fast native evaluation parity', () => {
|
|||
const extensionNames = new Set(
|
||||
ExpressionExtensions.flatMap((extension) => Object.keys(extension.functions)),
|
||||
);
|
||||
for (const method of [...CALLABLE_METHODS, ...ITERATOR_METHODS]) {
|
||||
for (const method of [...CALLABLE_METHODS, ...ITERATOR_METHODS, ...TIME_METHODS.keys()]) {
|
||||
expect(extensionNames.has(method)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user