mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Filter component + implement in If node (#7490)
New Filter component + implementation in If node (v2) <img width="3283" alt="image" src="https://github.com/n8n-io/n8n/assets/8850410/35c379ef-4b62-4d06-82e7-673d4edcd652"> --------- Co-authored-by: Giulio Andreini <andreini@netseven.it> Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
@@ -1071,6 +1071,7 @@ export type NodePropertyTypes =
|
||||
| 'resourceLocator'
|
||||
| 'curlImport'
|
||||
| 'resourceMapper'
|
||||
| 'filter'
|
||||
| 'credentials';
|
||||
|
||||
export type CodeAutocompleteTypes = 'function' | 'functionItem';
|
||||
@@ -1118,6 +1119,7 @@ export interface INodePropertyTypeOptions {
|
||||
sortable?: boolean; // Supported when "multipleValues" set to true
|
||||
expirable?: boolean; // Supported by: hidden (only in the credentials)
|
||||
resourceMapper?: ResourceMapperTypeOptions;
|
||||
filter?: FilterTypeOptions;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -1137,6 +1139,18 @@ export interface ResourceMapperTypeOptions {
|
||||
};
|
||||
}
|
||||
|
||||
type NonEmptyArray<T> = [T, ...T[]];
|
||||
|
||||
export type FilterTypeCombinator = 'and' | 'or';
|
||||
|
||||
export type FilterTypeOptions = Partial<{
|
||||
caseSensitive: boolean | string; // default = true
|
||||
leftValue: string; // when set, user can't edit left side of condition
|
||||
allowedCombinators: NonEmptyArray<FilterTypeCombinator>; // default = ['and', 'or']
|
||||
maxConditions: number; // default = 10
|
||||
typeValidation: 'strict' | 'loose' | {}; // default = strict, `| {}` is a TypeScript trick to allow custom strings, but still give autocomplete
|
||||
}>;
|
||||
|
||||
export interface IDisplayOptions {
|
||||
hide?: {
|
||||
[key: string]: NodeParameterValue[] | undefined;
|
||||
@@ -2209,6 +2223,42 @@ export type ResourceMapperValue = {
|
||||
matchingColumns: string[];
|
||||
schema: ResourceMapperField[];
|
||||
};
|
||||
|
||||
export type FilterOperatorType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'array'
|
||||
| 'object'
|
||||
| 'dateTime'
|
||||
| 'any';
|
||||
|
||||
export interface FilterOperatorValue {
|
||||
type: FilterOperatorType;
|
||||
operation: string;
|
||||
rightType?: FilterOperatorType;
|
||||
singleValue?: boolean; // default = false
|
||||
}
|
||||
|
||||
export type FilterConditionValue = {
|
||||
id: string;
|
||||
leftValue: unknown;
|
||||
operator: FilterOperatorValue;
|
||||
rightValue: unknown;
|
||||
};
|
||||
|
||||
export type FilterOptionsValue = {
|
||||
caseSensitive: boolean;
|
||||
leftValue: string;
|
||||
typeValidation: 'strict' | 'loose';
|
||||
};
|
||||
|
||||
export type FilterValue = {
|
||||
options: FilterOptionsValue;
|
||||
conditions: FilterConditionValue[];
|
||||
combinator: FilterTypeCombinator;
|
||||
};
|
||||
|
||||
export interface ExecutionOptions {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -36,18 +36,22 @@ import type {
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeParameterValue,
|
||||
ResourceMapperValue,
|
||||
ValidationResult,
|
||||
ConnectionTypes,
|
||||
INodeTypeDescription,
|
||||
INodeOutputConfiguration,
|
||||
INodeInputConfiguration,
|
||||
GenericValue,
|
||||
} from './Interfaces';
|
||||
import { isResourceMapperValue, isValidResourceLocatorParameterValue } from './type-guards';
|
||||
import {
|
||||
isFilterValue,
|
||||
isResourceMapperValue,
|
||||
isValidResourceLocatorParameterValue,
|
||||
} from './type-guards';
|
||||
import { deepCopy } from './utils';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Workflow } from './Workflow';
|
||||
import { validateFilterParameter } from './NodeParameters/FilterParameter';
|
||||
import { validateFieldType } from './TypeValidation';
|
||||
import { ApplicationError } from './errors/application.error';
|
||||
|
||||
export const cronNodeOptions: INodePropertyCollection[] = [
|
||||
@@ -1186,188 +1190,6 @@ export function nodeIssuesToString(issues: INodeIssues, node?: INode): string[]
|
||||
return nodeIssues;
|
||||
}
|
||||
|
||||
// Validates field against the schema and tries to parse it to the correct type
|
||||
export const validateFieldType = (
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
type: FieldType,
|
||||
options?: INodePropertyOptions[],
|
||||
): ValidationResult => {
|
||||
if (value === null || value === undefined) return { valid: true };
|
||||
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got '${String(value)}'`;
|
||||
switch (type.toLowerCase()) {
|
||||
case 'number': {
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseNumber(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'boolean': {
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseBoolean(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'datetime': {
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseDateTime(value) };
|
||||
} catch (e) {
|
||||
const luxonDocsURL =
|
||||
'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat';
|
||||
const errorMessage = `${defaultErrorMessage} <br/><br/> Consider using <a href="${luxonDocsURL}" target="_blank"><code>DateTime.fromFormat</code></a> to work with custom date formats.`;
|
||||
return { valid: false, errorMessage };
|
||||
}
|
||||
}
|
||||
case 'time': {
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseTime(value) };
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
errorMessage: `'${fieldName}' expects time (hh:mm:(:ss)) but we got '${String(value)}'.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
case 'object': {
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseObject(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'array': {
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseArray(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'options': {
|
||||
const validOptions = options?.map((option) => option.value).join(', ') || '';
|
||||
const isValidOption = options?.some((option) => option.value === value) || false;
|
||||
|
||||
if (!isValidOption) {
|
||||
return {
|
||||
valid: false,
|
||||
errorMessage: `'${fieldName}' expects one of the following values: [${validOptions}] but we got '${String(
|
||||
value,
|
||||
)}'`,
|
||||
};
|
||||
}
|
||||
return { valid: true, newValue: value };
|
||||
}
|
||||
default: {
|
||||
return { valid: true, newValue: value };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const tryToParseNumber = (value: unknown): number => {
|
||||
const isValidNumber = !isNaN(Number(value));
|
||||
|
||||
if (!isValidNumber) {
|
||||
throw new ApplicationError('Failed to parse value to number', { extra: { value } });
|
||||
}
|
||||
return Number(value);
|
||||
};
|
||||
|
||||
export const tryToParseBoolean = (value: unknown): value is boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
|
||||
return value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
// If value is not a empty string, try to parse it to a number
|
||||
if (!(typeof value === 'string' && value.trim() === '')) {
|
||||
const num = Number(value);
|
||||
if (num === 0) {
|
||||
return false;
|
||||
} else if (num === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApplicationError('Failed to parse value as boolean', {
|
||||
extra: { value },
|
||||
});
|
||||
};
|
||||
|
||||
export const tryToParseDateTime = (value: unknown): DateTime => {
|
||||
const dateString = String(value).trim();
|
||||
|
||||
// Rely on luxon to parse different date formats
|
||||
const isoDate = DateTime.fromISO(dateString, { setZone: true });
|
||||
if (isoDate.isValid) {
|
||||
return isoDate;
|
||||
}
|
||||
const httpDate = DateTime.fromHTTP(dateString, { setZone: true });
|
||||
if (httpDate.isValid) {
|
||||
return httpDate;
|
||||
}
|
||||
const rfc2822Date = DateTime.fromRFC2822(dateString, { setZone: true });
|
||||
if (rfc2822Date.isValid) {
|
||||
return rfc2822Date;
|
||||
}
|
||||
const sqlDate = DateTime.fromSQL(dateString, { setZone: true });
|
||||
if (sqlDate.isValid) {
|
||||
return sqlDate;
|
||||
}
|
||||
|
||||
throw new ApplicationError('Value is not a valid date', { extra: { dateString } });
|
||||
};
|
||||
|
||||
export const tryToParseTime = (value: unknown): string => {
|
||||
const isTimeInput = /^\d{2}:\d{2}(:\d{2})?((\-|\+)\d{4})?((\-|\+)\d{1,2}(:\d{2})?)?$/s.test(
|
||||
String(value),
|
||||
);
|
||||
if (!isTimeInput) {
|
||||
throw new ApplicationError('Value is not a valid time', { extra: { value } });
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const tryToParseArray = (value: unknown): unknown[] => {
|
||||
try {
|
||||
if (typeof value === 'object' && Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(String(value));
|
||||
} catch (e) {
|
||||
parsed = JSON.parse(String(value).replace(/'/g, '"'));
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new ApplicationError('Value is not a valid array', { extra: { value } });
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
throw new ApplicationError('Value is not a valid array', { extra: { value } });
|
||||
}
|
||||
};
|
||||
|
||||
export const tryToParseObject = (value: unknown): object => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const o = JSON.parse(String(value));
|
||||
if (typeof o !== 'object' || Array.isArray(o)) {
|
||||
throw new ApplicationError('Value is not a valid object', { extra: { value } });
|
||||
}
|
||||
return o;
|
||||
} catch (e) {
|
||||
throw new ApplicationError('Value is not a valid object', { extra: { value } });
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Validates resource locator node parameters based on validation ruled defined in each parameter mode
|
||||
*
|
||||
@@ -1423,7 +1245,9 @@ export const validateResourceMapperParameter = (
|
||||
}
|
||||
}
|
||||
if (!fieldValue?.toString().startsWith('=') && field.type) {
|
||||
const validationResult = validateFieldType(field.id, fieldValue, field.type, field.options);
|
||||
const validationResult = validateFieldType(field.id, fieldValue, field.type, {
|
||||
valueOptions: field.options,
|
||||
});
|
||||
if (!validationResult.valid && validationResult.errorMessage) {
|
||||
fieldErrors.push(validationResult.errorMessage);
|
||||
}
|
||||
@@ -1444,12 +1268,9 @@ export const validateParameter = (
|
||||
const options = type === 'options' ? nodeProperties.options : undefined;
|
||||
|
||||
if (!value?.toString().startsWith('=')) {
|
||||
const validationResult = validateFieldType(
|
||||
nodeName,
|
||||
value,
|
||||
type,
|
||||
options as INodePropertyOptions[],
|
||||
);
|
||||
const validationResult = validateFieldType(nodeName, value, type, {
|
||||
valueOptions: options as INodePropertyOptions[],
|
||||
});
|
||||
|
||||
if (!validationResult.valid && validationResult.errorMessage) {
|
||||
return validationResult.errorMessage;
|
||||
@@ -1583,6 +1404,14 @@ export function getParameterIssues(
|
||||
foundIssues.parameters = { ...foundIssues.parameters, ...issues };
|
||||
}
|
||||
}
|
||||
} else if (nodeProperties.type === 'filter' && isDisplayed) {
|
||||
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
|
||||
if (isFilterValue(value)) {
|
||||
const issues = validateFilterParameter(nodeProperties, value);
|
||||
if (Object.keys(issues).length > 0) {
|
||||
foundIssues.parameters = { ...foundIssues.parameters, ...issues };
|
||||
}
|
||||
}
|
||||
} else if (nodeProperties.validateType) {
|
||||
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
|
||||
const error = validateParameter(nodeProperties, value, nodeProperties.validateType);
|
||||
|
||||
345
packages/workflow/src/NodeParameters/FilterParameter.ts
Normal file
345
packages/workflow/src/NodeParameters/FilterParameter.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import type { DateTime } from 'luxon';
|
||||
import type {
|
||||
FilterConditionValue,
|
||||
FilterOperatorType,
|
||||
FilterOptionsValue,
|
||||
FilterValue,
|
||||
INodeProperties,
|
||||
ValidationResult,
|
||||
} from '../Interfaces';
|
||||
import { validateFieldType } from '../TypeValidation';
|
||||
import * as LoggerProxy from '../LoggerProxy';
|
||||
|
||||
type Result<T, E> = { ok: true; result: T } | { ok: false; error: E };
|
||||
|
||||
type FilterConditionMetadata = {
|
||||
index: number;
|
||||
unresolvedExpressions: boolean;
|
||||
itemIndex: number;
|
||||
errorFormat: 'full' | 'inline';
|
||||
};
|
||||
|
||||
export class FilterError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly description: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSingleFilterValue(
|
||||
value: unknown,
|
||||
type: FilterOperatorType,
|
||||
strict = false,
|
||||
): ValidationResult {
|
||||
return type === 'any' || value === null || value === undefined || value === ''
|
||||
? ({ valid: true, newValue: value } as ValidationResult)
|
||||
: validateFieldType('filter', value, type, { strict, parseStrings: true });
|
||||
}
|
||||
|
||||
function parseFilterConditionValues(
|
||||
condition: FilterConditionValue,
|
||||
options: FilterOptionsValue,
|
||||
metadata: Partial<FilterConditionMetadata>,
|
||||
): Result<{ left: unknown; right: unknown }, FilterError> {
|
||||
const index = metadata.index ?? 0;
|
||||
const itemIndex = metadata.itemIndex ?? 0;
|
||||
const errorFormat = metadata.errorFormat ?? 'full';
|
||||
const strict = options.typeValidation === 'strict';
|
||||
const { operator } = condition;
|
||||
const rightType = operator.rightType ?? operator.type;
|
||||
const parsedLeftValue = parseSingleFilterValue(condition.leftValue, operator.type, strict);
|
||||
const parsedRightValue = parseSingleFilterValue(condition.rightValue, rightType, strict);
|
||||
const leftValid =
|
||||
parsedLeftValue.valid ||
|
||||
(metadata.unresolvedExpressions &&
|
||||
typeof condition.leftValue === 'string' &&
|
||||
condition.leftValue.startsWith('='));
|
||||
const rightValid =
|
||||
parsedRightValue.valid ||
|
||||
!!operator.singleValue ||
|
||||
(metadata.unresolvedExpressions &&
|
||||
typeof condition.rightValue === 'string' &&
|
||||
condition.rightValue.startsWith('='));
|
||||
const leftValueString = String(condition.leftValue);
|
||||
const rightValueString = String(condition.rightValue);
|
||||
const errorDescription = 'Try to change the operator, or change the type with an expression';
|
||||
const inCondition = errorFormat === 'full' ? ` in condition ${index + 1} ` : ' ';
|
||||
const itemSuffix = `[item ${itemIndex}]`;
|
||||
|
||||
if (!leftValid && !rightValid) {
|
||||
const providedValues = 'The provided values';
|
||||
let types = `'${operator.type}'`;
|
||||
if (rightType !== operator.type) {
|
||||
types = `'${operator.type}' and '${rightType}' respectively`;
|
||||
}
|
||||
if (strict) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new FilterError(
|
||||
`${providedValues} '${leftValueString}' and '${rightValueString}'${inCondition}are not of the expected type ${types} ${itemSuffix}`,
|
||||
errorDescription,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: new FilterError(
|
||||
`${providedValues} '${leftValueString}' and '${rightValueString}'${inCondition}cannot be converted to the expected type ${types} ${itemSuffix}`,
|
||||
errorDescription,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const composeInvalidTypeMessage = (field: 'left' | 'right', type: string, value: string) => {
|
||||
const fieldNumber = field === 'left' ? 1 : 2;
|
||||
|
||||
if (strict) {
|
||||
return `The provided value ${fieldNumber} '${value}'${inCondition}is not of the expected type '${type}' ${itemSuffix}`;
|
||||
}
|
||||
return `The provided value ${fieldNumber} '${value}'${inCondition}cannot be converted to the expected type '${type}' ${itemSuffix}`;
|
||||
};
|
||||
|
||||
if (!leftValid) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new FilterError(
|
||||
composeInvalidTypeMessage('left', operator.type, leftValueString),
|
||||
errorDescription,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!rightValid) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new FilterError(
|
||||
composeInvalidTypeMessage('right', rightType, rightValueString),
|
||||
errorDescription,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, result: { left: parsedLeftValue.newValue, right: parsedRightValue.newValue } };
|
||||
}
|
||||
|
||||
export function executeFilterCondition(
|
||||
condition: FilterConditionValue,
|
||||
filterOptions: FilterOptionsValue,
|
||||
metadata: Partial<FilterConditionMetadata> = {},
|
||||
): boolean {
|
||||
const ignoreCase = !filterOptions.caseSensitive;
|
||||
const { operator } = condition;
|
||||
const parsedValues = parseFilterConditionValues(condition, filterOptions, metadata);
|
||||
|
||||
if (!parsedValues.ok) {
|
||||
throw parsedValues.error;
|
||||
}
|
||||
|
||||
let { left: leftValue, right: rightValue } = parsedValues.result;
|
||||
|
||||
const exists = leftValue !== undefined && leftValue !== null;
|
||||
if (condition.operator.operation === 'exists') {
|
||||
return exists;
|
||||
} else if (condition.operator.operation === 'notExists') {
|
||||
return !exists;
|
||||
}
|
||||
|
||||
switch (operator.type) {
|
||||
case 'string': {
|
||||
if (ignoreCase) {
|
||||
if (typeof leftValue === 'string') {
|
||||
leftValue = leftValue.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
if (
|
||||
typeof rightValue === 'string' &&
|
||||
!(condition.operator.operation === 'regex' || condition.operator.operation === 'notRegex')
|
||||
) {
|
||||
rightValue = rightValue.toLocaleLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
const left = (leftValue ?? '') as string;
|
||||
const right = (rightValue ?? '') as string;
|
||||
|
||||
switch (condition.operator.operation) {
|
||||
case 'equals':
|
||||
return left === right;
|
||||
case 'notEquals':
|
||||
return left !== right;
|
||||
case 'contains':
|
||||
return left.includes(right);
|
||||
case 'notContains':
|
||||
return !left.includes(right);
|
||||
case 'startsWith':
|
||||
return left.startsWith(right);
|
||||
case 'notStartsWith':
|
||||
return !left.startsWith(right);
|
||||
case 'endsWith':
|
||||
return left.endsWith(right);
|
||||
case 'notEndsWith':
|
||||
return !left.endsWith(right);
|
||||
case 'regex':
|
||||
return new RegExp(right).test(left);
|
||||
case 'notRegex':
|
||||
return !new RegExp(right).test(left);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'number': {
|
||||
const left = leftValue as number;
|
||||
const right = rightValue as number;
|
||||
|
||||
switch (condition.operator.operation) {
|
||||
case 'equals':
|
||||
return left === right;
|
||||
case 'notEquals':
|
||||
return left !== right;
|
||||
case 'gt':
|
||||
return left > right;
|
||||
case 'lt':
|
||||
return left < right;
|
||||
case 'gte':
|
||||
return left >= right;
|
||||
case 'lte':
|
||||
return left <= right;
|
||||
}
|
||||
}
|
||||
case 'dateTime': {
|
||||
const left = leftValue as DateTime;
|
||||
const right = rightValue as DateTime;
|
||||
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (condition.operator.operation) {
|
||||
case 'equals':
|
||||
return left.toMillis() === right.toMillis();
|
||||
case 'notEquals':
|
||||
return left.toMillis() !== right.toMillis();
|
||||
case 'after':
|
||||
return left.toMillis() > right.toMillis();
|
||||
case 'before':
|
||||
return left.toMillis() < right.toMillis();
|
||||
case 'afterOrEquals':
|
||||
return left.toMillis() >= right.toMillis();
|
||||
case 'beforeOrEquals':
|
||||
return left.toMillis() <= right.toMillis();
|
||||
}
|
||||
}
|
||||
case 'boolean': {
|
||||
const left = leftValue as boolean;
|
||||
const right = rightValue as boolean;
|
||||
|
||||
switch (condition.operator.operation) {
|
||||
case 'true':
|
||||
return left;
|
||||
case 'false':
|
||||
return !left;
|
||||
case 'equals':
|
||||
return left === right;
|
||||
case 'notEquals':
|
||||
return left !== right;
|
||||
}
|
||||
}
|
||||
case 'array': {
|
||||
const left = (leftValue ?? []) as unknown[];
|
||||
const rightNumber = rightValue as number;
|
||||
|
||||
switch (condition.operator.operation) {
|
||||
case 'contains':
|
||||
if (ignoreCase && typeof rightValue === 'string') {
|
||||
rightValue = rightValue.toLocaleLowerCase();
|
||||
}
|
||||
return left.includes(rightValue);
|
||||
case 'notContains':
|
||||
if (ignoreCase && typeof rightValue === 'string') {
|
||||
rightValue = rightValue.toLocaleLowerCase();
|
||||
}
|
||||
return !left.includes(rightValue);
|
||||
case 'lengthEquals':
|
||||
return left.length === rightNumber;
|
||||
case 'lengthNotEquals':
|
||||
return left.length !== rightNumber;
|
||||
case 'lengthGt':
|
||||
return left.length > rightNumber;
|
||||
case 'lengthLt':
|
||||
return left.length < rightNumber;
|
||||
case 'lengthGte':
|
||||
return left.length >= rightNumber;
|
||||
case 'lengthLte':
|
||||
return left.length <= rightNumber;
|
||||
case 'empty':
|
||||
return left.length === 0;
|
||||
case 'notEmpty':
|
||||
return left.length !== 0;
|
||||
}
|
||||
}
|
||||
case 'object': {
|
||||
const left = leftValue;
|
||||
|
||||
switch (condition.operator.operation) {
|
||||
case 'empty':
|
||||
return !!left && Object.keys(left).length === 0;
|
||||
case 'notEmpty':
|
||||
return !!left && Object.keys(left).length !== 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoggerProxy.warn(`Unknown filter parameter operator "${operator.type}:${operator.operation}"`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
type ExecuteFilterOptions = {
|
||||
itemIndex?: number;
|
||||
};
|
||||
export function executeFilter(
|
||||
value: FilterValue,
|
||||
{ itemIndex }: ExecuteFilterOptions = {},
|
||||
): boolean {
|
||||
const conditionPass = (condition: FilterConditionValue, index: number) =>
|
||||
executeFilterCondition(condition, value.options, { index, itemIndex });
|
||||
|
||||
if (value.combinator === 'and') {
|
||||
return value.conditions.every(conditionPass);
|
||||
} else if (value.combinator === 'or') {
|
||||
return value.conditions.some(conditionPass);
|
||||
}
|
||||
|
||||
LoggerProxy.warn(`Unknown filter combinator "${value.combinator as string}"`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const validateFilterParameter = (
|
||||
nodeProperties: INodeProperties,
|
||||
value: FilterValue,
|
||||
): Record<string, string[]> => {
|
||||
return value.conditions.reduce(
|
||||
(issues, condition, index) => {
|
||||
const key = `${nodeProperties.name}.${index}`;
|
||||
|
||||
try {
|
||||
parseFilterConditionValues(condition, value.options, {
|
||||
index,
|
||||
unresolvedExpressions: true,
|
||||
errorFormat: 'inline',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof FilterError) {
|
||||
issues[key].push(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
);
|
||||
};
|
||||
232
packages/workflow/src/TypeValidation.ts
Normal file
232
packages/workflow/src/TypeValidation.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces';
|
||||
import isObject from 'lodash/isObject';
|
||||
import { ApplicationError } from './errors';
|
||||
|
||||
export const tryToParseNumber = (value: unknown): number => {
|
||||
const isValidNumber = !isNaN(Number(value));
|
||||
|
||||
if (!isValidNumber) {
|
||||
throw new ApplicationError('Failed to parse value to number', { extra: { value } });
|
||||
}
|
||||
return Number(value);
|
||||
};
|
||||
|
||||
export const tryToParseString = (value: unknown): string => {
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
if (typeof value === 'undefined') return '';
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'bigint' ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number'
|
||||
) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const tryToParseBoolean = (value: unknown): value is boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
|
||||
return value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
// If value is not a empty string, try to parse it to a number
|
||||
if (!(typeof value === 'string' && value.trim() === '')) {
|
||||
const num = Number(value);
|
||||
if (num === 0) {
|
||||
return false;
|
||||
} else if (num === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApplicationError('Failed to parse value as boolean', {
|
||||
extra: { value },
|
||||
});
|
||||
};
|
||||
|
||||
export const tryToParseDateTime = (value: unknown): DateTime => {
|
||||
const dateString = String(value).trim();
|
||||
|
||||
// Rely on luxon to parse different date formats
|
||||
const isoDate = DateTime.fromISO(dateString, { setZone: true });
|
||||
if (isoDate.isValid) {
|
||||
return isoDate;
|
||||
}
|
||||
const httpDate = DateTime.fromHTTP(dateString, { setZone: true });
|
||||
if (httpDate.isValid) {
|
||||
return httpDate;
|
||||
}
|
||||
const rfc2822Date = DateTime.fromRFC2822(dateString, { setZone: true });
|
||||
if (rfc2822Date.isValid) {
|
||||
return rfc2822Date;
|
||||
}
|
||||
const sqlDate = DateTime.fromSQL(dateString, { setZone: true });
|
||||
if (sqlDate.isValid) {
|
||||
return sqlDate;
|
||||
}
|
||||
|
||||
throw new ApplicationError('Value is not a valid date', { extra: { dateString } });
|
||||
};
|
||||
|
||||
export const tryToParseTime = (value: unknown): string => {
|
||||
const isTimeInput = /^\d{2}:\d{2}(:\d{2})?((\-|\+)\d{4})?((\-|\+)\d{1,2}(:\d{2})?)?$/s.test(
|
||||
String(value),
|
||||
);
|
||||
if (!isTimeInput) {
|
||||
throw new ApplicationError('Value is not a valid time', { extra: { value } });
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const tryToParseArray = (value: unknown): unknown[] => {
|
||||
try {
|
||||
if (typeof value === 'object' && Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let parsed: unknown[];
|
||||
try {
|
||||
parsed = JSON.parse(String(value)) as unknown[];
|
||||
} catch (e) {
|
||||
parsed = JSON.parse(String(value).replace(/'/g, '"')) as unknown[];
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new ApplicationError('Value is not a valid array', { extra: { value } });
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
throw new ApplicationError('Value is not a valid array', { extra: { value } });
|
||||
}
|
||||
};
|
||||
|
||||
export const tryToParseObject = (value: unknown): object => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const o = JSON.parse(String(value)) as object;
|
||||
if (typeof o !== 'object' || Array.isArray(o)) {
|
||||
throw new ApplicationError('Value is not a valid object', { extra: { value } });
|
||||
}
|
||||
return o;
|
||||
} catch (e) {
|
||||
throw new ApplicationError('Value is not a valid object', { extra: { value } });
|
||||
}
|
||||
};
|
||||
|
||||
type ValidateFieldTypeOptions = Partial<{
|
||||
valueOptions: INodePropertyOptions[];
|
||||
strict: boolean;
|
||||
parseStrings: boolean;
|
||||
}>;
|
||||
// Validates field against the schema and tries to parse it to the correct type
|
||||
export const validateFieldType = (
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
type: FieldType,
|
||||
options: ValidateFieldTypeOptions = {},
|
||||
): ValidationResult => {
|
||||
if (value === null || value === undefined) return { valid: true };
|
||||
const strict = options.strict ?? false;
|
||||
const valueOptions = options.valueOptions ?? [];
|
||||
const parseStrings = options.parseStrings ?? false;
|
||||
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got '${String(value)}'`;
|
||||
switch (type.toLowerCase()) {
|
||||
case 'string': {
|
||||
if (!parseStrings) return { valid: true, newValue: value };
|
||||
try {
|
||||
if (strict && typeof value !== 'string') {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
return { valid: true, newValue: tryToParseString(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'number': {
|
||||
try {
|
||||
if (strict && typeof value !== 'number') {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
return { valid: true, newValue: tryToParseNumber(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'boolean': {
|
||||
try {
|
||||
if (strict && typeof value !== 'boolean') {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
return { valid: true, newValue: tryToParseBoolean(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'datetime': {
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseDateTime(value) };
|
||||
} catch (e) {
|
||||
const luxonDocsURL =
|
||||
'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat';
|
||||
const errorMessage = `${defaultErrorMessage} <br/><br/> Consider using <a href="${luxonDocsURL}" target="_blank"><code>DateTime.fromFormat</code></a> to work with custom date formats.`;
|
||||
return { valid: false, errorMessage };
|
||||
}
|
||||
}
|
||||
case 'time': {
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseTime(value) };
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
errorMessage: `'${fieldName}' expects time (hh:mm:(:ss)) but we got '${String(value)}'.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
case 'object': {
|
||||
try {
|
||||
if (strict && !isObject(value)) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
return { valid: true, newValue: tryToParseObject(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'array': {
|
||||
if (strict && !Array.isArray(value)) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
try {
|
||||
return { valid: true, newValue: tryToParseArray(value) };
|
||||
} catch (e) {
|
||||
return { valid: false, errorMessage: defaultErrorMessage };
|
||||
}
|
||||
}
|
||||
case 'options': {
|
||||
const validOptions = valueOptions.map((option) => option.value).join(', ');
|
||||
const isValidOption = valueOptions.some((option) => option.value === value);
|
||||
|
||||
if (!isValidOption) {
|
||||
return {
|
||||
valid: false,
|
||||
errorMessage: `'${fieldName}' expects one of the following values: [${validOptions}] but we got '${String(
|
||||
value,
|
||||
)}'`,
|
||||
};
|
||||
}
|
||||
return { valid: true, newValue: value };
|
||||
}
|
||||
default: {
|
||||
return { valid: true, newValue: value };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export * from './Workflow';
|
||||
export * from './WorkflowDataProxy';
|
||||
export * from './WorkflowHooks';
|
||||
export * from './VersionedNodeType';
|
||||
export * from './TypeValidation';
|
||||
export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers };
|
||||
export {
|
||||
isObjectEmpty,
|
||||
@@ -40,11 +41,13 @@ export {
|
||||
isINodePropertyCollectionList,
|
||||
isINodePropertyOptionsList,
|
||||
isResourceMapperValue,
|
||||
isFilterValue,
|
||||
} from './type-guards';
|
||||
|
||||
export { ExpressionExtensions } from './Extensions';
|
||||
export * as ExpressionParser from './Extensions/ExpressionParser';
|
||||
export { NativeMethods } from './NativeMethods';
|
||||
export * from './NodeParameters/FilterParameter';
|
||||
|
||||
export type { DocMetadata, NativeDoc } from './Extensions';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
INodePropertyCollection,
|
||||
INodeParameterResourceLocator,
|
||||
ResourceMapperValue,
|
||||
FilterValue,
|
||||
} from './Interfaces';
|
||||
|
||||
export const isINodeProperties = (
|
||||
@@ -54,3 +55,9 @@ export const isResourceMapperValue = (value: unknown): value is ResourceMapperVa
|
||||
'value' in value
|
||||
);
|
||||
};
|
||||
|
||||
export const isFilterValue = (value: unknown): value is FilterValue => {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && 'conditions' in value && 'combinator' in value
|
||||
);
|
||||
};
|
||||
|
||||
1012
packages/workflow/test/FilterParameter.test.ts
Normal file
1012
packages/workflow/test/FilterParameter.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { validateFieldType } from '@/NodeHelpers';
|
||||
import { validateFieldType } from '@/TypeValidation';
|
||||
import type { DateTime } from 'luxon';
|
||||
|
||||
const VALID_ISO_DATES = [
|
||||
@@ -174,16 +174,20 @@ describe('Type Validation', () => {
|
||||
|
||||
it('should validate options properly', () => {
|
||||
expect(
|
||||
validateFieldType('options', 'oranges', 'options', [
|
||||
{ name: 'apples', value: 'apples' },
|
||||
{ name: 'oranges', value: 'oranges' },
|
||||
]).valid,
|
||||
validateFieldType('options', 'oranges', 'options', {
|
||||
valueOptions: [
|
||||
{ name: 'apples', value: 'apples' },
|
||||
{ name: 'oranges', value: 'oranges' },
|
||||
],
|
||||
}).valid,
|
||||
).toEqual(true);
|
||||
expect(
|
||||
validateFieldType('options', 'something else', 'options', [
|
||||
{ name: 'apples', value: 'apples' },
|
||||
{ name: 'oranges', value: 'oranges' },
|
||||
]).valid,
|
||||
validateFieldType('options', 'something else', 'options', {
|
||||
valueOptions: [
|
||||
{ name: 'apples', value: 'apples' },
|
||||
{ name: 'oranges', value: 'oranges' },
|
||||
],
|
||||
}).valid,
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
@@ -202,4 +206,24 @@ describe('Type Validation', () => {
|
||||
expect(validateFieldType('time', '23:23:', 'time').valid).toEqual(false);
|
||||
expect(validateFieldType('time', '23::23::23', 'time').valid).toEqual(false);
|
||||
});
|
||||
|
||||
describe('options', () => {
|
||||
describe('strict=true', () => {
|
||||
it('should not convert/cast types', () => {
|
||||
const options = { strict: true };
|
||||
expect(validateFieldType('test', '42', 'number', options).valid).toBe(false);
|
||||
expect(validateFieldType('test', 'true', 'boolean', options).valid).toBe(false);
|
||||
expect(validateFieldType('test', [], 'object', options).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStrings=true', () => {
|
||||
it('should parse strings from other types', () => {
|
||||
const options = { parseStrings: true };
|
||||
expect(validateFieldType('test', 42, 'string').newValue).toBe(42);
|
||||
expect(validateFieldType('test', 42, 'string', options).newValue).toBe('42');
|
||||
expect(validateFieldType('test', true, 'string', options).newValue).toBe('true');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user