From e07eab880aa6abd8829e2403cb2008a271ebd18c Mon Sep 17 00:00:00 2001 From: Rohit Gahlawat <283466839+Rohit-Gahlawat@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:35:23 +0530 Subject: [PATCH] fix(Airtable Node): Support matching array field values in update operation (#31517) Co-authored-by: RomanDavydchuk --- .../test/v2/node/record/update.test.ts | 111 ++++++++++++++++ .../nodes/Airtable/test/v2/utils.test.ts | 121 +++++++++++++++++- .../nodes/Airtable/v2/helpers/utils.ts | 25 +++- 3 files changed, 254 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/record/update.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/record/update.test.ts index 929f7b18cf0..58c3053bd91 100644 --- a/packages/nodes-base/nodes/Airtable/test/v2/node/record/update.test.ts +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/record/update.test.ts @@ -215,4 +215,115 @@ describe('Test AirtableV2, update operation', () => { [{ fields: { bar: 'bar 1', foo: 'foo 1', id: 'recXXX' }, id: 'recXXX' }], ); }); + + it('should update a record by lookup field with array value, autoMapInputData', async () => { + vi.mocked(transport.apiRequestAllItems).mockResolvedValueOnce({ + records: [ + { + id: 'recAAA', + fields: { + lookupField: ['lookup_val_1'], + }, + }, + { + id: 'recBBB', + fields: { + lookupField: ['lookup_val_2'], + }, + }, + ], + }); + + const nodeParameters = { + operation: 'update', + columns: { + mappingMode: 'autoMapInputData', + matchingColumns: ['lookupField'], + }, + options: {}, + }; + + const items = [ + { + json: { + lookupField: 'lookup_val_1', + name: 'updated name', + }, + }, + ]; + + await update.execute.call( + createMockExecuteFunction(nodeParameters), + items, + 'appYoLbase', + 'tblltable', + ); + + expect(transport.batchUpdate).toHaveBeenCalledWith( + 'appYoLbase/tblltable', + { typecast: false }, + [{ fields: { lookupField: 'lookup_val_1', name: 'updated name' }, id: 'recAAA' }], + ); + }); + + it('should update all matches by lookup field with array value, autoMapInputData', async () => { + vi.mocked(transport.apiRequestAllItems).mockResolvedValueOnce({ + records: [ + { + id: 'recAAA', + fields: { + lookupField: ['shared_val'], + }, + }, + { + id: 'recBBB', + fields: { + lookupField: ['other_val'], + }, + }, + { + id: 'recCCC', + fields: { + lookupField: ['shared_val'], + }, + }, + ], + }); + + const nodeParameters = { + operation: 'update', + columns: { + mappingMode: 'autoMapInputData', + matchingColumns: ['lookupField'], + }, + options: { + updateAllMatches: true, + }, + }; + + const items = [ + { + json: { + lookupField: 'shared_val', + name: 'updated name', + }, + }, + ]; + + await update.execute.call( + createMockExecuteFunction(nodeParameters), + items, + 'appYoLbase', + 'tblltable', + ); + + expect(transport.batchUpdate).toHaveBeenCalledWith( + 'appYoLbase/tblltable', + { typecast: false }, + [ + { fields: { lookupField: 'shared_val', name: 'updated name' }, id: 'recAAA' }, + { fields: { lookupField: 'shared_val', name: 'updated name' }, id: 'recCCC' }, + ], + ); + }); }); diff --git a/packages/nodes-base/nodes/Airtable/test/v2/utils.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/utils.test.ts index 692b5c23d22..5c23f310450 100644 --- a/packages/nodes-base/nodes/Airtable/test/v2/utils.test.ts +++ b/packages/nodes-base/nodes/Airtable/test/v2/utils.test.ts @@ -1,4 +1,9 @@ -import { coerceArrayTypeFields, findMatches, removeIgnored } from '../../v2/helpers/utils'; +import { + coerceArrayTypeFields, + findMatches, + removeIgnored, + valuesMatch, +} from '../../v2/helpers/utils'; const makeColumnsParam = (fields: Array<{ id: string; type: string }>) => ({ mappingMode: 'defineBelow', @@ -179,4 +184,118 @@ describe('test AirtableV2, findMatches', () => { }, ]); }); + + it('should find match when record field value is an array and input is a scalar', () => { + const data = [ + { + fields: { + lookupField: ['value1'], + data: 'data 1', + }, + }, + { + fields: { + lookupField: ['value2'], + data: 'data 2', + }, + }, + ]; + + const result = findMatches(data, ['lookupField'], { + lookupField: 'value1', + data: 'updated', + }); + + expect(result).toEqual([ + { + fields: { + lookupField: ['value1'], + data: 'data 1', + }, + }, + ]); + }); + + it('should find all matches when record field values are arrays and input is a scalar', () => { + const data = [ + { + fields: { + lookupField: ['shared'], + data: 'data 1', + }, + }, + { + fields: { + lookupField: ['other'], + data: 'data 2', + }, + }, + { + fields: { + lookupField: ['shared'], + data: 'data 3', + }, + }, + ]; + + const result = findMatches( + data, + ['lookupField'], + { + lookupField: 'shared', + data: 'updated', + }, + true, + ); + + expect(result).toHaveLength(2); + expect(result[0].fields.lookupField).toEqual(['shared']); + expect(result[1].fields.lookupField).toEqual(['shared']); + }); +}); + +describe('test AirtableV2, valuesMatch', () => { + it('should match identical scalar values', () => { + expect(valuesMatch('foo', 'foo')).toBe(true); + }); + + it('should not match different scalar values', () => { + expect(valuesMatch('foo', 'bar')).toBe(false); + }); + + it('should match when record value is an array containing the input value', () => { + expect(valuesMatch(['foo'], 'foo')).toBe(true); + }); + + it('should match when record value is a multi-element array containing the input value', () => { + expect(valuesMatch(['foo', 'bar'], 'foo')).toBe(true); + }); + + it('should not match when record value is an array not containing the input value', () => { + expect(valuesMatch(['foo'], 'bar')).toBe(false); + }); + + it('should match identical arrays', () => { + expect(valuesMatch(['foo', 'bar'], ['foo', 'bar'])).toBe(true); + }); + + it('should not match arrays with different lengths', () => { + expect(valuesMatch(['foo'], ['foo', 'bar'])).toBe(false); + }); + + it('should not match arrays with different elements', () => { + expect(valuesMatch(['foo', 'baz'], ['foo', 'bar'])).toBe(false); + }); + + it('should match identical numbers', () => { + expect(valuesMatch(42, 42)).toBe(true); + }); + + it('should not match undefined values to a string', () => { + expect(valuesMatch(undefined, 'foo')).toBe(false); + }); + + it('should match when both are undefined', () => { + expect(valuesMatch(undefined, undefined)).toBe(true); + }); }); diff --git a/packages/nodes-base/nodes/Airtable/v2/helpers/utils.ts b/packages/nodes-base/nodes/Airtable/v2/helpers/utils.ts index 694b5dfde7b..ffc2ca7dd32 100644 --- a/packages/nodes-base/nodes/Airtable/v2/helpers/utils.ts +++ b/packages/nodes-base/nodes/Airtable/v2/helpers/utils.ts @@ -32,6 +32,27 @@ export function removeIgnored(data: IDataObject, ignore: string | string[]) { } } +/** + * Compare a value from the Airtable API response with a value from the user input. + * Airtable returns array values for Lookup and Linked Record fields (e.g. ["value"]), + * while user input typically provides scalar values (e.g. "value"). This helper + * handles that mismatch so that matching by such fields works correctly. + */ +export function valuesMatch(recordValue: unknown, inputValue: unknown): boolean { + if (recordValue === inputValue) return true; + + if (Array.isArray(recordValue) && !Array.isArray(inputValue)) { + return recordValue.includes(inputValue); + } + + if (Array.isArray(recordValue) && Array.isArray(inputValue)) { + if (recordValue.length !== inputValue.length) return false; + return recordValue.every((v, i) => v === inputValue[i]); + } + + return false; +} + export function findMatches( data: UpdateRecord[], keys: string[], @@ -41,7 +62,7 @@ export function findMatches( if (updateAll) { const matches = data.filter((record) => { for (const key of keys) { - if (record.fields[key] !== fields[key]) { + if (!valuesMatch(record.fields[key], fields[key])) { return false; } } @@ -56,7 +77,7 @@ export function findMatches( } else { const match = data.find((record) => { for (const key of keys) { - if (record.fields[key] !== fields[key]) { + if (!valuesMatch(record.fields[key], fields[key])) { return false; } }