From 6f2c1efc2fa9e098b9d9f0e75329c10c5522be1e Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Thu, 22 Jan 2026 14:23:11 +0000 Subject: [PATCH] feat(ai-builder): Providing instructions from workflow builder on creation of data tables (#24595) --- .../src/agents/responder.agent.ts | 10 + .../src/prompts/agents/builder.prompt.ts | 27 +++ .../src/prompts/agents/configurator.prompt.ts | 22 +++ .../prompts/agents/responder.prompt.test.ts | 27 +++ .../src/prompts/agents/responder.prompt.ts | 57 +++++- .../src/utils/data-table-helpers.ts | 174 ++++++++++++++++++ .../src/utils/test/data-table-helpers.test.ts | 157 ++++++++++++++++ .../src/validation/checks/connections.test.ts | 109 +++++++++++ .../src/validation/checks/connections.ts | 60 +++++- .../src/validation/types.ts | 1 + .../nodes/DataTable/actions/router.ts | 12 +- packages/workflow/src/data-table.types.ts | 19 ++ 12 files changed, 668 insertions(+), 7 deletions(-) create mode 100644 packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.test.ts create mode 100644 packages/@n8n/ai-workflow-builder.ee/src/utils/data-table-helpers.ts create mode 100644 packages/@n8n/ai-workflow-builder.ee/src/utils/test/data-table-helpers.test.ts create mode 100644 packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.test.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/agents/responder.agent.ts b/packages/@n8n/ai-workflow-builder.ee/src/agents/responder.agent.ts index 962304b59bd..2dc5b8132b7 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/agents/responder.agent.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/agents/responder.agent.ts @@ -9,6 +9,7 @@ import { buildRecursionErrorWithWorkflowGuidance, buildRecursionErrorNoWorkflowGuidance, buildGeneralErrorGuidance, + buildDataTableCreationGuidance, } from '@/prompts/agents/responder.prompt'; import type { CoordinationLogEntry } from '../types/coordination'; @@ -21,6 +22,7 @@ import { getConfiguratorOutput, hasRecursionErrorsCleared, } from '../utils/coordination-log'; +import { extractDataTableInfo } from '../utils/data-table-helpers'; const systemPrompt = ChatPromptTemplate.fromMessages([ [ @@ -141,6 +143,14 @@ export class ResponderAgent { contextParts.push(`**Configuration:**\n${configuratorOutput}`); } + // Data Table creation guidance + // If the workflow contains Data Table nodes, inform user they need to create tables manually + const dataTableInfo = extractDataTableInfo(context.workflowJSON); + if (dataTableInfo.length > 0) { + const dataTableGuidance = buildDataTableCreationGuidance(dataTableInfo); + contextParts.push(dataTableGuidance); + } + if (contextParts.length === 0) { return null; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/builder.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/builder.prompt.ts index cc7a961cb0c..40c5e57f0da 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/builder.prompt.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/builder.prompt.ts @@ -5,9 +5,13 @@ * Does NOT configure node parameters - that's the Configurator Agent's job. */ +import { DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS } from '@/utils/data-table-helpers'; + import { prompt } from '../builder'; import { structuredOutputParser } from '../shared/node-guidance'; +const dataTableColumnOperationsList = DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS.join(', '); + const BUILDER_ROLE = 'You are a Builder Agent specialized in constructing n8n workflows.'; const EXECUTION_SEQUENCE = `You MUST follow these steps IN ORDER. Do not skip any step. @@ -342,6 +346,28 @@ Common mistake to avoid: - NEVER connect Document Loader to main data outputs - Document Loader is an AI sub-node that gives Vector Store document processing capability`; +const DATA_TABLE_PATTERN = `DATA TABLE NODE PATTERN: + +**Row Column Operations (${dataTableColumnOperationsList}) - REQUIRE Set Node:** +When using Data Table nodes for row column operations, you MUST add a Set node immediately before the Data Table node. + +Structure: Set Node → Data Table Node (operation: ${dataTableColumnOperationsList}) + +Why: The Set node defines the columns/fields to write. This tells users exactly which columns to create in their Data Table. + +Example for storing data: +- add_nodes(nodeType: "n8n-nodes-base.set", name: "Prepare User Data") +- add_nodes(nodeType: "n8n-nodes-base.dataTable", name: "Store Users", initialParameters: {{ operation: "insert" }}) +- connect_nodes(source: "Prepare User Data", target: "Store Users") + +**Row Read Operations (get, getAll, delete) - NO Set Node needed:** +Read and delete operations don't write data, so they don't need a Set node before them. + +Example for reading data: +- add_nodes(nodeType: "n8n-nodes-base.dataTable", name: "Get Users", initialParameters: {{ operation: "get" }}) + +IMPORTANT: For ${dataTableColumnOperationsList} operations, NEVER connect a Data Table node directly to other nodes without a Set node in between.`; + const SWITCH_NODE_PATTERN = `For Switch nodes with multiple routing paths: - The number of outputs is determined by the number of entries in rules.values[] - You MUST create the rules.values[] array with placeholder entries for each output branch @@ -504,6 +530,7 @@ export function buildBuilderPrompt(): string { .section('multi_trigger_workflows', MULTI_TRIGGER_WORKFLOWS) .section('shared_memory_pattern', SHARED_MEMORY_PATTERN) .section('rag_workflow_pattern', RAG_PATTERN) + .section('data_table_pattern', DATA_TABLE_PATTERN) .section('switch_node_pattern', SWITCH_NODE_PATTERN) .section('node_connection_examples', NODE_CONNECTION_EXAMPLES) .section('connection_type_examples', CONNECTION_TYPES) diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/configurator.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/configurator.prompt.ts index 49c52c45709..50bfde4d7ba 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/configurator.prompt.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/configurator.prompt.ts @@ -5,8 +5,12 @@ * Uses natural language instructions to configure each node's settings. */ +import { DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS } from '@/utils/data-table-helpers'; + import { prompt } from '../builder'; +const dataTableColumnOperationsList = DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS.join(', '); + const CONFIGURATOR_ROLE = 'You are a Configurator Agent specialized in setting up n8n node parameters.'; @@ -108,6 +112,23 @@ const CRITICAL_PARAMETERS = `- HTTP Request: URL, method, headers (if auth neede - AI nodes: Prompts, models, configurations - Tool nodes: Use $fromAI for dynamic recipient/subject/message fields`; +const DATA_TABLE_CONFIGURATION = `DATA TABLE NODE CONFIGURATION: +When configuring Data Table nodes (n8n-nodes-base.dataTable): + +**For Row Column Operations (${dataTableColumnOperationsList}):** +- There MUST be a Set node (n8n-nodes-base.set) immediately before the Data Table node +- Configure the Set node with all the fields the user wants to store +- Use a PLACEHOLDER for dataTableId (e.g., "<__PLACEHOLDER_VALUE__data_table_name__>") +- Use columns.mappingMode: "autoMapInputData" (this maps columns from the preceding Set node) +- Example: "Set dataTableId to placeholder <__PLACEHOLDER_VALUE__my_table__>, set columns mapping mode to autoMapInputData" + +**For Row Read Operations (get, getAll, delete):** +- No Set node is required before the Data Table node +- Still use a PLACEHOLDER for dataTableId +- Configure any filter or query parameters as needed + +WHY: Data Tables must be created manually by the user. Using a placeholder ensures users know to create and select their table. For column operations, the Set node defines what columns to create.`; + const DEFAULT_VALUES_GUIDE = `PRINCIPLE: User requests ALWAYS take precedence. When user specifies a model, parameter, or value - use exactly what they requested. SAFE DEFAULTS - Trust these unless user specifies otherwise: @@ -225,6 +246,7 @@ export function buildConfiguratorPrompt(): string { .section('expression_techniques', EXPRESSION_TECHNIQUES) .section('tool_node_expressions', TOOL_NODE_EXPRESSIONS) .section('critical_parameters', CRITICAL_PARAMETERS) + .section('data_table_configuration', DATA_TABLE_CONFIGURATION) .section('default_values_guide', DEFAULT_VALUES_GUIDE) .section('switch_node_configuration', SWITCH_NODE_CONFIGURATION) .section('node_configuration_examples', NODE_CONFIGURATION_EXAMPLES) diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.test.ts new file mode 100644 index 00000000000..e496bde9808 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.test.ts @@ -0,0 +1,27 @@ +import type { DataTableInfo } from '@/utils/data-table-helpers'; + +import { buildDataTableCreationGuidance } from './responder.prompt'; + +describe('buildDataTableCreationGuidance', () => { + it('should include column definitions from Set node for row column operations', () => { + const dataTables: DataTableInfo[] = [ + { + nodeName: 'Store Users', + tableName: 'users', + columns: [ + { name: 'email', type: 'text' }, + { name: 'age', type: 'number' }, + ], + setNodeName: 'Prepare User Data', + operation: 'insert', + }, + ]; + + const guidance = buildDataTableCreationGuidance(dataTables); + + expect(guidance).toContain('Data Table Setup Required'); + expect(guidance).toContain('`email` (text)'); + expect(guidance).toContain('`age` (number)'); + expect(guidance).toContain('Prepare User Data'); + }); +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.ts index eb11584914b..6a0464288d5 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.ts @@ -5,6 +5,8 @@ * Also handles conversational queries and explanations. */ +import { type DataTableInfo, isDataTableRowColumnOperation } from '@/utils/data-table-helpers'; + import { prompt } from '../builder'; const RESPONDER_ROLE = `You are a helpful AI assistant for n8n workflow automation. @@ -18,8 +20,9 @@ const WORKFLOW_COMPLETION = `When you receive [Internal Context], synthesize a c 1. Summarize what was built in a friendly way 2. Explain the workflow structure briefly 3. Include setup instructions if provided -4. Ask if user wants adjustments -5. Do not tell user to activate/publish their workflow, because they will do this themselves when they are ready. +4. If Data Table setup is required, include the exact steps provided in the context (do NOT say data tables will be created automatically) +5. Ask if user wants adjustments +6. Do not tell user to activate/publish their workflow, because they will do this themselves when they are ready. Example response structure: "I've created your [workflow type] workflow! Here's what it does: @@ -28,6 +31,8 @@ Example response structure: **Setup Required:** [List any configuration steps from the context] +[If data tables are used, include Data Table creation steps with link to Data Tables tab] + Let me know if you'd like to adjust anything."`; const CONVERSATIONAL_RESPONSES = `- Be friendly and concise @@ -78,6 +83,54 @@ export function buildGeneralErrorGuidance(): string { ); } +/** + * Build guidance for data table creation. + * Data tables must be created manually - the AI workflow builder cannot create them automatically. + */ +export function buildDataTableCreationGuidance(dataTables: DataTableInfo[]): string { + if (dataTables.length === 0) { + return ''; + } + + const tableGuidance = dataTables.map((table) => { + const isColumnOperation = isDataTableRowColumnOperation(table.operation); + const columnInfo = buildColumnInfo(table, isColumnOperation); + return `- **${table.nodeName}** (${table.operation}): ${columnInfo}`; + }); + + return prompt({ format: 'markdown' }) + .section( + 'Data Table Setup Required', + `Data tables must be created manually before the workflow can run. +Do NOT tell the user that data tables will be created automatically. + +Go to the [Data Tables tab](/home/datatables) to create the required tables: + +${tableGuidance.join('\n')} + +After creating each table, select it in the corresponding Data Table node.`, + ) + .build(); +} + +function buildColumnInfo(table: DataTableInfo, isColumnOperation: boolean): string { + if (!isColumnOperation) { + return `Ensure the table has columns for reading/querying (uses "${table.operation}" operation)`; + } + + if (table.columns.length > 0) { + const columnList = table.columns.map((c) => `\`${c.name}\` (${c.type})`).join(', '); + const source = table.setNodeName ? ` (from "${table.setNodeName}" node)` : ''; + return `Add columns: ${columnList}${source}`; + } + + if (table.setNodeName) { + return `Add columns matching the fields in the "${table.setNodeName}" node`; + } + + return 'Add columns based on the data you want to store'; +} + export function buildResponderPrompt(): string { return prompt() .section('role', RESPONDER_ROLE) diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/data-table-helpers.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/data-table-helpers.ts new file mode 100644 index 00000000000..9a40534ce17 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/data-table-helpers.ts @@ -0,0 +1,174 @@ +/** + * Data Table Helpers + * + * Utility functions for extracting and processing Data Table information + * from workflow JSON for use in the responder agent. + */ +import { + getParentNodes, + mapConnectionsByDestination, + type DataTableRowOperation, +} from 'n8n-workflow'; + +import type { SimpleWorkflow } from '../types'; + +export const DATA_TABLE_NODE_TYPE = 'n8n-nodes-base.dataTable'; +export const SET_NODE_TYPE = 'n8n-nodes-base.set'; + +/** Row operations that require column definitions (from a preceding Set node) */ +export const DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS: readonly DataTableRowOperation[] = [ + 'insert', + 'update', + 'upsert', +]; + +export type DataTableRowColumnOperation = (typeof DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS)[number]; + +/** Type guard to check if an operation requires column definitions */ +export function isDataTableRowColumnOperation( + operation: string, +): operation is DataTableRowColumnOperation { + return (DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS as readonly string[]).includes(operation); +} + +/** + * Column definition with name and type + */ +export interface ColumnDefinition { + name: string; + type: string; +} + +/** + * Information about a Data Table node in the workflow + */ +export interface DataTableInfo { + /** The node name in the workflow */ + nodeName: string; + /** The table name/ID (may be a placeholder) */ + tableName?: string; + /** Column definitions inferred from the preceding Set node */ + columns: ColumnDefinition[]; + /** The name of the Set node that defines the columns (if found) */ + setNodeName?: string; + /** The operation (insert, update, upsert, get, delete) */ + operation: DataTableRowOperation; +} + +/** + * Map Set node field types to Data Table column types + */ +function mapSetNodeTypeToDataTableType(setNodeType: string): string { + switch (setNodeType) { + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'string': + default: + return 'text'; + } +} + +interface SetNodeAssignment { + name: string; + type: string; +} + +interface SetNodeAssignments { + assignments?: SetNodeAssignment[]; +} + +function isSetNodeAssignments(value: unknown): value is SetNodeAssignments { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + if (!('assignments' in obj)) return true; // assignments is optional + if (!Array.isArray(obj.assignments)) return false; + return obj.assignments.every( + (item) => + typeof item === 'object' && + item !== null && + 'name' in item && + 'type' in item && + typeof (item as Record).name === 'string' && + typeof (item as Record).type === 'string', + ); +} + +/** + * Extract field definitions from a Set node's assignments + */ +export function extractSetNodeFields( + workflow: SimpleWorkflow, + nodeName: string, +): ColumnDefinition[] { + const node = workflow.nodes.find((n) => n.name === nodeName && n.type === SET_NODE_TYPE); + if (!node) return []; + + const params = node.parameters ?? {}; + const assignments = params.assignments; + + if (!isSetNodeAssignments(assignments) || !assignments.assignments) return []; + + return assignments.assignments + .filter((a) => a.name && a.type) + .map((a) => ({ + name: a.name, + type: mapSetNodeTypeToDataTableType(a.type), + })); +} + +/** + * Extract data table information from workflow nodes. + * Used to inform users about data tables they need to create manually. + * + * For row write operations (insert, update, upsert), the configurator agent + * is instructed to place a Set node before Data Table nodes. This function + * infers column definitions from the preceding Set node. + * + * For read operations (get, getAll, delete), no Set node is expected. + */ +export function extractDataTableInfo(workflow: SimpleWorkflow): DataTableInfo[] { + const dataTableNodes = workflow.nodes.filter((node) => node.type === DATA_TABLE_NODE_TYPE); + const connectionsByDestination = mapConnectionsByDestination(workflow.connections); + + return dataTableNodes.map((node) => { + const params = node.parameters ?? {}; + + let tableName = undefined; + const dataTableId = params.dataTableId as { value?: string } | undefined; + if (dataTableId?.value) { + tableName = dataTableId.value; + } + + // Get the operation type + const operation: DataTableRowOperation = + (params.operation as DataTableRowOperation) ?? 'insert'; + + // Only look for Set node columns on row write operations + let columns: ColumnDefinition[] = []; + let setNodeName: string | undefined = undefined; + + // Look for a set node before the data table operation - this should contain the columns + if (isDataTableRowColumnOperation(operation)) { + // Get direct predecessors (depth=1) using getParentNodes from n8n-workflow + const predecessors = getParentNodes(connectionsByDestination, node.name, 'main', 1); + for (const predecessorName of predecessors) { + const setFields = extractSetNodeFields(workflow, predecessorName); + if (setFields.length > 0) { + columns = setFields; + setNodeName = predecessorName; + break; + } + } + } + + return { + nodeName: node.name, + tableName, + columns, + setNodeName, + operation, + }; + }); +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/data-table-helpers.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/data-table-helpers.test.ts new file mode 100644 index 00000000000..52658e4c4bb --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/data-table-helpers.test.ts @@ -0,0 +1,157 @@ +import { NodeConnectionTypes } from 'n8n-workflow'; + +import type { SimpleWorkflow } from '../../types'; +import { + DATA_TABLE_NODE_TYPE, + SET_NODE_TYPE, + extractSetNodeFields, + extractDataTableInfo, +} from '../data-table-helpers'; + +describe('data-table-helpers', () => { + const createEmptyWorkflow = (): SimpleWorkflow => ({ + name: 'Test Workflow', + nodes: [], + connections: {}, + }); + + describe('extractSetNodeFields', () => { + it('should return empty array when node not found', () => { + const workflow = createEmptyWorkflow(); + expect(extractSetNodeFields(workflow, 'NonExistent')).toEqual([]); + }); + + it('should return empty array when Set node has no assignments', () => { + const workflow: SimpleWorkflow = { + name: 'Test', + nodes: [ + { + id: '1', + name: 'Set', + type: SET_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + }; + expect(extractSetNodeFields(workflow, 'Set')).toEqual([]); + }); + + it('should extract fields from Set node assignments', () => { + const workflow: SimpleWorkflow = { + name: 'Test', + nodes: [ + { + id: '1', + name: 'Set', + type: SET_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + assignments: { + assignments: [ + { name: 'email', type: 'string' }, + { name: 'age', type: 'number' }, + { name: 'active', type: 'boolean' }, + ], + }, + }, + }, + ], + connections: {}, + }; + + const fields = extractSetNodeFields(workflow, 'Set'); + expect(fields).toEqual([ + { name: 'email', type: 'text' }, + { name: 'age', type: 'number' }, + { name: 'active', type: 'boolean' }, + ]); + }); + }); + + describe('extractDataTableInfo', () => { + it('should return empty array for workflow with no Data Table nodes', () => { + const workflow = createEmptyWorkflow(); + expect(extractDataTableInfo(workflow)).toEqual([]); + }); + + it('should extract basic Data Table info', () => { + const workflow: SimpleWorkflow = { + name: 'Test', + nodes: [ + { + id: '1', + name: 'Data Table', + type: DATA_TABLE_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + dataTableId: { __rl: true, mode: 'id', value: 'my_table' }, + operation: 'insert', + }, + }, + ], + connections: {}, + }; + + const info = extractDataTableInfo(workflow); + expect(info).toHaveLength(1); + expect(info[0]).toEqual({ + nodeName: 'Data Table', + tableName: 'my_table', + columns: [], + setNodeName: undefined, + operation: 'insert', + }); + }); + + it('should infer columns from predecessor Set node', () => { + const workflow: SimpleWorkflow = { + name: 'Test', + nodes: [ + { + id: '1', + name: 'Prepare Data', + type: SET_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + assignments: { + assignments: [ + { name: 'userId', type: 'number' }, + { name: 'status', type: 'string' }, + ], + }, + }, + }, + { + id: '2', + name: 'Data Table', + type: DATA_TABLE_NODE_TYPE, + typeVersion: 1, + position: [200, 0], + parameters: { + dataTableId: { value: 'users' }, + operation: 'insert', + }, + }, + ], + connections: { + 'Prepare Data': { + main: [[{ node: 'Data Table', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }, + }; + + const info = extractDataTableInfo(workflow); + expect(info[0].columns).toEqual([ + { name: 'userId', type: 'number' }, + { name: 'status', type: 'text' }, + ]); + expect(info[0].setNodeName).toBe('Prepare Data'); + }); + }); +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.test.ts new file mode 100644 index 00000000000..6698cebe11f --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.test.ts @@ -0,0 +1,109 @@ +import type { INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import type { SimpleWorkflow } from '@/types'; +import { DATA_TABLE_NODE_TYPE, SET_NODE_TYPE } from '@/utils/data-table-helpers'; + +import { validateConnections } from './connections'; + +describe('validateConnections', () => { + const createMockNodeType = (type: string): INodeTypeDescription => + ({ + name: type, + displayName: type, + group: ['transform'], + version: 1, + description: 'Mock node', + defaults: { name: type }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [], + }) as unknown as INodeTypeDescription; + + const mockNodeTypes: INodeTypeDescription[] = [ + createMockNodeType(DATA_TABLE_NODE_TYPE), + createMockNodeType(SET_NODE_TYPE), + createMockNodeType('n8n-nodes-base.httpRequest'), + ]; + + describe('data-table-missing-set-node validation', () => { + it('should return violation when Data Table row column operation has no Set node predecessor', () => { + const workflow: SimpleWorkflow = { + name: 'Test', + nodes: [ + { + id: '1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: '2', + name: 'Store Data', + type: DATA_TABLE_NODE_TYPE, + typeVersion: 1, + position: [200, 0], + parameters: { + operation: 'insert', + }, + }, + ], + connections: { + 'HTTP Request': { + main: [[{ node: 'Store Data', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }, + }; + + const violations = validateConnections(workflow, mockNodeTypes); + + expect(violations).toContainEqual( + expect.objectContaining({ + name: 'data-table-missing-set-node', + type: 'major', + }), + ); + }); + + it('should NOT return violation when Data Table has Set node predecessor', () => { + const workflow: SimpleWorkflow = { + name: 'Test', + nodes: [ + { + id: '1', + name: 'Prepare Data', + type: SET_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: '2', + name: 'Store Data', + type: DATA_TABLE_NODE_TYPE, + typeVersion: 1, + position: [200, 0], + parameters: { + operation: 'insert', + }, + }, + ], + connections: { + 'Prepare Data': { + main: [[{ node: 'Store Data', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }, + }; + + const violations = validateConnections(workflow, mockNodeTypes); + + expect(violations).not.toContainEqual( + expect.objectContaining({ + name: 'data-table-missing-set-node', + }), + ); + }); + }); +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts b/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts index ecec7a5780c..9de0fc4a86a 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts @@ -1,7 +1,17 @@ -import type { INodeConnections, INodeTypeDescription, NodeConnectionType } from 'n8n-workflow'; -import { mapConnectionsByDestination } from 'n8n-workflow'; +import type { + IConnections, + INodeConnections, + INodeTypeDescription, + NodeConnectionType, +} from 'n8n-workflow'; +import { getParentNodes, mapConnectionsByDestination, NodeConnectionTypes } from 'n8n-workflow'; import type { SimpleWorkflow } from '@/types'; +import { + DATA_TABLE_NODE_TYPE, + isDataTableRowColumnOperation, + SET_NODE_TYPE, +} from '@/utils/data-table-helpers'; import { isSubNode } from '@/utils/node-helpers'; import { createNodeTypeMaps, getNodeTypeForNode } from '@/validation/utils/node-type-map'; import { resolveNodeInputs, resolveNodeOutputs } from '@/validation/utils/resolve-connections'; @@ -138,6 +148,48 @@ function checkMergeNodeConnections( return issues; } +function checkDataTableHasSetNodePredecessor( + connectionsByDestination: IConnections, + node: SimpleWorkflow['nodes'][number], + nodesByName: Map, +): ProgrammaticViolation[] { + if (node.type !== DATA_TABLE_NODE_TYPE) { + return []; + } + + // Only check for Set node on row column operations (insert, update, upsert) + // Read operations (get, getAll) and delete don't need a Set node + const operationParam = node.parameters?.operation; + const operation = typeof operationParam === 'string' ? operationParam : 'insert'; + if (!isDataTableRowColumnOperation(operation)) { + return []; + } + + // Check if any direct predecessor is a Set node + const predecessors = getParentNodes( + connectionsByDestination, + node.name, + NodeConnectionTypes.Main, + 1, + ); + const hasSetNodePredecessor = predecessors.some( + (name) => nodesByName.get(name)?.type === SET_NODE_TYPE, + ); + + if (hasSetNodePredecessor) { + return []; + } + + return [ + { + name: 'data-table-missing-set-node', + type: 'major', + description: `Data Table node "${node.name}" uses "${operation}" operation and should have a Set node (Edit Fields) immediately before it to define the columns. Add a Set node and connect it to the Data Table.`, + pointsDeducted: 20, + }, + ]; +} + function checkSubNodeRootConnections( workflow: SimpleWorkflow, nodeInfo: NodeResolvedConnectionTypesInfo, @@ -242,6 +294,10 @@ export function validateConnections( violations.push(...checkMergeNodeConnections(nodeInfo, nodeConnections)); violations.push(...checkSubNodeRootConnections(workflow, nodeInfo, nodesByName)); + + violations.push( + ...checkDataTableHasSetNodePredecessor(connectionsByDestination, node, nodesByName), + ); } return violations; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/validation/types.ts b/packages/@n8n/ai-workflow-builder.ee/src/validation/types.ts index 57fcc27b9aa..5c6f8240c0e 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/validation/types.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/validation/types.ts @@ -31,6 +31,7 @@ export const PROGRAMMATIC_VIOLATION_NAMES = [ 'workflow-similarity-evaluation-failed', 'http-request-hardcoded-credentials', 'set-node-credential-field', + 'data-table-missing-set-node', ] as const; export type ProgrammaticViolationName = (typeof PROGRAMMATIC_VIOLATION_NAMES)[number]; diff --git a/packages/nodes-base/nodes/DataTable/actions/router.ts b/packages/nodes-base/nodes/DataTable/actions/router.ts index 5077e4ff830..7285f060060 100644 --- a/packages/nodes-base/nodes/DataTable/actions/router.ts +++ b/packages/nodes-base/nodes/DataTable/actions/router.ts @@ -1,4 +1,10 @@ -import type { IExecuteFunctions, INodeExecutionData, AllEntities } from 'n8n-workflow'; +import type { + IExecuteFunctions, + INodeExecutionData, + AllEntities, + DataTableRowOperation, + DataTableTableOperation, +} from 'n8n-workflow'; import { NodeApiError, NodeOperationError } from 'n8n-workflow'; import * as row from './row/Row.resource'; @@ -7,8 +13,8 @@ import { DATA_TABLE_ID_FIELD } from '../common/fields'; import { getDataTableProxyExecute } from '../common/utils'; type DataTableNodeType = AllEntities<{ - row: 'insert' | 'get' | 'rowExists' | 'rowNotExists' | 'deleteRows' | 'update' | 'upsert'; - table: 'create' | 'delete' | 'list' | 'update'; + row: DataTableRowOperation; + table: DataTableTableOperation; }>; const BULK_OPERATIONS = ['insert'] as const; diff --git a/packages/workflow/src/data-table.types.ts b/packages/workflow/src/data-table.types.ts index 84fa517f6a9..5278363229e 100644 --- a/packages/workflow/src/data-table.types.ts +++ b/packages/workflow/src/data-table.types.ts @@ -1,5 +1,24 @@ export type DataTableColumnType = 'string' | 'number' | 'boolean' | 'date'; +/** + * Data Table row operations + * Used by the Data Table node (n8n-nodes-base.dataTable) for row-level CRUD operations + */ +export type DataTableRowOperation = + | 'insert' + | 'get' + | 'rowExists' + | 'rowNotExists' + | 'deleteRows' + | 'update' + | 'upsert'; + +/** + * Data Table table operations + * Used by the Data Table node for table-level management operations + */ +export type DataTableTableOperation = 'create' | 'delete' | 'list' | 'update'; + export type DataTableColumn = { id: string; name: string;