first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableRowReturn,
|
||||
IDataTableProjectService,
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeProperties,
|
||||
DataTableColumnType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ALL_CONDITIONS, ANY_CONDITION, ROWS_LIMIT_DEFAULT, type FilterType } from './constants';
|
||||
import { DATA_TABLE_ID_FIELD } from './fields';
|
||||
import { buildGetManyFilter, isFieldArray, isMatchType, getDataTableProxyExecute } from './utils';
|
||||
|
||||
/**
|
||||
* Recursively converts Date objects to ISO strings in an object
|
||||
* This ensures that all output data is JSON-compatible
|
||||
*/
|
||||
function convertDatesToIsoStrings<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return obj.toISOString() as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(convertDatesToIsoStrings) as T;
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const converted: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
converted[key] = convertDatesToIsoStrings(value);
|
||||
}
|
||||
return converted as T;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function getSelectFields(
|
||||
displayOptions: IDisplayOptions,
|
||||
requireCondition = false,
|
||||
skipOperator = false,
|
||||
): INodeProperties[] {
|
||||
return [
|
||||
{
|
||||
displayName: 'Must Match',
|
||||
name: 'matchType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Any Condition',
|
||||
value: ANY_CONDITION,
|
||||
},
|
||||
{
|
||||
name: 'All Conditions',
|
||||
value: ALL_CONDITIONS,
|
||||
},
|
||||
] satisfies Array<{ value: FilterType; name: string }>,
|
||||
displayOptions,
|
||||
default: ANY_CONDITION,
|
||||
},
|
||||
{
|
||||
displayName: 'Conditions',
|
||||
name: 'filters',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
minRequiredFields: requireCondition ? 1 : 0,
|
||||
},
|
||||
displayOptions,
|
||||
default: {},
|
||||
placeholder: 'Add Condition',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Conditions',
|
||||
name: 'conditions',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'keyName',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: [`${DATA_TABLE_ID_FIELD}.value`],
|
||||
loadOptionsMethod: 'getDataTableColumns',
|
||||
},
|
||||
default: 'id',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Condition',
|
||||
name: 'condition',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-missing-from-dynamic-options
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['&keyName'],
|
||||
loadOptionsMethod: 'getConditionsForColumn',
|
||||
},
|
||||
default: 'eq',
|
||||
displayOptions: skipOperator
|
||||
? {
|
||||
show: { '@version': [{ _cnd: { lt: 0 } }] },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'keyValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
condition: ['isEmpty', 'isNotEmpty', 'isTrue', 'isFalse'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
description: 'Filter to decide which rows get',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function getSelectFilter(
|
||||
ctx: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<DataTableFilter> {
|
||||
const fields = ctx.getNodeParameter('filters.conditions', index, []);
|
||||
const matchType = ctx.getNodeParameter('matchType', index, ANY_CONDITION);
|
||||
const node = ctx.getNode();
|
||||
|
||||
if (!isMatchType(matchType)) {
|
||||
throw new NodeOperationError(node, 'unexpected match type');
|
||||
}
|
||||
if (!isFieldArray(fields)) {
|
||||
throw new NodeOperationError(node, 'unexpected fields input');
|
||||
}
|
||||
|
||||
// Validate filter conditions against current table schema
|
||||
let allColumnsWithTypes: Record<string, DataTableColumnType> = DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP;
|
||||
|
||||
if (fields.length > 0) {
|
||||
const dataTableProxy = await getDataTableProxyExecute(ctx, index);
|
||||
const availableColumns = await dataTableProxy.getColumns();
|
||||
|
||||
// Add system columns with their types
|
||||
allColumnsWithTypes = {
|
||||
...DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP,
|
||||
...Object.fromEntries(availableColumns.map((col) => [col.name, col.type])),
|
||||
};
|
||||
|
||||
const invalidConditions = fields.filter((field) => !allColumnsWithTypes[field.keyName]);
|
||||
|
||||
if (invalidConditions.length > 0) {
|
||||
const invalidColumnNames = invalidConditions.map((c) => c.keyName).join(', ');
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Filter validation failed: Column(s) "${invalidColumnNames}" do not exist in the selected table. ` +
|
||||
'This often happens when switching between tables with different schemas. ' +
|
||||
'Please update your filter conditions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return buildGetManyFilter(fields, matchType, allColumnsWithTypes, node);
|
||||
}
|
||||
|
||||
export async function executeSelectMany(
|
||||
ctx: IExecuteFunctions,
|
||||
index: number,
|
||||
dataTableProxy: IDataTableProjectService,
|
||||
rejectEmpty = false,
|
||||
limit?: number,
|
||||
sortBy?: [string, 'ASC' | 'DESC'],
|
||||
): Promise<Array<{ json: DataTableRowReturn }>> {
|
||||
const filter = await getSelectFilter(ctx, index);
|
||||
|
||||
if (rejectEmpty && filter.filters.length === 0) {
|
||||
throw new NodeOperationError(ctx.getNode(), 'At least one condition is required');
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 1000;
|
||||
const result: Array<{ json: DataTableRowReturn }> = [];
|
||||
|
||||
const returnAll = ctx.getNodeParameter('returnAll', index, false);
|
||||
limit = limit ?? (!returnAll ? ctx.getNodeParameter('limit', index, ROWS_LIMIT_DEFAULT) : 0);
|
||||
|
||||
const nodeVersion = ctx.getNode().typeVersion;
|
||||
const shouldConvertDates = nodeVersion >= 1.1;
|
||||
|
||||
let expectedTotal: number | undefined;
|
||||
let skip = 0;
|
||||
let take = PAGE_SIZE;
|
||||
|
||||
while (true) {
|
||||
const { data, count } = await dataTableProxy.getManyRowsAndCount({
|
||||
skip,
|
||||
take: limit ? Math.min(take, limit - result.length) : take,
|
||||
filter,
|
||||
sortBy,
|
||||
});
|
||||
const wrapped = data.map((json) => ({
|
||||
json: shouldConvertDates ? convertDatesToIsoStrings(json) : json,
|
||||
}));
|
||||
|
||||
// Fast path: everything fits in a single page
|
||||
if (skip === 0 && count === data.length) {
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
// Ensure the total doesn't change mid-pagination
|
||||
if (expectedTotal !== undefined && count !== expectedTotal) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'synchronization error: result count changed during pagination',
|
||||
);
|
||||
}
|
||||
expectedTotal = count;
|
||||
|
||||
result.push.apply(result, wrapped);
|
||||
|
||||
// Stop if we've hit the limit
|
||||
if (limit && result.length >= limit) break;
|
||||
|
||||
// Stop if we've collected everything
|
||||
if (result.length >= count) break;
|
||||
|
||||
skip = result.length;
|
||||
take = Math.min(PAGE_SIZE, count - result.length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user