Skip to content

Commit

Permalink
feat(dt-functions): introduce number expression extensions (n8n-io#4046)
Browse files Browse the repository at this point in the history
* 🎉 Introduce Number Extensions

* ⚡ Support more shared extensions

* ⚡ Improve handling of name collision

* ✅ Update tests
  • Loading branch information
valya committed Nov 8, 2022
1 parent ebb18f1 commit cb248e5
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 52 deletions.
5 changes: 3 additions & 2 deletions packages/workflow/src/Expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,10 @@ export class Expression {
}
}

// Handle TypeErrors, because we're extending native types,
// Handle Type Errors,
// because we're extending native types,
// {{ "".isBlank() }} will always fail once
// here we can then check and retry with an extended expressionTemplate string
// check and retry with an extended expressionTemplate string
// {{ extend("").isBlank() }}
if (error instanceof TypeError) {
expressionTemplate = this.extendSyntax(parameterValue);
Expand Down
24 changes: 17 additions & 7 deletions packages/workflow/src/Extensions/ArrayExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export class ArrayExtensions extends BaseExtension<any> {
this.initializeMethodMap();
}

count(mainArg: any[]): number {
return this.length(mainArg);
}

bind(mainArg: any[], extraArgs?: number[] | string[] | boolean[] | undefined) {
return Array.from(this.methodMapping).reduce((p, c) => {
const [key, method] = c;
Expand All @@ -34,16 +38,18 @@ export class ArrayExtensions extends BaseExtension<any> {
extraArgs?: number[] | string[] | boolean[] | undefined,
) => any[] | boolean | string | Date | number
>([
['count', this.count],
['duplicates', this.unique],
['isPresent', this.isPresent],
['filter', this.filter],
['first', this.first],
['last', this.last],
['length', this.length],
['pluck', this.pluck],
['unique', this.unique],
['random', this.random],
['randomItem', this.randomItem],
['remove', this.unique],
['size', this.size],
]);
}

Expand All @@ -63,12 +69,8 @@ export class ArrayExtensions extends BaseExtension<any> {
return Array.isArray(value) && value.length === 0;
}

isPresent(value: any[], extraArgs?: any[]): boolean {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
if (!Array.isArray(extraArgs)) {
throw new ExpressionError('arguments must be passed to isPresent');
}
const comparators = extraArgs as string[] | number[];
isPresent(value: any[], extraArgs?: any): boolean {
const comparators = Array.isArray(extraArgs) ? extraArgs : [extraArgs];
return value.some((v: string | number) => {
return (comparators as Array<typeof v>).includes(v);
});
Expand Down Expand Up @@ -104,7 +106,15 @@ export class ArrayExtensions extends BaseExtension<any> {
return length ? value[Math.floor(Math.random() * length)] : undefined;
}

randomItem(value: any[]): any {
return this.random(value);
}

unique(value: any[]): any[] {
return Array.from(new Set(value));
}

size(value: any[]): number {
return this.length(value);
}
}
2 changes: 0 additions & 2 deletions packages/workflow/src/Extensions/DateExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,12 @@ export class DateExtensions extends BaseExtension<Date> {
['begginingOf', this.begginingOf],
['endOfMonth', this.endOfMonth],
['extract', this.extract],
['format', this.format],
['isBetween', this.isBetween],
['isDst', this.isDst],
['isInLast', this.isInLast],
['isWeekend', this.isWeekend],
['minus', this.minus],
['plus', this.plus],
['toLocaleString', this.toLocaleString],
['toTimeFromNow', this.toTimeFromNow],
['timeTo', this.timeTo],
]);
Expand Down
60 changes: 45 additions & 15 deletions packages/workflow/src/Extensions/ExpressionExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as BabelCore from '@babel/core';
import * as BabelTypes from '@babel/types';
import { DateTime, Interval, Duration } from 'luxon';
import { ExpressionExtensionError } from '../ExpressionError';
import { NumberExtensions } from './NumberExtensions';

import { DateExtensions } from './DateExtensions';
import { StringExtensions } from './StringExtensions';
Expand All @@ -19,15 +20,20 @@ const EXPRESSION_EXTENDER = 'extend';
const stringExtensions = new StringExtensions();
const dateExtensions = new DateExtensions();
const arrayExtensions = new ArrayExtensions();
const numberExtensions = new NumberExtensions();

const EXPRESSION_EXTENSION_METHODS = Array.from(
new Set([
...stringExtensions.listMethods(),
...numberExtensions.listMethods(),
...dateExtensions.listMethods(),
...arrayExtensions.listMethods(),
'toDecimal',
'isBlank',
'isPresent',
'toDecimal',
'toLocaleString',
'random',
'format',
]),
);

Expand Down Expand Up @@ -117,39 +123,63 @@ type ExtMethods = {
};

export function extend(mainArg: unknown, ...extraArgs: unknown[]): ExtMethods {
const extensions: ExtMethods = {
toDecimal() {
if (typeof mainArg !== 'number') {
throw new ExpressionExtensionError('toDecimal() requires a number-type main arg');
const higherLevelExtensions: ExtMethods = {
format(): string {
if (typeof mainArg === 'number') {
return numberExtensions.format(Number(mainArg), extraArgs);
}

if (!extraArgs || extraArgs.length > 1) {
throw new ExpressionExtensionError('toDecimal() requires a single extra arg');
}

const [extraArg] = extraArgs;

if (typeof extraArg !== 'number') {
throw new ExpressionExtensionError('toDecimal() requires a number-type extra arg');
if ('isLuxonDateTime' in (mainArg as any) || mainArg instanceof Date) {
const date = new Date(mainArg as string);
return dateExtensions.format(date, extraArgs);
}

return mainArg.toFixed(extraArg);
throw new ExpressionExtensionError('format() is only callable on types "Number" and "Date"');
},
isBlank(): boolean {
if (typeof mainArg === 'string') {
return stringExtensions.isBlank(mainArg);
}

if (typeof mainArg === 'number') {
return numberExtensions.isBlank(Number(mainArg));
}

if (Array.isArray(mainArg)) {
return arrayExtensions.isBlank(mainArg);
}

return true;
},
isPresent(): boolean {
if (typeof mainArg === 'number') {
return numberExtensions.isPresent(Number(mainArg));
}

if (Array.isArray(mainArg)) {
return arrayExtensions.isPresent(mainArg);
}

throw new ExpressionExtensionError(
'isPresent() is only callable on types "Number" and "Array"',
);
},
random(): any {
if (typeof mainArg === 'number') {
return numberExtensions.random(Number(mainArg));
}

if (Array.isArray(mainArg)) {
return arrayExtensions.random(mainArg);
}

throw new ExpressionExtensionError('random() is only callable on types "Number" and "Array"');
},
toLocaleString(): string {
return dateExtensions.toLocaleString(new Date(mainArg as string), extraArgs);
},
...stringExtensions.bind(mainArg as string, extraArgs as string[] | undefined),
...numberExtensions.bind(Number(mainArg), extraArgs as any[] | undefined),
...dateExtensions.bind(
new Date(mainArg as string),
extraArgs as number[] | string[] | boolean[] | undefined,
Expand All @@ -160,5 +190,5 @@ export function extend(mainArg: unknown, ...extraArgs: unknown[]): ExtMethods {
),
};

return extensions;
return higherLevelExtensions;
}
8 changes: 0 additions & 8 deletions packages/workflow/src/Extensions/ExtensionError.ts

This file was deleted.

61 changes: 61 additions & 0 deletions packages/workflow/src/Extensions/NumberExtensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
// eslint-disable-next-line import/no-cycle
import { BaseExtension, ExtensionMethodHandler } from './Extensions';

export class NumberExtensions extends BaseExtension<number> {
methodMapping = new Map<string, ExtensionMethodHandler<number>>();

constructor() {
super();
this.initializeMethodMap();
}

bind(mainArg: number, extraArgs?: number[] | string[] | boolean[] | undefined) {
return Array.from(this.methodMapping).reduce((p, c) => {
const [key, method] = c;
Object.assign(p, {
[key]: () => {
return method.call(this, mainArg, extraArgs);
},
});
return p;
}, {} as object);
}

private initializeMethodMap(): void {
this.methodMapping = new Map<
string,
(
value: number,
extraArgs?: number | number[] | string[] | boolean[] | undefined,
) => boolean | string | Date | number
>([]);
}

format(value: number, extraArgs?: any): string {
const [locales = 'en-US', config] = extraArgs as [
string | string[],
{ compactDisplay: string; notation: string; style: string },
];

return new Intl.NumberFormat(locales, {
...config,
notation: 'compact',
compactDisplay: 'short',
style: 'decimal',
}).format(value);
}

isBlank(value: number): boolean {
return value == null || typeof value !== 'number';
}

isPresent(value: number): boolean {
return !this.isBlank(value);
}

random(value: number): number {
return Math.floor(Math.random() * value);
}
}
2 changes: 0 additions & 2 deletions packages/workflow/src/Extensions/StringExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ export class StringExtensions extends BaseExtension<string> {
['encrypt', this.encrypt],
['getOnlyFirstCharacters', this.getOnlyFirstCharacters],
['hash', this.encrypt],
['isPresent', this.isPresent],
['length', this.length],
['removeMarkdown', this.removeMarkdown],
['sayHi', this.sayHi],
['stripTags', this.stripTags],
Expand Down
51 changes: 35 additions & 16 deletions packages/workflow/test/ExpressionExtensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { extend } from '../src/Extensions';
import { DateExtensions } from '../src/Extensions/DateExtensions';
import { StringExtensions } from '../src/Extensions/StringExtensions';
import { ArrayExtensions } from '../src/Extensions/ArrayExtensions';
import { NumberExtensions } from '../src/Extensions/NumberExtensions';

describe('Expression Extensions', () => {
describe('extend()', () => {
Expand Down Expand Up @@ -38,30 +39,24 @@ describe('Expression Extensions', () => {

it('should be able to utilize date expression extension methods', () => {
const JUST_NOW_STRING_RESULT = 'just now';
// Date sensitive test case here so testing it to not be undefined should be enough
expect(evaluate('={{DateTime.now().isWeekend()}}')).not.toEqual(undefined);

expect(evaluate('={{DateTime.now().isWeekend()}}')).toEqual(
dateExtensions().isWeekend(new Date()),
);
expect(evaluate('={{DateTime.now().toTimeFromNow()}}')).toEqual(JUST_NOW_STRING_RESULT);

expect(evaluate('={{DateTime.now().begginingOf("week")}}')).toEqual(
dateExtensions('week').begginingOf.call({}, new Date(), 'week'),
dateExtensions('week').begginingOf(new Date(), 'week'),
);

expect(evaluate('={{ DateTime.now().endOfMonth() }}')).toEqual(
dateExtensions().endOfMonth.call({}, new Date()),
dateExtensions().endOfMonth(new Date()),
);

expect(evaluate('={{ DateTime.now().extract("day") }}')).toEqual(
dateExtensions('day').extract.call({}, new Date(), 'day'),
dateExtensions('day').extract(new Date(), 'day'),
);

expect(evaluate('={{ DateTime.now().format("yyyy LLL dd") }}')).toEqual(
dateExtensions('yyyy LLL dd').format.call({}, new Date(), 'yyyy LLL dd'),
dateExtensions('yyyy LLL dd').format(new Date(), 'yyyy LLL dd'),
);

expect(evaluate('={{ DateTime.now().format("yyyy LLL dd") }}')).not.toEqual(
dateExtensions("HH 'hours and' mm 'minutes'").format.call(
{},
dateExtensions("HH 'hours and' mm 'minutes'").format(
new Date(),
"HH 'hours and' mm 'minutes'",
),
Expand All @@ -74,7 +69,7 @@ describe('Expression Extensions', () => {

it('should be able to utilize string expression extension methods', () => {
expect(evaluate('={{"NotBlank".isBlank()}}')).toEqual(
stringExtensions('NotBlank').isBlank.call(String, 'NotBlank'),
stringExtensions('NotBlank').isBlank('NotBlank'),
);

expect(evaluate('={{"myNewField".getOnlyFirstCharacters(5)}}')).toEqual('myNew');
Expand Down Expand Up @@ -139,7 +134,11 @@ describe('Expression Extensions', () => {
it('should be able to utilize array expression extension methods', () => {
expect(evaluate('={{ [1,2,3].random() }}')).not.toBeUndefined();

expect(evaluate('={{ [1,2,3, "imhere"].isPresent("imhere") }}')).toEqual(true);
expect(evaluate('={{ [1,2,3].randomItem() }}')).not.toBeUndefined();

expect(evaluate('={{ [1,2,3, "imhere"].isPresent("imhere") }}')).toEqual(
arrayExtensions([1, 2, 3, 'imhere'], 'imhere').isPresent([1, 2, 3, 'imhere'], 'imhere'),
);

expect(
evaluate(`={{ [
Expand Down Expand Up @@ -169,6 +168,10 @@ describe('Expression Extensions', () => {

expect(evaluate('={{ [].length() }}')).toEqual(arrayExtensions([]).length([]));

expect(evaluate('={{ [1].count() }}')).toEqual(arrayExtensions([1]).length([1]));

expect(evaluate('={{ [1,2].size() }}')).toEqual(arrayExtensions([1, 2]).length([1, 2]));

expect(evaluate('={{ ["repeat","repeat","a","b","c"].last() }}')).toEqual('c');

expect(evaluate('={{ ["repeat","repeat","a","b","c"].first() }}')).toEqual('repeat');
Expand All @@ -177,5 +180,21 @@ describe('Expression Extensions', () => {
expect.arrayContaining(['repeat', 'repeat']),
);
});

const numberExtensions = (data: number, ...args: any[]) => {
return extend(data, ...args) as unknown as NumberExtensions;
};

it('should be able to utilize number expression extension methods', () => {
expect(evaluate('={{ Number(100).random() }}')).not.toBeUndefined();

expect(evaluate('={{ Number(100).isBlank() }}')).toEqual(false);

expect(evaluate('={{ Number(100).isPresent() }}')).toEqual(
numberExtensions(100).isPresent(100),
);

expect(evaluate('={{ Number(100).format() }}')).toEqual(numberExtensions(100).format(100));
});
});
});

0 comments on commit cb248e5

Please sign in to comment.