Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# v0.12.3
* Add `table.upsert()` method for updating existing records or creating new ones based on merge fields
* Support for both Promise and callback APIs
* Comprehensive error handling and validation
* Full TypeScript support with proper type definitions

# v0.12.2
* Fix invalid URL error in `abort-controller`

Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,40 @@ is equivalent to
table.select().firstPage().then(result => { ... })
```

# Upsert Records

The `upsert` method allows you to update existing records or create new ones based on specified merge fields. This is useful when you want to ensure records exist with certain values, regardless of whether they already exist in the table.

```js
// Upsert with Promise
const records = await table.upsert(
[{ fields: { Name: 'John', Email: 'john@example.com' } }],
['Email']
);

// Upsert with callback
table.upsert(
[{ fields: { Name: 'Jane', Email: 'jane@example.com' } }],
['Email'],
{ typecast: true },
(err, records) => {
if (err) console.error(err);
else console.log('Upserted:', records);
}
);

// Upsert with multiple merge fields
table.upsert(
[{ fields: { Name: 'Bob', Email: 'bob@company.com', Company: 'Acme Corp' } }],
['Email', 'Company']
);
```

The `upsert` method will:
- **Update existing records** if they match the specified merge fields
- **Create new records** if no match is found
- **Return an error** if multiple records match the merge criteria (duplicate matches)

# Tests

Tests are run via `npm run test`.
Expand Down
38 changes: 37 additions & 1 deletion build/airtable.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ module.exports = objectToQueryParamString;

},{"lodash/isArray":79,"lodash/isNil":85,"lodash/keys":93}],12:[function(require,module,exports){
"use strict";
module.exports = "0.12.2";
module.exports = "0.12.3";

},{}],13:[function(require,module,exports){
"use strict";
Expand Down Expand Up @@ -902,6 +902,7 @@ var Table = /** @class */ (function () {
this.create = callback_to_promise_1.default(this._createRecords, this);
this.update = callback_to_promise_1.default(this._updateRecords.bind(this, false), this);
this.replace = callback_to_promise_1.default(this._updateRecords.bind(this, true), this);
this.upsert = callback_to_promise_1.default(this._upsertRecords, this);
this.destroy = callback_to_promise_1.default(this._destroyRecord, this);
// Deprecated API
this.list = deprecate_1.default(this._listRecords.bind(this), 'table.list', 'Airtable: `list()` is deprecated. Use `select()` instead.');
Expand Down Expand Up @@ -1003,6 +1004,41 @@ var Table = /** @class */ (function () {
}
}
};
Table.prototype._upsertRecords = function (recordsData, fieldsToMergeOn, optionalParameters, done) {
var _this = this;
// Handle optional parameters
if (!done) {
done = optionalParameters;
optionalParameters = {};
}
// Validate fieldsToMergeOn
if (!Array.isArray(fieldsToMergeOn) || fieldsToMergeOn.length === 0) {
var error = new Error('fieldsToMergeOn must be a non-empty array of field names');
done(error);
return;
}
// Validate recordsData is an array (upsert only supports batch operations)
if (!Array.isArray(recordsData)) {
var error = new Error('recordsData must be an array for upsert operations');
done(error);
return;
}
// Construct request payload following Airtable API spec
var requestData = __assign({ performUpsert: {
fieldsToMergeOn: fieldsToMergeOn,
}, records: recordsData }, optionalParameters);
// Use PATCH method as per Airtable API
this._base.runAction('patch', "/" + this._urlEncodedNameOrId() + "/", {}, requestData, function (err, resp, body) {
if (err) {
done(err);
return;
}
var result = body.records.map(function (record) {
return new record_1.default(_this, record.id, record);
});
done(null, result);
});
};
Table.prototype._destroyRecord = function (recordIdsOrId, done) {
var _this = this;
if (Array.isArray(recordIdsOrId)) {
Expand Down
77 changes: 77 additions & 0 deletions examples/upsert-example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Example: Using table.upsert() method
*
* This example demonstrates how to use the upsert method to update existing records
* or create new ones based on specified merge fields.
*/

import Airtable from 'airtable';

// Configure Airtable
Airtable.configure({apiKey: 'YOUR_API_KEY'});
const base = Airtable.base('YOUR_BASE_ID');
const table = base('Table Name');

async function upsertExample(): Promise<void> {
try {
// Example 1: Basic upsert with single merge field
console.log('=== Basic Upsert ===');
const records1 = await table.upsert(
[
{fields: {Name: 'John Doe', Email: 'john@example.com', Status: 'Active'}},
{fields: {Name: 'Jane Smith', Email: 'jane@example.com', Status: 'Pending'}},
],
['Email'] // Merge on Email field
);
console.log('Upserted records:', records1.length);

// Example 2: Upsert with multiple merge fields
console.log('\n=== Multi-field Upsert ===');
const records2 = await table.upsert(
[
{
fields: {
Name: 'Bob Johnson',
Email: 'bob@company.com',
Company: 'Acme Corp',
Department: 'Engineering',
},
},
],
['Email', 'Company'] // Merge on both Email and Company
);
console.log('Upserted with multiple merge fields:', records2.length);

// Example 3: Upsert with typecast option
console.log('\n=== Upsert with Typecast ===');
const records3 = await table.upsert(
[{fields: {Name: 'Alice Brown', Email: 'alice@example.com', Age: '25'}}],
['Email'],
{typecast: true} // Automatically convert string '25' to number
);
console.log('Upserted with typecast:', records3.length);

// Example 4: Using callback API
console.log('\n=== Callback API ===');
table.upsert(
[{fields: {Name: 'Charlie Wilson', Email: 'charlie@example.com'}}],
['Email'],
(err, records) => {
if (err) {
console.error('Upsert failed:', err);
} else {
console.log('Callback upsert successful:', records.length);
}
}
);
} catch (error) {
console.error('Upsert error:', error);
}
}

// Run the example
if (import.meta.url === `file://${process.argv[1]}`) {
upsertExample();
}

