mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(ai-builder): Providing instructions from workflow builder on creation of data tables (#24595)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>).name === 'string' &&
|
||||
typeof (item as Record<string, unknown>).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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, SimpleWorkflow['nodes'][number]>,
|
||||
): 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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user