fix(core): Fix Resource Mapper types in SDK (no-changelog) (#30213)

This commit is contained in:
Milorad FIlipović 2026-05-11 14:35:53 +02:00 committed by GitHub
parent ea98243c2b
commit 127544ae5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 207 additions and 10 deletions

View File

@ -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

View File

@ -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('');
}

View File

@ -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

View File

@ -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);
});
});

View File

@ -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
*/