fix(core): Fix data transformation function that are reported not to work properly (#5338)

* 🔥 Remove test extensions
* 🚧 Add test description
* 📘 Expand types
*  Export extensions
*  Export collection
*  Mark all proxies
* ✏️ Rename for clarity
*  Export from barrel
*  Create datatype completions
*  Mount datatype completions
* 🧪 Adjust tests
*  Add `path` prop
* 🔥 Remove `()` from completion labels
*  Filter out completions for pseudo-proxies
* 🐛 Fix method error
*  Add metrics
* ✏️ Improve naming
*  Start completion on empty resolvable
*  Implement completion previews
*  Break out completion manager
*  Implement in expression editor modal
* ✏️ Improve naming
*  Filter out irrelevant completions
*  Add preview hint
* ✏️ Improve comments
* 🎨 Style preview hint
*  Expand `hasNoParams`
*  Add spacing for readability
*  Add error codes
* ✏️ Add comment
* 🐛 Fix Esc behavior
*  Parse Unicode
*  Throw on invalid `DateTime`
*  Fix second root completion detection
*  Switch message at completable prefix position
* 🐛 Fix function names for non-dev build
* 🐛 Fix `json` handling
* 🔥 Comment out previews
* ♻️ Apply feedback
* 🔥 Remove extensions
* 🚚 Rename extensions
*  Adjust some implementations
* 🔥 Remove dummy extensions
* 🐛 Fix object regex
* ♻️ Apply feedback
* ✏️ Fix typos
* ✏️ Add `fn is not a function` message
* 🔥 Remove check
*  Add `isNotEmpty` for objects
* 🚚 Rename `global` to `alpha`
* 🔥 Remove `encrypt`
*  Restore `is not a function` error
*  Support `week` on `extract()`
* 🧪 Fix tests
*  Add validation to some string extensions
*  Validate number arrays in some extensions
* 🧪 Fix tests
* ✏️ Improve error message
*  Revert extensions framework changes
* 🧹 Previews cleanup
*  Condense blank completions
*  Refactor dollar completions
*  Refactor non-dollar completions
*  Refactor Luxon completions
*  Refactor datatype completions
*  Use `DATETIMEUNIT_MAP`
* ✏️ Update test description
*  Revert "Use `DATETIMEUNIT_MAP`"
This reverts commit 472a77df5c.
* 🧪 Add tests
* ♻️ Restore generic extensions
* 🔥 Remove logs
* 🧪 Expand tests
*  Add `Math` completions
* ✏️ List breaking change
*  Add doc tooltips
* 🐛 Fix node selector regex
* 🐛 Fix `context` resolution
* 🐛 Allow dollar completions in args
*  Make numeric array methods context-dependent
* 📝 Adjust docs
* 🐛 Fix selector ref
*  Surface error for valid URL
* 🐛 Disallow whitespace in `isEmail` check
* 🧪 Fix test for `isUrl`
*  Add comma validator in `toFloat`
*  Add validation to `$jmespath()`
*  Revert valid URL error
*  Adjust `$jmespath()` validation
* 🧪 Adjust `isUrl` test
*  Remove `{}` and `[]` from compact
* ✏️ Update docs
* 🚚 Rename `stripTags` to `removeTags`
*  Do not inject whitespace inside resolvable
*  Make completions aware of `()`
* ✏️ Add note
*  Update sorting
*  Hide active node name from node selector
* 🔥 Remove `length()` and its aliases
*  Validate non-zero for `chunk`
* ✏️ Reword all error messages
* 🐛 Fix `$now` and `$today`
*  Simplify with `stripExcessParens`
*  Fold luxon into datatype
* 🧪 Clean up tests
* 🔥 Remove tests for removed methods
* 👕 Fix type
* ⬆️ Upgrade lang pack
*  Undo change to `vitest` command
* 🔥 Remove unused method
*  Separate `return` line
* ✏️ Improve description
* 🧪 Expand tests for initial-only completions
* 🧪 Add bracket-aware completions
*  Make check for `all()` stricter
* ✏️ Adjust explanatory comments
* 🔥 Remove unneded copy
* 🔥 Remove outdated comment
*  Make naming consistent
* ✏️ Update comments
*  Improve URL scheme check
* ✏️ Add comment
* 🚚 Move extension
* ✏️ Update `BREAKING-CHANGES.md`
* ✏️ Update upcoming version
* ✏️ Fix grammar
* ✏️ Shorten message
* 🐛 Fix `Esc` behavior
* 🐛 Fix `isNumeric`
*  Using UTC to handle-dates on back-end
*  Added more unit tests for date extensions
*  Not using `JSON.stringify` to render dates
*  Using `deep-equal` library instead of our `deepCompare` function
*  Adding more tests to array extensions
*  Fixing `inBetween` extension function
*  Added tests for `.inBetween()`
*  Updating `isEven` and `isOdd` to throw for floats
*  Updating `Array.merge()` so it works without arguments
* 🔀 Fixing leftover merge confilct
*  Updating `removeFieldsContaining` and `keepFieldsContaining` to throw on empty strings
*  Fixing `pluck()` so it returns only plucked values
* ⬆️ Updating pnpm lockfile
* 👕 Fixing lint errors
*  Using workflow timezone to display dates
* ✔️ Updating tests with workflow timezone
*  Not using system timezone when creating Luxon dates
*  Updating `merge()` and `pluck()` array functions
* 🔀 Sync with `master`: Removing code that was preserved during merge
*  Updating `.pluck()` to return full array if no arguments are passed
*  Updating `keepFieldsContaining` and `merge` object functions
*  Using week as default for `date.extract()`
*  Adding more test cases for DT functions
*  Removing `Object.merge` extension function. Adding missing `deep-equal` dependency
*  Handling `toDate` case when time component is not specified
*  Using workflow's timezone to render dates in output panel, updated unit tests after removing `Object.merge` function
*  Not parsing numbers as dates
* 👕 Fixing lint errors
*  Fixing a typo
*  Making date detection more strict so only stringified dates are getting converted
* 👌 Addressing PR feedback
* 🔥 Removing leftover comment
---------

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
Milorad FIlipović 2023-02-15 10:50:16 +01:00 committed by GitHub
parent 83505cb0d4
commit 0cf45bc4c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 372 additions and 224 deletions

View file

@ -73,13 +73,14 @@ import VueJsonPretty from 'vue-json-pretty';
import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import Draggable from '@/components/Draggable.vue';
import { convertPath, executionDataToJson, isString, shorten } from '@/utils';
import { parseDate, executionDataToJson, isString, shorten } from '@/utils';
import { INodeUi } from '@/Interface';
import { externalHooks } from '@/mixins/externalHooks';
import { mapStores } from 'pinia';
import { useNDVStore } from '@/stores/ndv';
import MappingPill from './MappingPill.vue';
import { getMappedExpression } from '@/utils/mappingUtils';
import { useWorkflowsStore } from '@/stores/workflows';
const runDataJsonActions = () => import('@/components/RunDataJsonActions.vue');
@ -149,7 +150,7 @@ export default mixins(externalHooks).extend({
}
},
computed: {
...mapStores(useNDVStore),
...mapStores(useNDVStore, useWorkflowsStore),
jsonData(): IDataObject[] {
return executionDataToJson(this.inputData);
},
@ -209,7 +210,11 @@ export default mixins(externalHooks).extend({
}, 1000); // ensure dest data gets set if drop
},
getContent(value: unknown): string {
return isString(value) ? `"${value}"` : JSON.stringify(value);
if (isString(value)) {
const parsedDate = parseDate(value, this.workflowsStore.workflow.settings?.timezone);
return parsedDate ? parsedDate.toString() : `"${value}"`;
}
return JSON.stringify(value);
},
getListItemName(path: string): string {
return path.replace(/^(\["?\d"?]\.?)/g, '');

View file

@ -159,7 +159,7 @@
<script lang="ts">
/* eslint-disable prefer-spread */
import { INodeUi, ITableData, NDVState } from '@/Interface';
import { getPairedItemId } from '@/utils';
import { getPairedItemId, parseDate } from '@/utils';
import Vue, { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
import { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
@ -171,6 +171,7 @@ import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv';
import MappingPill from './MappingPill.vue';
import { getMappedExpression } from '@/utils/mappingUtils';
import { DateTime } from 'luxon';
const MAX_COLUMNS_LIMIT = 40;
@ -365,6 +366,10 @@ export default mixins(externalHooks).extend({
return this.$locale.baseText('runData.emptyString');
}
if (typeof value === 'string') {
const parsedDate = parseDate(value, this.workflowsStore.workflow.settings?.timezone);
if (parsedDate) {
return parsedDate.toString();
}
return value.replaceAll('\n', '\\n');
}

View file

@ -3,6 +3,8 @@ import { IDataObject, jsonParse } from 'n8n-workflow';
import { Schema, Optional, Primitives } from '@/Interface';
import { isObj } from '@/utils/typeGuards';
import { generatePath } from '@/utils/mappingUtils';
import { DateTime } from 'luxon';
import { useWorkflowsStore } from '@/stores/workflows';
/*
Constants and utility functions than can be used to manipulate different data types and objects
@ -247,3 +249,17 @@ export const getSchema = (input: Optional<Primitives | object>, path = ''): Sche
return schema;
};
// Convert UTC dates that come from back-end to workflow timezone
export const parseDate = (input: string, timezone: string | undefined): DateTime | null => {
const isUTCDate = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/.test(
input,
);
if (isUTCDate) {
const date = new Date(Date.parse(input));
if (date.toString() !== 'Invalid Date') {
return DateTime.fromJSDate(date, { zone: timezone });
}
}
return null;
};

View file

@ -41,6 +41,7 @@
"devDependencies": {
"@n8n_io/eslint-config": "",
"@types/crypto-js": "^4.1.1",
"@types/deep-equal": "^1.0.1",
"@types/express": "^4.17.6",
"@types/jmespath": "^0.15.0",
"@types/lodash.get": "^4.4.6",
@ -54,6 +55,7 @@
"@n8n_io/riot-tmpl": "^2.0.0",
"ast-types": "0.15.2",
"crypto-js": "^4.1.1",
"deep-equal": "^2.2.0",
"esprima-next": "5.8.4",
"jmespath": "^0.16.0",
"js-base64": "^3.7.2",

View file

@ -60,11 +60,21 @@ export class Expression {
convertObjectValueToString(value: object): string {
const typeName = Array.isArray(value) ? 'Array' : 'Object';
if (DateTime.isDateTime(value) && value.invalidReason !== null) {
if (value instanceof DateTime && value.invalidReason !== null) {
throw new Error('invalid DateTime');
}
const result = JSON.stringify(value)
let result = '';
if (value instanceof Date) {
// We don't want to use JSON.stringify for dates since it disregards workflow timezone
result = DateTime.fromJSDate(value, {
zone: this.workflow.settings.timezone?.toString() ?? 'default',
}).toISO();
} else {
result = JSON.stringify(value);
}
result = result
.replace(/,"/g, ', "') // spacing for
.replace(/":/g, '": '); // readability

View file

@ -1,87 +1,7 @@
import { ExpressionError, ExpressionExtensionError } from '../ExpressionError';
import type { ExtensionMap } from './Extensions';
import { compact as oCompact, merge as oMerge } from './ObjectExtensions';
function deepCompare(left: unknown, right: unknown): boolean {
if (left === right) {
return true;
}
// Check to see if they're the basic type
if (typeof left !== typeof right) {
return false;
}
if (typeof left === 'number' && isNaN(left) && isNaN(right as number)) {
return true;
}
// Explicitly return false if certain primitives don't equal each other
if (['number', 'string', 'bigint', 'boolean', 'symbol'].includes(typeof left) && left !== right) {
return false;
}
// Quickly check how many properties each has to avoid checking obviously mismatching
// objects
if (Object.keys(left as object).length !== Object.keys(right as object).length) {
return false;
}
// Quickly check if they're arrays
if (Array.isArray(left) !== Array.isArray(right)) {
return false;
}
// Check if arrays are equal, ordering is important
if (Array.isArray(left)) {
if (left.length !== (right as unknown[]).length) {
return false;
}
return left.every((v, i) => deepCompare(v, (right as object[])[i]));
}
// Check right first quickly. This is to see if we have mismatched properties.
// We'll check the left more indepth later to cover all our bases.
for (const key in right as object) {
if ((left as object).hasOwnProperty(key) !== (right as object).hasOwnProperty(key)) {
return false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
} else if (typeof (left as any)[key] !== typeof (right as any)[key]) {
return false;
}
}
// Check left more in depth
for (const key in left as object) {
if ((left as object).hasOwnProperty(key) !== (right as object).hasOwnProperty(key)) {
return false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
} else if (typeof (left as any)[key] !== typeof (right as any)[key]) {
return false;
}
if (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
typeof (left as any)[key] === 'object'
) {
if (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(left as any)[key] !== (right as any)[key] &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
!deepCompare((left as any)[key], (right as any)[key])
) {
return false;
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
if ((left as any)[key] !== (right as any)[key]) {
return false;
}
}
}
return true;
}
import { compact as oCompact } from './ObjectExtensions';
import deepEqual from 'deep-equal';
function first(value: unknown[]): unknown {
return value[0];
@ -103,18 +23,26 @@ function pluck(value: unknown[], extraArgs: unknown[]): unknown[] {
if (!Array.isArray(extraArgs)) {
throw new ExpressionError('arguments must be passed to pluck');
}
const fieldsToPluck = extraArgs;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (value as any[]).map((element: object) => {
const entries = Object.entries(element);
return entries.reduce((p, c) => {
const [key, val] = c as [string, Date | string | number];
if (fieldsToPluck.includes(key)) {
Object.assign(p, { [key]: val });
if (!extraArgs || extraArgs.length === 0) {
return value;
}
const plucked = value.reduce<unknown[]>((pluckedFromObject, current) => {
if (current && typeof current === 'object') {
const p: unknown[] = [];
Object.keys(current).forEach((k) => {
extraArgs.forEach((field: string) => {
if (current && field === k) {
p.push((current as { [key: string]: unknown })[k]);
}
});
});
if (p.length > 0) {
pluckedFromObject.push(p.length === 1 ? p[0] : p);
}
return p;
}, {});
}) as unknown[];
}
return pluckedFromObject;
}, new Array<unknown>());
return plucked;
}
function randomItem(value: unknown[]): unknown {
@ -127,8 +55,13 @@ function unique(value: unknown[], extraArgs: string[]): unknown[] {
return value.reduce<unknown[]>((l, v) => {
if (typeof v === 'object' && v !== null && extraArgs.every((i) => i in v)) {
const alreadySeen = l.find((i) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
extraArgs.every((j) => deepCompare((i as any)[j], (v as any)[j])),
extraArgs.every((j) =>
deepEqual(
(i as Record<string, unknown>)[j],
(v as Record<string, unknown>, { strict: true })[j],
{ strict: true },
),
),
);
if (!alreadySeen) {
l.push(v);
@ -138,7 +71,7 @@ function unique(value: unknown[], extraArgs: string[]): unknown[] {
}, []);
}
return value.reduce<unknown[]>((l, v) => {
if (l.findIndex((i) => deepCompare(i, v)) === -1) {
if (l.findIndex((i) => deepEqual(i, v, { strict: true })) === -1) {
l.push(v);
}
return l;
@ -281,28 +214,58 @@ function renameKeys(value: unknown[], extraArgs: string[]): unknown[] {
});
}
function merge(value: unknown[], extraArgs: unknown[][]): unknown[] {
function mergeObjects(value: Record<string, unknown>, extraArgs: unknown[]): unknown {
const [other] = extraArgs;
if (!other) {
return value;
}
if (typeof other !== 'object') {
throw new ExpressionExtensionError('merge(): expected object arg');
}
const newObject = { ...value };
for (const [key, val] of Object.entries(other)) {
if (!(key in newObject)) {
newObject[key] = val;
}
}
return newObject;
}
function merge(value: unknown[], extraArgs: unknown[][]): unknown {
const [others] = extraArgs;
if (others === undefined) {
// If there are no arguments passed, merge all objects within the array
const merged = value.reduce((combined, current) => {
if (current !== null && typeof current === 'object' && !Array.isArray(current)) {
combined = mergeObjects(combined as Record<string, unknown>, [current]);
}
return combined;
}, {});
return merged;
}
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'merge(): expected array arg, e.g. .merge([{ id: 1, otherValue: 3 }])',
);
}
const listLength = value.length > others.length ? value.length : others.length;
const newList = new Array(listLength);
let merged = {};
for (let i = 0; i < listLength; i++) {
if (value[i] !== undefined) {
if (typeof value[i] === 'object' && typeof others[i] === 'object') {
newList[i] = oMerge(value[i] as object, [others[i]]);
} else {
newList[i] = value[i];
merged = Object.assign(
merged,
mergeObjects(value[i] as Record<string, unknown>, [others[i]]),
);
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
newList[i] = others[i];
}
}
return newList;
return merged;
}
function union(value: unknown[], extraArgs: unknown[][]): unknown[] {
@ -312,7 +275,8 @@ function union(value: unknown[], extraArgs: unknown[][]): unknown[] {
}
const newArr: unknown[] = Array.from(value);
for (const v of others) {
if (newArr.findIndex((w) => deepCompare(w, v)) === -1) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
if (newArr.findIndex((w) => deepEqual(w, v, { strict: true })) === -1) {
newArr.push(v);
}
}
@ -328,7 +292,7 @@ function difference(value: unknown[], extraArgs: unknown[][]): unknown[] {
}
const newArr: unknown[] = [];
for (const v of value) {
if (others.findIndex((w) => deepCompare(w, v)) === -1) {
if (others.findIndex((w) => deepEqual(w, v, { strict: true })) === -1) {
newArr.push(v);
}
}
@ -344,12 +308,13 @@ function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] {
}
const newArr: unknown[] = [];
for (const v of value) {
if (others.findIndex((w) => deepCompare(w, v)) !== -1) {
if (others.findIndex((w) => deepEqual(w, v, { strict: true })) !== -1) {
newArr.push(v);
}
}
for (const v of others) {
if (value.findIndex((w) => deepCompare(w, v)) !== -1) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
if (value.findIndex((w) => deepEqual(w, v, { strict: true })) !== -1) {
newArr.push(v);
}
}

View file

@ -1,8 +1,10 @@
import { ExpressionExtensionError } from './../ExpressionError';
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
import { DateTime } from 'luxon';
import type { DateTimeUnit, DurationLike, DurationObjectUnits, LocaleOptions } from 'luxon';
import type { ExtensionMap } from './Extensions';
import { convertToDateTime } from './utils';
type DurationUnit =
| 'milliseconds'
@ -82,11 +84,8 @@ function beginningOf(date: Date | DateTime, extraArgs: DurationUnit[]): Date {
if (isDateTime(date)) {
return date.startOf(DATETIMEUNIT_MAP[unit] || unit).toJSDate();
}
let datetime = DateTime.fromJSDate(date);
if (date.getTimezoneOffset() === 0) {
datetime = datetime.setZone('UTC');
}
return datetime.startOf(DATETIMEUNIT_MAP[unit] || unit).toJSDate();
const dateTime = DateTime.fromJSDate(date);
return dateTime.startOf(DATETIMEUNIT_MAP[unit] || unit).toJSDate();
}
function endOfMonth(date: Date | DateTime): Date {
@ -97,7 +96,7 @@ function endOfMonth(date: Date | DateTime): Date {
}
function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Date {
let [part] = extraArgs;
let [part = 'week'] = extraArgs;
let date = inputDate;
if (isDateTime(date)) {
date = date.toJSDate();
@ -126,10 +125,22 @@ function format(date: Date | DateTime, extraArgs: unknown[]): string {
return DateTime.fromJSDate(date).toFormat(dateFormat, { ...localeOpts });
}
function isBetween(date: Date | DateTime, extraArgs: unknown[]): boolean {
const [first, second] = extraArgs as string[];
const firstDate = new Date(first);
const secondDate = new Date(second);
function isBetween(
date: Date | DateTime,
extraArgs: Array<string | Date | DateTime>,
): boolean | undefined {
if (extraArgs.length !== 2) {
throw new ExpressionExtensionError('isBetween(): expected exactly two args');
}
const [first, second] = extraArgs;
const firstDate = convertToDateTime(first);
const secondDate = convertToDateTime(second);
if (!firstDate || !secondDate) {
return;
}
if (firstDate > secondDate) {
return secondDate < date && date < firstDate;

View file

@ -1,7 +1,7 @@
/**
* @jest-environment jsdom
*/
import { ExpressionExtensionError } from './../ExpressionError';
import type { ExtensionMap } from './Extensions';
function format(value: number, extraArgs: unknown[]): string {
@ -14,10 +14,16 @@ function format(value: number, extraArgs: unknown[]): string {
}
function isEven(value: number) {
if (!Number.isInteger(value)) {
throw new ExpressionExtensionError('isEven() is only callable on integers');
}
return value % 2 === 0;
}
function isOdd(value: number) {
if (!Number.isInteger(value)) {
throw new ExpressionExtensionError('isOdd() is only callable on integers');
}
return Math.abs(value) % 2 === 1;
}

View file

@ -1,22 +1,6 @@
import { ExpressionExtensionError } from '../ExpressionError';
import type { ExtensionMap } from './Extensions';
export function merge(value: object, extraArgs: unknown[]): unknown {
const [other] = extraArgs;
if (typeof other !== 'object' || !other) {
throw new ExpressionExtensionError('merge(): expected object arg');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObject: any = { ...value };
for (const [key, val] of Object.entries(other)) {
if (!(key in newObject)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
newObject[key] = val;
}
}
return newObject;
}
function isEmpty(value: object): boolean {
return Object.keys(value).length === 0;
}
@ -43,8 +27,8 @@ function removeField(value: object, extraArgs: string[]): object {
function removeFieldsContaining(value: object, extraArgs: string[]): object {
const [match] = extraArgs;
if (typeof match !== 'string') {
throw new ExpressionExtensionError('removeFieldsContaining(): expected string arg');
if (typeof match !== 'string' || match === '') {
throw new ExpressionExtensionError('removeFieldsContaining(): expected non-empty string arg');
}
const newObject = { ...value };
for (const [key, val] of Object.entries(value)) {
@ -58,12 +42,14 @@ function removeFieldsContaining(value: object, extraArgs: string[]): object {
function keepFieldsContaining(value: object, extraArgs: string[]): object {
const [match] = extraArgs;
if (typeof match !== 'string') {
throw new ExpressionExtensionError('argument of keepFieldsContaining must be a string');
if (typeof match !== 'string' || match === '') {
throw new ExpressionExtensionError(
'argument of keepFieldsContaining must be a non-empty string',
);
}
const newObject = { ...value };
for (const [key, val] of Object.entries(value)) {
if (typeof val === 'string' && !val.includes(match)) {
if (typeof val !== 'string' || (typeof val === 'string' && !val.includes(match))) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
delete (newObject as any)[key];
}
@ -120,11 +106,6 @@ urlEncode.doc = {
// @TODO_NEXT_PHASE: Surface extensions below which take args
merge.doc = {
name: 'merge',
returnType: 'object',
};
hasField.doc = {
name: 'hasField',
returnType: 'boolean',
@ -150,7 +131,6 @@ export const objectExtensions: ExtensionMap = {
functions: {
isEmpty,
isNotEmpty,
merge,
hasField,
removeField,
removeFieldsContaining,

View file

@ -113,12 +113,15 @@ function removeTags(value: string): string {
}
function toDate(value: string): Date {
const date = new Date(value.toString());
const date = new Date(Date.parse(value));
if (date.toString() === 'Invalid Date') {
throw new ExpressionError.ExpressionExtensionError('cannot convert to date');
}
// If time component is not specified, force 00:00h
if (!/:/.test(value)) {
date.setHours(0, 0, 0);
}
return date;
}

View file

@ -0,0 +1,19 @@
import { DateTime } from 'luxon';
// Utility functions and type guards for expression extensions
export const convertToDateTime = (value: string | Date | DateTime): DateTime | undefined => {
let converted: DateTime | undefined;
if (typeof value === 'string') {
converted = DateTime.fromJSDate(new Date(value));
if (converted.invalidReason !== null) {
return;
}
} else if (value instanceof Date) {
converted = DateTime.fromJSDate(value);
} else if (DateTime.isDateTime(value)) {
converted = value;
}
return converted;
};

View file

@ -22,16 +22,58 @@ describe('Data Transformation Functions', () => {
{ value: 3, string: '3' },
{ value: 4, string: '4' },
{ value: 5, string: '5' },
{ value: 6, string: '6' }
{ value: 6, string: '6' },
{ value: { something: 'else' } }
].pluck("value") }}`),
).toEqual(
expect.arrayContaining([1, 2, 3, 4, 5, 6, { something: 'else' }]),
);
});
test('.pluck() should work correctly for multiple values', () => {
expect(
evaluate(`={{ [
{
firstName: 'John',
lastName: 'Doe',
phone: {
home: '111-222',
office: '333-444'
}
},
{
firstName: 'Jane',
lastName: 'Doe',
phone: {
office: '555-666'
}
}
].pluck("firstName", "lastName") }}`),
).toEqual(
expect.arrayContaining([["John", "Doe"],["Jane", "Doe"]]),
);
});
test('.pluck() should work return everything with no args', () => {
expect(
evaluate(`={{ [
{ value: 1, string: '1' },
{ value: 2, string: '2' },
{ value: 3, string: '3' },
{ value: 4, string: '4' },
{ value: 5, string: '5' },
{ value: 6, string: '6' },
{ value: { something: 'else' } }
].pluck() }}`),
).toEqual(
expect.arrayContaining([
{ value: 1 },
{ value: 2 },
{ value: 3 },
{ value: 4 },
{ value: 5 },
{ value: 6 },
{ value: 1, string: '1' },
{ value: 2, string: '2' },
{ value: 3, string: '3' },
{ value: 4, string: '4' },
{ value: 5, string: '5' },
{ value: 6, string: '6' },
{ value: { something: 'else' } }
]),
);
});
@ -42,6 +84,14 @@ describe('Data Transformation Functions', () => {
);
});
test('.unique() should work on an arrays containing nulls, objects and arrays', () => {
expect(
evaluate(
'={{ [1, 2, 3, "as", {}, {}, 1, 2, [1,2], "[sad]", "[sad]", null].unique() }}',
),
).toEqual([1, 2, 3, "as", {}, [1,2], "[sad]", null]);
});
test('.isEmpty() should work correctly on an array', () => {
expect(evaluate('={{ [].isEmpty() }}')).toEqual(true);
});
@ -63,10 +113,15 @@ describe('Data Transformation Functions', () => {
evaluate(
'={{ [{ test1: 1, test2: 2 }, { test1: 1, test3: 3 }].merge([{ test1: 2, test3: 3 }, { test4: 4 }]) }}',
),
).toEqual([
{ test1: 1, test2: 2, test3: 3 },
{ test1: 1, test3: 3, test4: 4 },
]);
).toEqual({"test1": 1, "test2": 2, "test3": 3, "test4": 4});
});
test('.merge() should work correctly without arguments', () => {
expect(
evaluate(
'={{ [{ a: 1, some: null }, { a: 2, c: "something" }, 2, "asds", { b: 23 }, null, [1, 2]].merge() }}',
),
).toEqual({"a": 1, "some": null, "c": "something", "b": 23});
});
test('.smartJoin() should work correctly on an array of objects', () => {
@ -119,6 +174,14 @@ describe('Data Transformation Functions', () => {
).toEqual([{ test1: 1 }, { test2: 2 }, { test1: 1, test3: 3 }, { test4: 4 }]);
});
test('.union() should work on an arrays containing nulls, objects and arrays', () => {
expect(
evaluate(
'={{ [1, 2, "dd", {}, null].union([1, {}, null, 3]) }}',
),
).toEqual([1, 2, "dd", {}, null, 3]);
});
test('.intersection() should work on an array of objects', () => {
expect(
evaluate(
@ -127,6 +190,14 @@ describe('Data Transformation Functions', () => {
).toEqual([{ test2: 2 }]);
});
test('.intersection() should work on an arrays containing nulls, objects and arrays', () => {
expect(
evaluate(
'={{ [1, 2, "dd", {}, null].intersection([1, {}, null]) }}',
),
).toEqual([1, {}, null]);
});
test('.difference() should work on an array of objects', () => {
expect(
evaluate(
@ -139,6 +210,14 @@ describe('Data Transformation Functions', () => {
).toEqual([]);
});
test('.difference() should work on an arrays containing nulls, objects and arrays', () => {
expect(
evaluate(
'={{ [1, 2, "dd", {}, null, ["a", 1]].difference([1, {}, null, ["a", 1]]) }}',
),
).toEqual([2, "dd"]);
});
test('.compact() should work on an array', () => {
expect(
evaluate(

View file

@ -20,6 +20,33 @@ describe('Data Transformation Functions', () => {
);
});
test('.beginningOf("week") should work correctly on a string', () => {
const evaluatedDate = evaluate('={{ "2023-01-30".toDate().beginningOf("week") }}');
const expectedDate = DateTime.local(2023, 1, 23, { zone: TEST_TIMEZONE }).toJSDate();
if (evaluatedDate && evaluatedDate instanceof Date) {
expect(evaluatedDate.toDateString()).toEqual(expectedDate.toDateString());
}
});
test('.beginningOf("month") should work correctly on a string', () => {
const evaluatedDate = evaluate('={{ "2023-06-16".toDate().beginningOf("month") }}');
const expectedDate = DateTime.local(2023, 6, 1, { zone: TEST_TIMEZONE }).toJSDate();
if (evaluatedDate && evaluatedDate instanceof Date) {
expect(evaluatedDate.toDateString()).toEqual(expectedDate.toDateString());
}
});
test('.beginningOf("year") should work correctly on a string', () => {
const evaluatedDate = evaluate('={{ "2023-01-30".toDate().beginningOf("year") }}');
const expectedDate = DateTime.local(2023, 1, 1, { zone: TEST_TIMEZONE }).toJSDate();
if (evaluatedDate && evaluatedDate instanceof Date) {
expect(evaluatedDate.toDateString()).toEqual(expectedDate.toDateString());
}
});
test('.endOfMonth() should work correctly on a date', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 16).endOfMonth() }}')).toEqual(
DateTime.local(2023, 1, 31, 23, 59, 59, 999, { zone: TEST_TIMEZONE }).toJSDate(),
@ -30,6 +57,11 @@ describe('Data Transformation Functions', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 20).extract("day") }}')).toEqual(20);
});
test('.extract() should extract week for no args', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 20).extract() }}')).toEqual(3);
});
test('.format("yyyy LLL dd") should work correctly on a date', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 16).format("yyyy LLL dd") }}')).toEqual(
'2023 Jan 16',
@ -40,5 +72,29 @@ describe('Data Transformation Functions', () => {
const date = new Date(2022, 0, 3);
expect(evaluate(`={{ "${getLocalISOString(date)}".toDate() }}`)).toEqual(date);
});
test('.inBetween() should work on string and Date', () => {
expect(evaluate(`={{ $now.isBetween('2023-06-23'.toDate(), '2023-06-23') }}`)).toBeDefined();
});
test('.inBetween() should work on string and DateTime', () => {
expect(evaluate(`={{ $now.isBetween($now, '2023-06-23') }}`)).toBeDefined();
});
test('.inBetween() should not work for invalid strings', () => {
expect(evaluate(`={{ $now.isBetween($now, 'invalid') }}`)).toBeUndefined();
});
test('.inBetween() should not work for numbers', () => {
expect(evaluate(`={{ $now.isBetween($now, 1) }}`)).toBeUndefined();
});
test('.inBetween() should not work for a single argument', () => {
expect(() => evaluate(`={{ $now.isBetween($now) }}`)).toThrow();
});
test('.inBetween() should not work for a more than two arguments', () => {
expect(() => evaluate(`={{ $now.isBetween($now, '2023-06-23', '2023-09-21'.toDate()) }}`)).toThrow();
});
});
});

View file

@ -18,6 +18,9 @@ export const workflow = new Workflow({
connections: {},
active: false,
nodeTypes,
settings: {
timezone: TEST_TIMEZONE,
}
});
export const expression = new Expression(workflow);

View file

@ -38,14 +38,22 @@ describe('Data Transformation Functions', () => {
expect(evaluate('={{ (9).isOdd() }}')).toEqual(true);
expect(evaluate('={{ (8).isOdd() }}')).toEqual(false);
expect(evaluate('={{ (0).isOdd() }}')).toEqual(false);
expect(evaluate('={{ (NaN).isOdd() }}')).toEqual(false);
});
test('.isOdd() should not work on a float or NaN', () => {
expect(() => evaluate('={{ (NaN).isOdd() }}')).toThrow();
expect(() => evaluate('={{ (9.2).isOdd() }}')).toThrow();
});
test('.isEven() should work on a number', () => {
expect(evaluate('={{ (9).isEven() }}')).toEqual(false);
expect(evaluate('={{ (8).isEven() }}')).toEqual(true);
expect(evaluate('={{ (0).isEven() }}')).toEqual(true);
expect(evaluate('={{ (NaN).isEven() }}')).toEqual(false);
});
test('.isEven() should not work on a float or NaN', () => {
expect(() => evaluate('={{ (NaN).isEven() }}')).toThrow();
expect(() => evaluate('={{ (9.2).isEven() }}')).toThrow();
});
});

View file

@ -7,14 +7,6 @@ describe('Data Transformation Functions', () => {
expect(evaluate('={{({ test1: 1 }).isEmpty()}}')).toEqual(false);
});
test('.merge should work on an object', () => {
expect(evaluate('={{ ({ test1: 1, test2: 2 }).merge({ test2: 3, test3: 3 }) }}')).toEqual({
test1: 1,
test2: 2,
test3: 3,
});
});
test('.hasField should work on an object', () => {
expect(evaluate('={{ ({ test1: 1 }).hasField("test1") }}')).toEqual(true);
expect(evaluate('={{ ({ test1: 1 }).hasField("test2") }}')).toEqual(false);
@ -44,6 +36,14 @@ describe('Data Transformation Functions', () => {
});
});
test('.removeFieldsContaining should not work for empty string', () => {
expect(
() => evaluate(
'={{ ({ test1: "i exist", test2: "i should be removed", test3: "i should also be removed" }).removeFieldsContaining("") }}',
),
).toThrow();
});
test('.keepFieldsContaining should work on an object', () => {
expect(
evaluate(
@ -54,6 +54,24 @@ describe('Data Transformation Functions', () => {
});
});
test('.keepFieldsContaining should work on a nested object', () => {
expect(
evaluate(
'={{ ({ test1: "i exist", test2: "i should be removed", test3: { test4: "me too" } }).keepFieldsContaining("exist") }}',
),
).toEqual({
test1: 'i exist',
});
});
test('.keepFieldsContaining should not work for empty string', () => {
expect(
() => evaluate(
'={{ ({ test1: "i exist", test2: "i should be removed", test3: "i should also be removed" }).keepFieldsContaining("") }}',
),
).toThrow();
});
test('.compact should work on an object', () => {
expect(
evaluate('={{ ({ test1: 1, test2: "2", test3: undefined, test4: null }).compact() }}'),

View file

@ -934,6 +934,7 @@ importers:
'@n8n_io/eslint-config': ''
'@n8n_io/riot-tmpl': ^2.0.0
'@types/crypto-js': ^4.1.1
'@types/deep-equal': ^1.0.1
'@types/express': ^4.17.6
'@types/jmespath': ^0.15.0
'@types/lodash.get': ^4.4.6
@ -944,6 +945,7 @@ importers:
'@types/xml2js': ^0.4.3
ast-types: 0.15.2
crypto-js: ^4.1.1
deep-equal: ^2.2.0
esprima-next: 5.8.4
jmespath: ^0.16.0
js-base64: ^3.7.2
@ -960,6 +962,7 @@ importers:
'@n8n_io/riot-tmpl': 2.0.0
ast-types: 0.15.2
crypto-js: 4.1.1
deep-equal: 2.2.0
esprima-next: 5.8.4
jmespath: 0.16.0
js-base64: 3.7.2
@ -975,6 +978,7 @@ importers:
devDependencies:
'@n8n_io/eslint-config': link:../@n8n_io/eslint-config
'@types/crypto-js': 4.1.1
'@types/deep-equal': 1.0.1
'@types/express': 4.17.14
'@types/jmespath': 0.15.0
'@types/lodash.get': 4.4.7
@ -5300,6 +5304,8 @@ packages:
resolution: {integrity: sha512-KlPPdikagvL6ELjWsljbyDIPzNCeliYkqRpI+zea99vBBbCIA5JNshZAwQKTON139c87y9qvTFVgkFd14rtS4g==}
dev: true
/@types/deep-equal/1.0.1:
resolution: {integrity: sha512-mMUu4nWHLBlHtxXY17Fg6+ucS/MnndyOWyOe7MmwkoMYxvfQU2ajtRaEvqSUv+aVkMqH/C0NCI8UoVfRNQ10yg==}
/@types/detect-port/1.3.2:
resolution: {integrity: sha512-xxgAGA2SAU4111QefXPSp5eGbDm/hW6zhvYl9IeEPZEry9F4d66QAHm5qpUXjb6IsevZV/7emAEx5MhP6O192g==}
dev: true
@ -9313,7 +9319,6 @@ packages:
which-boxed-primitive: 1.0.2
which-collection: 1.0.1
which-typed-array: 1.1.9
dev: true
/deep-is/0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -9866,14 +9871,14 @@ packages:
has: 1.0.3
has-property-descriptors: 1.0.0
has-symbols: 1.0.3
internal-slot: 1.0.3
internal-slot: 1.0.4
is-callable: 1.2.7
is-negative-zero: 2.0.2
is-regex: 1.1.4
is-shared-array-buffer: 1.0.2
is-string: 1.0.7
is-weakref: 1.0.2
object-inspect: 1.12.2
object-inspect: 1.12.3
object-keys: 1.1.1
object.assign: 4.1.4
regexp.prototype.flags: 1.4.3
@ -9949,7 +9954,6 @@ packages:
is-string: 1.0.7
isarray: 2.0.5
stop-iteration-iterator: 1.0.0
dev: true
/es-module-lexer/0.9.3:
resolution: {integrity: sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==}
@ -12212,14 +12216,6 @@ packages:
through: 2.3.8
dev: false
/internal-slot/1.0.3:
resolution: {integrity: sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==}
engines: {node: '>= 0.4'}
dependencies:
get-intrinsic: 1.1.3
has: 1.0.3
side-channel: 1.0.4
/internal-slot/1.0.4:
resolution: {integrity: sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==}
engines: {node: '>= 0.4'}
@ -12502,7 +12498,6 @@ packages:
/is-map/2.0.2:
resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==}
dev: true
/is-nan/1.3.2:
resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==}
@ -12608,7 +12603,6 @@ packages:
/is-set/2.0.2:
resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==}
dev: true
/is-shared-array-buffer/1.0.2:
resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
@ -12646,17 +12640,6 @@ packages:
gopd: 1.0.1
has-tostringtag: 1.0.0
/is-typed-array/1.1.9:
resolution: {integrity: sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A==}
engines: {node: '>= 0.4'}
dependencies:
available-typed-arrays: 1.0.5
call-bind: 1.0.2
es-abstract: 1.20.4
for-each: 0.3.3
has-tostringtag: 1.0.0
dev: false
/is-typedarray/1.0.0:
resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==}
@ -12682,7 +12665,6 @@ packages:
/is-weakmap/2.0.1:
resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==}
dev: true
/is-weakref/1.0.2:
resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==}
@ -12694,7 +12676,6 @@ packages:
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.1.3
dev: true
/is-whitespace/0.3.0:
resolution: {integrity: sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==}
@ -12719,7 +12700,6 @@ packages:
/isarray/2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
dev: true
/isbot/3.6.1:
resolution: {integrity: sha512-e1RmjWns87x60QyiHberWWMJGutL3+Ad0nZ8cz735iDEDDS6ApPfKSFo4EMj0PmMZ0m0ntpWIM0ADdqDFvUJPQ==}
@ -15385,9 +15365,6 @@ packages:
kind-of: 3.2.2
dev: true
/object-inspect/1.12.2:
resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==}
/object-inspect/1.12.3:
resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
@ -15397,7 +15374,6 @@ packages:
dependencies:
call-bind: 1.0.2
define-properties: 1.1.4
dev: true
/object-keys/1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
@ -18359,7 +18335,6 @@ packages:
engines: {node: '>= 0.4'}
dependencies:
internal-slot: 1.0.4
dev: true
/stoppable/1.1.0:
resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==}
@ -19912,9 +19887,9 @@ packages:
inherits: 2.0.4
is-arguments: 1.1.1
is-generator-function: 1.0.10
is-typed-array: 1.1.9
is-typed-array: 1.1.10
safe-buffer: 5.2.1
which-typed-array: 1.1.8
which-typed-array: 1.1.9
dev: false
/util/0.12.5:
@ -20903,7 +20878,6 @@ packages:
is-set: 2.0.2
is-weakmap: 2.0.1
is-weakset: 2.0.2
dev: true
/which-module/1.0.0:
resolution: {integrity: sha512-F6+WgncZi/mJDrammbTuHe1q0R5hOXv/mBaiNA2TCNT/LTHusX0V+CJnj9XT8ki5ln2UZyyddDgHfCzyrOH7MQ==}
@ -20913,18 +20887,6 @@ packages:
resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==}
dev: true
/which-typed-array/1.1.8:
resolution: {integrity: sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw==}
engines: {node: '>= 0.4'}
dependencies:
available-typed-arrays: 1.0.5
call-bind: 1.0.2
es-abstract: 1.20.4
for-each: 0.3.3
has-tostringtag: 1.0.0
is-typed-array: 1.1.9
dev: false
/which-typed-array/1.1.9:
resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==}
engines: {node: '>= 0.4'}