fix(Airtable Node): Support matching array field values in update operation (#31517)

Co-authored-by: RomanDavydchuk <roman.davydchuk@n8n.io>
This commit is contained in:
Rohit Gahlawat
2026-07-21 12:35:23 +05:30
committed by GitHub
parent e1ad8f4c15
commit e07eab880a
3 changed files with 254 additions and 3 deletions
@@ -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' },
],
);
});
});
@@ -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);
});
});
@@ -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;
}
}