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(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 = {}; 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 expression', 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 { 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 = 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> { 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; }