Skip to content

Commit 1791688

Browse files
committed
MySQLTable: Implement new .insertIfNotExists() method
1 parent 5464863 commit 1791688

File tree

3 files changed

+266
-0
lines changed

3 files changed

+266
-0
lines changed

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,7 @@ A class that provides convenient methods for performing queries.
522522
* [.trxn](#MySQLTable+trxn) : <code>?[Connection](#Connection)</code>
523523
* [.select(columns, [sqlString], [values], [cb])](#MySQLTable+select) ⇒ <code>Promise</code>
524524
* [.insert([data], [sqlString], [values], [cb])](#MySQLTable+insert) ⇒ <code>Promise</code>
525+
* [.insertIfNotExists(data, keyColumns, [cb])](#MySQLTable+insertIfNotExists) ⇒ <code>Promise</code>
525526
* [.update([data], [sqlString], [values], [cb])](#MySQLTable+update) ⇒ <code>Promise</code>
526527
* [.delete([sqlString], [values], [cb])](#MySQLTable+delete) ⇒ <code>Promise</code>
527528
* [.query()](#MySQLTable+query) ⇒ <code>Promise</code>
@@ -710,6 +711,65 @@ userTable.insert([['email', 'name'], users])
710711
```
711712

712713

714+
---
715+
716+
<a name="MySQLTable+insertIfNotExists"></a>
717+
718+
### mySQLTable.insertIfNotExists(data, keyColumns, [cb]) ⇒ <code>Promise</code>
719+
Inserts a new row into the table if there are no existing rows in
720+
the table that have the same values for the specified columns.
721+
722+
This is useful because if the row is not inserted, the table's
723+
`AUTO_INCREMENT` value is not increased (unlike when an insert
724+
fails because of a unique key constraint).
725+
726+
727+
| Param | Type | Description |
728+
|:--- |:--- |:--- |
729+
| data | <code>Object</code> | An object mapping column names to data values to insert. The values are escaped by default. If you don't want a value to be escaped, wrap it in a "raw" object (see the last example below). |
730+
| keyColumns | <code>Array.&lt;string&gt;</code> | The names of columns in the `data` object. If there is already a row in the table with the same values for these columns as the values being inserted, the data will not be inserted. |
731+
| [cb] | <code>[queryCallback](#module_mysql-plus..queryCallback)</code> | A callback that gets called with the results of the query. |
732+
733+
**Returns**: <code>?Promise</code> - If the `cb` parameter is omitted, a promise that will
734+
resolve with the results of the query is returned.
735+
736+
**Example**: Insert a new user if a user with the same email does not exist
737+
```js
738+
userTable.insertIfNotExists({email: 'email@example.com', name: 'John Doe'}, ['email'])
739+
.then(result => result.affectedRows);
740+
// 0 - If there was a row with `email` = 'email@example.com' (row not inserted)
741+
// 1 - If there wasn't (row was inserted)
742+
743+
// INSERT INTO `user` (`email`, `name`)
744+
// SELECT 'email@example.com', 'John Doe'
745+
// FROM DUAL WHERE NOT EXISTS (
746+
// SELECT 1 FROM `user`
747+
// WHERE `email` = 'email@example.com' LIMIT 1
748+
// );
749+
```
750+
751+
**Example**: Insert without escaping some values
752+
```js
753+
const data = {
754+
placeId: 'ChIJK2f-X1bxK4gRkB0jxyh7AwU',
755+
type: 'city',
756+
// IMPORTANT: You must manually escape any user-input values used in the "raw" object.
757+
location: {__raw: 'POINT(-80.5204096, 43.4642578)'},
758+
};
759+
placeTable.insertIfNotExists(data, ['placeId', 'type'])
760+
.then(result => result.affectedRows);
761+
// 0 - If there was a row with the same `placeId` and `type` (row not inserted)
762+
// 1 - If there wasn't (row was inserted)
763+
764+
// INSERT INTO `place` (`placeId`, `type`, `location`)
765+
// SELECT 'ChIJK2f-X1bxK4gRkB0jxyh7AwU', 'city', POINT(-80.5204096, 43.4642578)
766+
// FROM DUAL WHERE NOT EXISTS (
767+
// SELECT 1 FROM `place`
768+
// WHERE `placeId` = 'ChIJK2f-X1bxK4gRkB0jxyh7AwU' AND `type` = 'city' LIMIT 1
769+
// );
770+
```
771+
772+
713773
---
714774

715775
<a name="MySQLTable+update"></a>

lib/MySQLTable.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,80 @@ class MySQLTable {
200200
);
201201
}
202202

203+
/**
204+
* Inserts a new row into the table if there are no existing rows in
205+
* the table that have the same values for the specified columns.
206+
*
207+
* This is useful because if the row is not inserted, the table's
208+
* `AUTO_INCREMENT` value is not increased (unlike when an insert
209+
* fails because of a unique key constraint).
210+
*
211+
* @param {Object} data - An object mapping column names to data values to insert.
212+
* The values are escaped by default. If you don't want a value to be escaped,
213+
* wrap it in a "raw" object (see the last example below).
214+
* @param {string[]} keyColumns - The names of columns in the `data` object.
215+
* If there is already a row in the table with the same values for these
216+
* columns as the values being inserted, the data will not be inserted.
217+
* @param {module:mysql-plus~queryCallback} [cb] - A callback that gets called with the results of the query.
218+
* @returns {?Promise} If the `cb` parameter is omitted, a promise that will
219+
* resolve with the results of the query is returned.
220+
*
221+
* @example <caption>Insert a new user if a user with the same email does not exist</caption>
222+
* userTable.insertIfNotExists({email: 'email@example.com', name: 'John Doe'}, ['email'])
223+
* .then(result => result.affectedRows);
224+
* // 0 - If there was a row with `email` = 'email@example.com' (row not inserted)
225+
* // 1 - If there wasn't (row was inserted)
226+
*
227+
* // INSERT INTO `user` (`email`, `name`)
228+
* // SELECT 'email@example.com', 'John Doe'
229+
* // FROM DUAL WHERE NOT EXISTS (
230+
* // SELECT 1 FROM `user`
231+
* // WHERE `email` = 'email@example.com' LIMIT 1
232+
* // );
233+
*
234+
* @example <caption>Insert without escaping some values</caption>
235+
* const data = {
236+
* placeId: 'ChIJK2f-X1bxK4gRkB0jxyh7AwU',
237+
* type: 'city',
238+
* // IMPORTANT: You must manually escape any user-input values used in the "raw" object.
239+
* location: {__raw: 'POINT(-80.5204096, 43.4642578)'},
240+
* };
241+
* placeTable.insertIfNotExists(data, ['placeId', 'type'])
242+
* .then(result => result.affectedRows);
243+
* // 0 - If there was a row with the same `placeId` and `type` (row not inserted)
244+
* // 1 - If there wasn't (row was inserted)
245+
*
246+
* // INSERT INTO `place` (`placeId`, `type`, `location`)
247+
* // SELECT 'ChIJK2f-X1bxK4gRkB0jxyh7AwU', 'city', POINT(-80.5204096, 43.4642578)
248+
* // FROM DUAL WHERE NOT EXISTS (
249+
* // SELECT 1 FROM `place`
250+
* // WHERE `placeId` = 'ChIJK2f-X1bxK4gRkB0jxyh7AwU' AND `type` = 'city' LIMIT 1
251+
* // );
252+
*/
253+
insertIfNotExists(data, keyColumns, cb) {
254+
const db = this._db;
255+
var columnNames = '';
256+
var insertValues = '';
257+
var whereClause = '';
258+
259+
for (var dataColumnName in data) {
260+
columnNames += (columnNames ? ',' : '') + db.escapeId(dataColumnName);
261+
insertValues += (insertValues ? ',' : '') + this._rawEscape(data[dataColumnName]);
262+
}
263+
264+
for (var i = 0; i < keyColumns.length; i++) {
265+
var keyColumnName = keyColumns[i];
266+
whereClause += (i > 0 ? ' AND ' : '') +
267+
db.escapeId(keyColumnName) + '=' + this._rawEscape(data[keyColumnName]);
268+
}
269+
270+
return db.pquery(
271+
`INSERT INTO ${this._escapedName} (${columnNames}) SELECT ${insertValues} FROM DUAL ` +
272+
`WHERE NOT EXISTS(SELECT 1 FROM ${this._escapedName} WHERE ${whereClause} LIMIT 1)`,
273+
cb
274+
);
275+
}
276+
203277
/**
204278
* Updates data in the table.
205279
*
@@ -333,6 +407,12 @@ class MySQLTable {
333407
transacting(trxn) {
334408
return new MySQLTable(this.name, this.schema, this.pool, trxn);
335409
}
410+
411+
_rawEscape(value) {
412+
return value && value.__raw !== undefined
413+
? value.__raw
414+
: this._db.escape(value);
415+
}
336416
}
337417

338418
module.exports = MySQLTable;

test/unit/MySQLTable.test.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict';
22

3+
const CallbackManager = require('es6-callback-manager');
34
const MySQLTable = require('../../lib/MySQLTable');
45
const MySQLPlus = require('../../lib/MySQLPlus');
56

@@ -411,6 +412,131 @@ describe('MySQLTable', () => {
411412
});
412413

413414

415+
describe('#insertIfNotExists()', () => {
416+
417+
describe('with a callback', () => {
418+
419+
before(done => {
420+
testTable.insertIfNotExists({email: 'one@email.com'}, ['email'], done);
421+
});
422+
423+
after(resetTable);
424+
425+
it('should not insert anything and not change the table if a row with the same key already exists', done => {
426+
testTable.insertIfNotExists({email: 'one@email.com'}, ['email'], (err, result) => {
427+
if (err) throw err;
428+
result.affectedRows.should.equal(0);
429+
430+
testTable.query('SHOW CREATE TABLE ' + testTable.name, (err, result) => {
431+
if (err) throw err;
432+
result[0]['Create Table'].should.match(/ AUTO_INCREMENT=2 /);
433+
done();
434+
});
435+
});
436+
});
437+
438+
it('should insert the specified data into the table', done => {
439+
testTable.insertIfNotExists({email: 'two@email.com'}, ['email'], (err, result) => {
440+
if (err) throw err;
441+
result.affectedRows.should.equal(1);
442+
result.insertId.should.equal(2);
443+
done();
444+
});
445+
});
446+
447+
it('should accept raw data to insert and not escape it', done => {
448+
const cbManager = new CallbackManager(done);
449+
const doneOnlyRaw = cbManager.registerCallback();
450+
const doneDataAndRaw = cbManager.registerCallback();
451+
const doneRowExists = cbManager.registerCallback();
452+
453+
testTable.insertIfNotExists({email: {__raw: '"three@email.com"'}}, ['email'], (err, result) => {
454+
if (err) throw err;
455+
result.affectedRows.should.equal(1);
456+
result.insertId.should.equal(3);
457+
458+
testTable.select('email', 'WHERE id = 3', (err, rows) => {
459+
if (err) throw err;
460+
rows[0].email.should.equal('three@email.com');
461+
doneOnlyRaw();
462+
});
463+
464+
testTable.insertIfNotExists({id: 5, email: {__raw: '"five@email.com"'}}, ['id', 'email'], (err, result) => {
465+
if (err) throw err;
466+
result.affectedRows.should.equal(1);
467+
result.insertId.should.equal(5);
468+
doneDataAndRaw();
469+
});
470+
});
471+
472+
testTable.insertIfNotExists({email: {__raw: '"one@email.com"'}}, ['email'], (err, result) => {
473+
if (err) throw err;
474+
result.affectedRows.should.equal(0);
475+
doneRowExists();
476+
});
477+
});
478+
479+
});
480+
481+
482+
describe('with a promise', () => {
483+
484+
before(
485+
() => testTable.insertIfNotExists({email: 'one@email.com'}, ['email'])
486+
);
487+
488+
after(resetTable);
489+
490+
it('should not insert anything and not change the table if a row with the same key already exists', () => {
491+
return testTable.insertIfNotExists({email: 'one@email.com'}, ['email'])
492+
.then(result => {
493+
result.affectedRows.should.equal(0);
494+
return testTable.query('SHOW CREATE TABLE ' + testTable.name);
495+
})
496+
.then(result => {
497+
result[0]['Create Table'].should.match(/ AUTO_INCREMENT=2 /);
498+
});
499+
});
500+
501+
it('should insert the specified data into the table', () => {
502+
return testTable.insertIfNotExists({email: 'two@email.com'}, ['email'])
503+
.then(result => {
504+
result.affectedRows.should.equal(1);
505+
result.insertId.should.equal(2);
506+
});
507+
});
508+
509+
it('should accept raw data to insert and not escape it', () => {
510+
const promiseNewRow = testTable.insertIfNotExists({email: {__raw: '"three@email.com"'}}, ['email'])
511+
.then(result => {
512+
result.affectedRows.should.equal(1);
513+
result.insertId.should.equal(3);
514+
515+
return Promise.all([
516+
testTable.select('email', 'WHERE id = 3'),
517+
testTable.insertIfNotExists({id: 5, email: {__raw: '"five@email.com"'}}, ['id', 'email']),
518+
]);
519+
})
520+
.then(results => {
521+
results[0][0].email.should.equal('three@email.com');
522+
523+
results[1].affectedRows.should.equal(1);
524+
results[1].insertId.should.equal(5);
525+
});
526+
527+
const promiseRowExists = testTable.insertIfNotExists({email: {__raw: '"one@email.com"'}}, ['email'])
528+
.then(result => {
529+
result.affectedRows.should.equal(0);
530+
});
531+
532+
return Promise.all([promiseNewRow, promiseRowExists]);
533+
});
534+
535+
});
536+
537+
});
538+
539+
414540
describe('#update()', () => {
415541

416542
describe('with a callback', () => {

0 commit comments

Comments
 (0)