mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(core): Fix Resource Mapper types in SDK (no-changelog) (#30213)
This commit is contained in:
parent
ea98243c2b
commit
127544ae5b
|
|
@ -543,6 +543,36 @@ describe('generate-types', () => {
|
|||
expect(result).toBe('AssignmentCollectionValue');
|
||||
});
|
||||
|
||||
it('should map resourceMapper type to structured mapper value', () => {
|
||||
const prop: NodeProperty = {
|
||||
name: 'columns',
|
||||
displayName: 'Columns',
|
||||
type: 'resourceMapper',
|
||||
default: { mappingMode: 'defineBelow', value: null },
|
||||
};
|
||||
|
||||
const result = generateTypes.mapPropertyType(prop);
|
||||
|
||||
expect(result).toBe('ResourceMapperValue | Expression<string>');
|
||||
});
|
||||
|
||||
it('should map resourceMapper with loadOptionsDependsOn and noDataExpression to structured mapper value', () => {
|
||||
const prop: NodeProperty = {
|
||||
name: 'columns',
|
||||
displayName: 'Columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: { mappingMode: 'defineBelow', value: null },
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateTypes.mapPropertyType(prop);
|
||||
|
||||
expect(result).toBe('ResourceMapperValue');
|
||||
});
|
||||
|
||||
it('should map string type with multipleValues to an array type', () => {
|
||||
const prop: NodeProperty = {
|
||||
name: 'attendees',
|
||||
|
|
@ -1996,6 +2026,39 @@ describe('generate-types', () => {
|
|||
expect(result).toContain('isTrigger: true');
|
||||
});
|
||||
|
||||
it('should emit helper type for resourceMapper properties', () => {
|
||||
const sheetsLikeNode: NodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.googleSheets',
|
||||
displayName: 'Google Sheets',
|
||||
description: 'Read and write rows',
|
||||
group: ['transform'],
|
||||
version: 4.7,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
name: 'columns',
|
||||
displayName: 'Columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: { mappingMode: 'defineBelow', value: null },
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = generateTypes.generateNodeTypeFile(sheetsLikeNode);
|
||||
|
||||
expect(result).toContain('type ResourceMapperField = {');
|
||||
expect(result).toContain('mappingMode: string;');
|
||||
expect(result).toContain('value?: null | Record<string, unknown>;');
|
||||
expect(result).toContain('schema?: ResourceMapperField[]');
|
||||
expect(result).toContain('columns?: ResourceMapperValue;');
|
||||
expect(result).not.toContain('columns?: string;');
|
||||
});
|
||||
|
||||
// Regression: required string with default: '' used to emit
|
||||
// `fieldToSplitOut?: ...` in the TS type, which dropped the required
|
||||
// signal to LLMs consuming the type. Empty defaults don't satisfy
|
||||
|
|
|
|||
|
|
@ -105,6 +105,13 @@ ${prefix} AssignmentType = 'string' | 'number' | 'boolean' | 'array' | 'object'
|
|||
${prefix} AssignmentCollectionValue = { assignments: Array<{ id: string; name: string; value: unknown; type: AssignmentType }> };`;
|
||||
}
|
||||
|
||||
function generateResourceMapperTypeDeclaration(exported: boolean): string {
|
||||
const prefix = exported ? 'export type' : 'type';
|
||||
return `${prefix} ResourceMapperField = { id?: string; displayName?: string; required?: boolean; defaultMatch?: boolean; display?: boolean; type?: string; canBeUsedToMatch?: boolean; [key: string]: unknown };
|
||||
${prefix} ResourceMapperCommon = { matchingColumns?: string[]; cachedResultName?: string; [key: string]: unknown };
|
||||
${prefix} ResourceMapperValue = ResourceMapperCommon & { mappingMode: string; value?: null | Record<string, unknown>; schema?: ResourceMapperField[] };`;
|
||||
}
|
||||
|
||||
function isCustomApiCall(operation: string): boolean {
|
||||
return operation === CUSTOM_API_CALL_KEY;
|
||||
}
|
||||
|
|
@ -733,6 +740,8 @@ function mapNestedPropertyTypeInner(
|
|||
return 'string[]';
|
||||
case 'resourceLocator':
|
||||
return generateResourceLocatorType(prop);
|
||||
case 'resourceMapper':
|
||||
return 'ResourceMapperValue | Expression<string>';
|
||||
case 'filter':
|
||||
return 'FilterValue';
|
||||
case 'assignmentCollection':
|
||||
|
|
@ -789,6 +798,8 @@ function mapNestedPropertyTypeInner(
|
|||
return 'IDataObject | string | Expression<string>';
|
||||
case 'resourceLocator':
|
||||
return generateResourceLocatorType(prop);
|
||||
case 'resourceMapper':
|
||||
return 'ResourceMapperValue | Expression<string>';
|
||||
case 'filter':
|
||||
return 'FilterValue';
|
||||
case 'assignmentCollection':
|
||||
|
|
@ -1425,6 +1436,8 @@ function mapPropertyTypeInner(
|
|||
return 'string[]';
|
||||
case 'resourceLocator':
|
||||
return generateResourceLocatorType(prop);
|
||||
case 'resourceMapper':
|
||||
return 'ResourceMapperValue | Expression<string>';
|
||||
case 'filter':
|
||||
return 'FilterValue';
|
||||
case 'assignmentCollection':
|
||||
|
|
@ -1489,6 +1502,9 @@ function mapPropertyTypeInner(
|
|||
case 'resourceLocator':
|
||||
return generateResourceLocatorType(prop);
|
||||
|
||||
case 'resourceMapper':
|
||||
return 'ResourceMapperValue | Expression<string>';
|
||||
|
||||
case 'filter':
|
||||
return 'FilterValue';
|
||||
|
||||
|
|
@ -2330,8 +2346,9 @@ export function generateSharedFile(
|
|||
// Helper types
|
||||
const needsFilter = outputProps.some((p) => p.type === 'filter');
|
||||
const needsAssignment = outputProps.some((p) => p.type === 'assignmentCollection');
|
||||
const needsResourceMapper = outputProps.some((p) => p.type === 'resourceMapper');
|
||||
|
||||
if (needsFilter || needsAssignment) {
|
||||
if (needsFilter || needsAssignment || needsResourceMapper) {
|
||||
lines.push('// Helper types for special n8n fields');
|
||||
if (needsFilter) {
|
||||
lines.push(generateFilterTypeDeclaration(true));
|
||||
|
|
@ -2339,6 +2356,9 @@ export function generateSharedFile(
|
|||
if (needsAssignment) {
|
||||
lines.push(generateAssignmentTypeDeclarations(true));
|
||||
}
|
||||
if (needsResourceMapper) {
|
||||
lines.push(generateResourceMapperTypeDeclaration(true));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
|
|
@ -2448,9 +2468,10 @@ export function generateDiscriminatorFile(
|
|||
// Check what helper types we need
|
||||
const needsFilter = props.some((p) => p.type === 'filter');
|
||||
const needsAssignment = props.some((p) => p.type === 'assignmentCollection');
|
||||
const needsResourceMapper = props.some((p) => p.type === 'resourceMapper');
|
||||
|
||||
// Inline helper types (only the ones needed)
|
||||
if (needsFilter || needsAssignment) {
|
||||
if (needsFilter || needsAssignment || needsResourceMapper) {
|
||||
lines.push('// Helper types for special n8n fields');
|
||||
if (needsFilter) {
|
||||
lines.push(generateFilterTypeDeclaration(false));
|
||||
|
|
@ -2458,6 +2479,9 @@ export function generateDiscriminatorFile(
|
|||
if (needsAssignment) {
|
||||
lines.push(generateAssignmentTypeDeclarations(false));
|
||||
}
|
||||
if (needsResourceMapper) {
|
||||
lines.push(generateResourceMapperTypeDeclaration(false));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
|
|
@ -2892,8 +2916,9 @@ export function generateSingleVersionTypeFile(
|
|||
// Helper types (if needed) based on filtered properties
|
||||
const needsFilter = outputProps.some((p) => p.type === 'filter');
|
||||
const needsAssignment = outputProps.some((p) => p.type === 'assignmentCollection');
|
||||
const needsResourceMapper = outputProps.some((p) => p.type === 'resourceMapper');
|
||||
|
||||
if (needsFilter || needsAssignment) {
|
||||
if (needsFilter || needsAssignment || needsResourceMapper) {
|
||||
lines.push('// Helper types for special n8n fields');
|
||||
if (needsFilter) {
|
||||
lines.push(generateFilterTypeDeclaration(false));
|
||||
|
|
@ -2901,6 +2926,9 @@ export function generateSingleVersionTypeFile(
|
|||
if (needsAssignment) {
|
||||
lines.push(generateAssignmentTypeDeclarations(false));
|
||||
}
|
||||
if (needsResourceMapper) {
|
||||
lines.push(generateResourceMapperTypeDeclaration(false));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
|
|
@ -3158,8 +3186,9 @@ export function generateNodeTypeFile(nodes: NodeTypeDescription | NodeTypeDescri
|
|||
// Helper types (if needed) - only add if they'll actually be used in output
|
||||
const needsFilter = outputProps.some((p) => p.type === 'filter');
|
||||
const needsAssignment = outputProps.some((p) => p.type === 'assignmentCollection');
|
||||
const needsResourceMapper = outputProps.some((p) => p.type === 'resourceMapper');
|
||||
|
||||
if (needsFilter || needsAssignment) {
|
||||
if (needsFilter || needsAssignment || needsResourceMapper) {
|
||||
lines.push('// Helper types for special n8n fields');
|
||||
if (needsFilter) {
|
||||
lines.push(generateFilterTypeDeclaration(false));
|
||||
|
|
@ -3167,6 +3196,9 @@ export function generateNodeTypeFile(nodes: NodeTypeDescription | NodeTypeDescri
|
|||
if (needsAssignment) {
|
||||
lines.push(generateAssignmentTypeDeclarations(false));
|
||||
}
|
||||
if (needsResourceMapper) {
|
||||
lines.push(generateResourceMapperTypeDeclaration(false));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,21 @@ describe('mapPropertyToZodSchema for resourceLocator', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('mapPropertyToZodSchema for resourceMapper', () => {
|
||||
it('returns resourceMapperValueSchema', () => {
|
||||
const prop: NodeProperty = {
|
||||
name: 'columns',
|
||||
displayName: 'Columns',
|
||||
type: 'resourceMapper',
|
||||
default: { mappingMode: 'defineBelow', value: null },
|
||||
};
|
||||
|
||||
const schema = mapPropertyToZodSchema(prop);
|
||||
|
||||
expect(schema).toBe('resourceMapperValueSchema');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPropertyOptional', () => {
|
||||
// Regression: required: true + default: '' on a `string` field used to be
|
||||
// treated as optional, because any defined default short-circuited the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { resourceMapperValueSchema } from './schema-helpers';
|
||||
|
||||
describe('resourceMapperValueSchema', () => {
|
||||
it('accepts defineBelow when value is null and schema is present', () => {
|
||||
const result = resourceMapperValueSchema.safeParse({
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
schema: [{ id: 'name', displayName: 'Name', type: 'string', required: false }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts defineBelow before schema is loaded', () => {
|
||||
const result = resourceMapperValueSchema.safeParse({
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts defineBelow with manual values', () => {
|
||||
const result = resourceMapperValueSchema.safeParse({
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
name: '={{ $json.name }}',
|
||||
email: '={{ $json.email }}',
|
||||
},
|
||||
schema: [
|
||||
{ id: 'name', displayName: 'Name', type: 'string', required: false },
|
||||
{ id: 'email', displayName: 'Email', type: 'string', required: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts unknown mapping modes', () => {
|
||||
const result = resourceMapperValueSchema.safeParse({
|
||||
mappingMode: 'newMappingMode',
|
||||
value: {
|
||||
name: '={{ $json.name }}',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts autoMapInputData without schema', () => {
|
||||
const result = resourceMapperValueSchema.safeParse({
|
||||
mappingMode: 'autoMapInputData',
|
||||
value: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts expression values', () => {
|
||||
const result = resourceMapperValueSchema.safeParse('={{ $json.columns }}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -44,18 +44,41 @@ export type {
|
|||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Resource Mapper Value schema (object format)
|
||||
* Used for mapping input data to columns/fields
|
||||
* Resource Mapper field schema.
|
||||
* Used for dynamic field/column metadata returned by resource mapper methods.
|
||||
*/
|
||||
const resourceMapperObjectSchema = z
|
||||
const resourceMapperFieldSchema = z
|
||||
.object({
|
||||
mappingMode: z.string(),
|
||||
value: z.unknown().optional(),
|
||||
schema: z.array(z.unknown()).optional(),
|
||||
id: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
defaultMatch: z.boolean().optional(),
|
||||
display: z.boolean().optional(),
|
||||
type: z.string().optional(),
|
||||
canBeUsedToMatch: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* Shared Resource Mapper fields.
|
||||
*/
|
||||
const resourceMapperCommonSchema = z
|
||||
.object({
|
||||
matchingColumns: z.array(z.string()).optional(),
|
||||
cachedResultName: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* Resource Mapper Value schema (object format).
|
||||
* Used for mapping input data to columns/fields
|
||||
*/
|
||||
const resourceMapperObjectSchema = resourceMapperCommonSchema.extend({
|
||||
mappingMode: z.string(),
|
||||
value: z.union([z.null(), z.record(z.string(), z.unknown())]).optional(),
|
||||
schema: z.array(resourceMapperFieldSchema).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resource Mapper Value schema - accepts object format OR expression
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user