export {upsertExample};
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "airtable",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"homepage": "https://github.com/airtable/airtable.js",
"repository": "git://github.com/airtable/airtable.js.git",
Expand Down
77 changes: 77 additions & 0 deletions src/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,32 @@ interface TableChangeRecords<TFields extends FieldSet> {
(recordsData: RecordData<Partial<TFields>>[], done: RecordCollectionCallback<TFields>): void;
}

interface TableUpsertRecords<TFields extends FieldSet> {
(
recordsData: CreateRecords<TFields>,
fieldsToMergeOn: string[],
optionalParameters?: OptionalParameters
): Promise<Records<TFields>>;
(
recordsData: CreateRecords<TFields>,
fieldsToMergeOn: string[],
optionalParameters: OptionalParameters,
done: RecordCollectionCallback<TFields>
): void;
(
recordsData: CreateRecords<TFields>,
fieldsToMergeOn: string[],
done: RecordCollectionCallback<TFields>
): void;
}

interface TableDestroyRecords<TFields extends FieldSet> {
(recordId: string): Promise<Record<TFields>>;
(recordId: string, done: RecordCallback<TFields>): void;
(recordIds: string[]): Promise<Records<TFields>>;
(recordIds: string[], done: RecordCollectionCallback<TFields>): void;
}

class Table<TFields extends FieldSet> {
readonly _base: Base;

Expand All @@ -111,6 +131,7 @@ class Table<TFields extends FieldSet> {
readonly create: TableCreateRecords<TFields>;
readonly update: TableChangeRecords<TFields>;
readonly replace: TableChangeRecords<TFields>;
readonly upsert: TableUpsertRecords<TFields>;
readonly destroy: TableDestroyRecords<TFields>;

/** @deprecated */
Expand All @@ -135,6 +156,7 @@ class Table<TFields extends FieldSet> {
this.create = callbackToPromise(this._createRecords, this);
this.update = callbackToPromise(this._updateRecords.bind(this, false), this);
this.replace = callbackToPromise(this._updateRecords.bind(this, true), this);
this.upsert = callbackToPromise(this._upsertRecords, this);
this.destroy = callbackToPromise(this._destroyRecord, this);

// Deprecated API
Expand Down Expand Up @@ -333,6 +355,61 @@ class Table<TFields extends FieldSet> {
}
}

_upsertRecords(
recordsData: CreateRecords<TFields>,
fieldsToMergeOn: string[],
optionalParameters: OptionalParameters | RecordCollectionCallback<TFields>,
done?: RecordCollectionCallback<TFields>
): void | Promise<Records<TFields>> {
// Handle optional parameters
if (!done) {
done = optionalParameters as RecordCollectionCallback<TFields>;
optionalParameters = {};
}

// Validate fieldsToMergeOn
if (!Array.isArray(fieldsToMergeOn) || fieldsToMergeOn.length === 0) {
const error = new Error('fieldsToMergeOn must be a non-empty array of field names');
done(error);
return;
}

// Validate recordsData is an array (upsert only supports batch operations)
if (!Array.isArray(recordsData)) {
const error = new Error('recordsData must be an array for upsert operations');
done(error);
return;
}

// Construct request payload following Airtable API spec
const requestData = {
performUpsert: {
fieldsToMergeOn: fieldsToMergeOn,
},
records: recordsData,
...optionalParameters,
};

// Use PATCH method as per Airtable API
this._base.runAction(
'patch',
`/${this._urlEncodedNameOrId()}/`,
{},
requestData,
(err, resp, body) => {
if (err) {
done(err);
return;
}

const result = body.records.map(record => {
return new Record(this, record.id, record);
});
done(null, result);
}
);
}

_destroyRecord(recordId: string, done: RecordCallback<TFields>): void;
_destroyRecord(recordIds: string[], done: RecordCollectionCallback<TFields>): void;
_destroyRecord(
Expand Down
38 changes: 37 additions & 1 deletion test/test_files/airtable.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ module.exports = objectToQueryParamString;

},{"lodash/isArray":79,"lodash/isNil":85,"lodash/keys":93}],12:[function(require,module,exports){
"use strict";
module.exports = "0.12.2";
module.exports = "0.12.3";

},{}],13:[function(require,module,exports){
"use strict";
Expand Down Expand Up @@ -902,6 +902,7 @@ var Table = /** @class */ (function () {
this.create = callback_to_promise_1.default(this._createRecords, this);
this.update = callback_to_promise_1.default(this._updateRecords.bind(this, false), this);
this.replace = callback_to_promise_1.default(this._updateRecords.bind(this, true), this);
this.upsert = callback_to_promise_1.default(this._upsertRecords, this);
this.destroy = callback_to_promise_1.default(this._destroyRecord, this);
// Deprecated API
this.list = deprecate_1.default(this._listRecords.bind(this), 'table.list', 'Airtable: `list()` is deprecated. Use `select()` instead.');
Expand Down Expand Up @@ -1003,6 +1004,41 @@ var Table = /** @class */ (function () {
}
}
};
Table.prototype._upsertRecords = function (recordsData, fieldsToMergeOn, optionalParameters, done) {
var _this = this;
// Handle optional parameters
if (!done) {
done = optionalParameters;
optionalParameters = {};
}
// Validate fieldsToMergeOn
if (!Array.isArray(fieldsToMergeOn) || fieldsToMergeOn.length === 0) {
var error = new Error('fieldsToMergeOn must be a non-empty array of field names');
done(error);
return;
}
// Validate recordsData is an array (upsert only supports batch operations)
if (!Array.isArray(recordsData)) {
var error = new Error('recordsData must be an array for upsert operations');
done(error);
return;
}
// Construct request payload following Airtable API spec
var requestData = __assign({ performUpsert: {
fieldsToMergeOn: fieldsToMergeOn,
}, records: recordsData }, optionalParameters);
// Use PATCH method as per Airtable API
this._base.runAction('patch', "/" + this._urlEncodedNameOrId() + "/", {}, requestData, function (err, resp, body) {
if (err) {
done(err);
return;
}
var result = body.records.map(function (record) {
return new record_1.default(_this, record.id, record);
});
done(null, result);
});
};
Table.prototype._destroyRecord = function (recordIdsOrId, done) {
var _this = this;
if (Array.isArray(recordIdsOrId)) {
Expand Down
Loading