feat: add capability for insert multiple rows in single query

Signed-off-by: Samarpan Bhattacharya <this.is.samy@gmail.com>
This commit is contained in:
Samarpan Bhattacharya 2022-09-09 13:43:18 +09:00 committed by Diana Lau
parent cb20ae6575
commit d29bec72a8
9 changed files with 1776 additions and 41 deletions

View File

@ -47,6 +47,8 @@ function Memory(m, settings) {
util.inherits(Memory, Connector);
Memory.prototype.multiInsertSupported = true;
Memory.prototype.getDefaultIdType = function() {
return Number;
};
@ -277,6 +279,29 @@ Memory.prototype.create = function create(model, data, options, callback) {
});
};
Memory.prototype.createAll = function create(model, dataArray, options, callback) {
const returnArr = [];
async.eachSeries(
dataArray,
(data, cb) => {
this._createSync(model, data, (err, id) => {
if (err) {
return process.nextTick(function() {
cb(err);
});
}
const returnData = Object.assign({}, data);
this.setIdValue(model, returnData, id);
returnArr.push(returnData);
this.saveToFile(id, cb);
});
},
(err) => {
callback(err, returnArr);
},
);
};
Memory.prototype.updateOrCreate = function(model, data, options, callback) {
const self = this;
this.exists(model, self.getIdValue(model, data), options, function(err, exists) {

View File

@ -457,6 +457,242 @@ DataAccessObject.create = function(data, options, cb) {
return cb.promise;
};
/**
* Create an instances of Model with given data array and save to the attached data source. Callback is optional.
* Example:
*```js
* User.createAll([{first: 'Joe', last: 'Bob'},{first: 'Tom', last: 'Cat'}], function(err, users) {
* console.log(users[0] instanceof User); // true
* });
* ```
* Note: You must include a callback and use the created models provided in the callback if your code depends on your model being
* saved or having an ID.
*
* @param {Object} [dataArray] Optional data object with array of records
* @param {Object} [options] Options for create
* @param {Function} [cb] Callback function called with these arguments:
* - err (null or Error)
* - instance (null or Models)
*/
DataAccessObject.createAll = function(dataArray, options, cb) {
const connectionPromise = stillConnecting(this.getDataSource(), this, arguments);
if (connectionPromise) {
return connectionPromise;
}
let Model = this;
const connector = Model.getConnector();
if (!connector.multiInsertSupported) {
// If multi insert is not supported, then, revert to create method
// Array is handled in create method already in legacy code
// This ensures backwards compatibility
return this.create(dataArray, options, cb);
}
assert(
typeof connector.createAll === 'function',
'createAll() must be implemented by the connector',
);
if (options === undefined && cb === undefined) {
if (typeof dataArray === 'function') {
// create(cb)
cb = dataArray;
dataArray = [];
}
} else if (cb === undefined) {
if (typeof options === 'function') {
// create(data, cb);
cb = options;
options = {};
}
}
dataArray = dataArray || [];
options = options || {};
cb = cb || utils.createPromiseCallback();
assert(typeof dataArray === 'object' && dataArray.length,
'The data argument must be an array with length > 0');
assert(typeof options === 'object', 'The options argument must be an object');
assert(typeof cb === 'function', 'The cb argument must be a function');
const validationPromises = [];
for (let index = 0; index < dataArray.length; index++) {
const data = dataArray[index];
const hookState = {};
const enforced = {};
let obj;
try {
obj = new Model(data);
this.applyProperties(enforced, obj);
obj.setAttributes(enforced);
} catch (err) {
process.nextTick(function() {
cb(err);
});
return cb.promise;
}
Model = this.lookupModel(data); // data-specific
if (Model !== obj.constructor) obj = new Model(data);
const context = {
Model: Model,
instance: obj,
isNewInstance: true,
hookState: hookState,
options: options,
};
const promise = new Promise((resolve, reject) => {
Model.notifyObserversOf('before save', context, function(err) {
if (err) return reject({
error: err,
data: obj,
});
const d = obj.toObject(true);
// options has precedence on model-setting
if (options.validate === false) {
return resolve(obj);
}
// only when options.validate is not set, take model-setting into consideration
if (
options.validate === undefined &&
Model.settings.automaticValidation === false
) {
return resolve(obj);
}
// validation required
obj.isValid(
function(valid) {
if (valid) {
resolve(obj);
} else {
reject({
error: new ValidationError(obj),
data: obj,
});
}
},
d,
options,
);
});
});
validationPromises.push(promise);
}
Promise.all(validationPromises).then((objArray) => {
const values = [];
const valMap = new Map();
objArray.forEach((obj) => {
const val = Model._sanitizeData(obj.toObject(true), options);
values.push(val);
valMap.set(obj, applyDefaultsOnWrites(val, Model.definition));
});
function createCallback(err, savedArray) {
if (err) {
return cb(err, objArray);
}
const context = values.map((val) => {
return {
Model: Model,
data: val,
isNewInstance: true,
hookState: {},
options: options,
};
});
Model.notifyObserversOf('loaded', context, function(err) {
if (err) return cb(err);
const afterSavePromises = [];
savedArray.map((obj) => {
const dataModel = new Model(obj);
let afterSavePromise;
if (options.notify !== false) {
const context = {
Model: Model,
instance: dataModel,
isNewInstance: true,
hookState: {},
options: options,
};
afterSavePromise = new Promise((resolve, reject) => {
Model.notifyObserversOf('after save', context, function(err) {
if (err) {
reject(err);
} else {
resolve(dataModel);
}
});
});
afterSavePromises.push(afterSavePromise);
} else {
afterSavePromises.push(Promise.resolve(dataModel));
}
});
Promise.all(afterSavePromises).then(saved => {
cb(null, saved);
}).catch(err => {
cb(err, objArray);
});
});
}
context = objArray.map(obj => {
return {
Model: Model,
data: valMap.get(obj),
isNewInstance: true,
currentInstance: obj,
hookState: {},
options: options,
};
});
const persistPromise = new Promise((resolve, reject) => {
Model.notifyObserversOf('persist', context, function(err, ctx) {
if (err) return reject(err);
const objDataArray = ctx
.map((obj) => {
return obj.currentInstance.constructor._forDB(obj.data);
})
.filter((objData) => !!objData);
resolve(objDataArray);
});
});
persistPromise.then((objDataArray) => {
invokeConnectorMethod(
connector,
'createAll',
Model,
[objDataArray],
options,
createCallback,
);
}).catch((err) => {
err && cb(err);
});
}).catch((err) => {
err && cb(err.error, err.data);
});
return cb.promise;
};
// Implementation of applyDefaultOnWrites property
function applyDefaultsOnWrites(obj, modelDefinition) {
for (const key in modelDefinition.properties) {

View File

@ -31,8 +31,9 @@
"lint": "eslint .",
"build": "npm run build-ts-types",
"build-ts-types": "tsc -p tsconfig.json --outDir dist",
"pretest": "npm run build",
"test": "nyc mocha",
"posttest": "npm run tsc && npm run lint"
"posttest": "npm run lint"
},
"devDependencies": {
"@commitlint/cli": "^17.2.0",

File diff suppressed because it is too large Load Diff

View File

@ -69,27 +69,6 @@ Object.defineProperty(module.exports, 'skip', {
value: skip,
});
function clearAndCreate(model, data, callback) {
const createdItems = [];
model.destroyAll(function() {
nextItem(null, null);
});
let itemIndex = 0;
function nextItem(err, lastItem) {
if (lastItem !== null) {
createdItems.push(lastItem);
}
if (itemIndex >= data.length) {
callback(createdItems);
return;
}
model.create(data[itemIndex], nextItem);
itemIndex++;
}
}
/* eslint-disable mocha/handle-done-callback */
function testOrm(dataSource) {
const requestsAreCounted = dataSource.name !== 'mongodb';
@ -251,6 +230,45 @@ function testOrm(dataSource) {
});
});
it('should save objects when createAll is invoked', function(test) {
const title = 'Initial title',
title2 = 'Hello world',
date = new Date();
Post.createAll(
[{
title: title,
date: date,
}],
function(err, objs) {
const obj = objs[0];
test.ok(obj.id, 'Object id should present');
test.equals(obj.title, title);
// test.equals(obj.date, date);
obj.title = title2;
test.ok(obj.propertyChanged('title'), 'Title changed');
obj.save(function(err, obj) {
test.equal(obj.title, title2);
test.ok(!obj.propertyChanged('title'));
const p = new Post({title: 1});
p.title = 2;
p.save(function(err, obj) {
test.ok(!p.propertyChanged('title'));
p.title = 3;
test.ok(p.propertyChanged('title'));
test.equal(p.title_was, 2);
p.save(function() {
test.equal(p.title_was, 3);
test.ok(!p.propertyChanged('title'));
test.done();
});
});
});
},
);
});
it('should create object with initial data', function(test) {
const title = 'Initial title',
date = new Date;

View File

@ -632,10 +632,13 @@ function seed(done) {
{id: 5, seq: 5, name: 'Stuart Sutcliffe', order: 3, vip: true},
];
async.series([
async.series(
[
User.destroyAll.bind(User),
function(cb) {
async.each(beatles, User.create.bind(User), cb);
User.createAll(beatles, cb);
},
], done);
],
done,
);
}

View File

@ -25,8 +25,16 @@ ContextRecorder.prototype.recordAndNext = function(transformFm) {
transformFm(context);
}
if (Array.isArray(context)) {
context = context.map(ctx => {
const ctxCopy = deepCloneToObject(ctx);
ctxCopy.hookState.test = true;
return ctxCopy;
});
} else {
context = deepCloneToObject(context);
context.hookState.test = true;
}
if (typeof self.records === 'string') {
self.records = context;
@ -37,7 +45,11 @@ ContextRecorder.prototype.recordAndNext = function(transformFm) {
self.records = [self.records];
}
if (Array.isArray(context)) {
self.records.push(...context);
} else {
self.records.push(context);
}
next();
};
};

View File

@ -671,6 +671,310 @@ module.exports = function(dataSource, should, connectorCapabilities) {
});
});
describe('PersistedModel.createAll', function() {
it('triggers hooks in the correct order', function(done) {
monitorHookExecution();
TestModel.createAll(
[{name: '1'}, {name: '2'}],
function(err) {
if (err) return done(err);
hookMonitor.names.should.eql([
'before save',
'before save',
'persist',
'loaded',
'after save',
'after save',
]);
done();
},
);
});
it('aborts when `after save` fires when option to notify is false', function(done) {
monitorHookExecution();
TestModel.create(
[{name: '1'}, {name: '2'}],
{notify: false},
function(err) {
if (err) return done(err);
hookMonitor.names.should.not.containEql('after save');
done();
},
);
});
it('triggers `before save` hook for each item in the array', function(done) {
TestModel.observe('before save', ctxRecorder.recordAndNext());
TestModel.createAll([{name: '1'}, {name: '2'}], function(err, list) {
if (err) return done(err);
// Creation of multiple instances is executed in parallel
ctxRecorder.records.sort(function(c1, c2) {
return c1.instance.name - c2.instance.name;
});
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
instance: {id: list[0].id, name: '1', extra: undefined},
isNewInstance: true,
}),
aCtxForModel(TestModel, {
instance: {id: list[1].id, name: '2', extra: undefined},
isNewInstance: true,
}),
]);
done();
});
});
it('aborts when `before save` hook fails', function(done) {
TestModel.observe('before save', nextWithError(expectedError));
TestModel.createAll([{name: '1'}, {name: '2'}], function(err) {
err.should.eql(expectedError);
done();
});
});
it('applies updates from `before save` hook to each item in the array', function(done) {
TestModel.observe('before save', function(ctx, next) {
ctx.instance.should.be.instanceOf(TestModel);
ctx.instance.extra = 'hook data';
next();
});
TestModel.createAll(
[{id: uid.next(), name: 'a-name'}, {id: uid.next(), name: 'b-name'}],
function(err, instances) {
if (err) return done(err);
instances.forEach(instance => {
instance.should.have.property('extra', 'hook data');
});
done();
},
);
});
it('validates model after `before save` hook', function(done) {
TestModel.observe('before save', invalidateTestModel());
TestModel.createAll([{name: 'created1'}, {name: 'created2'}], function(err) {
(err || {}).should.be.instanceOf(ValidationError);
(err.details.codes || {}).should.eql({name: ['presence']});
done();
});
});
it('triggers `persist` hook', function(done) {
TestModel.observe('persist', ctxRecorder.recordAndNext());
TestModel.createAll(
[{id: 'new-id-1', name: 'a name'}, {id: 'new-id-2', name: 'b name'}],
function(err, instances) {
if (err) return done(err);
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
data: {id: 'new-id-1', name: 'a name'},
isNewInstance: true,
currentInstance: {extra: null, id: 'new-id-1', name: 'a name'},
}),
aCtxForModel(TestModel, {
data: {id: 'new-id-2', name: 'b name'},
isNewInstance: true,
currentInstance: {extra: null, id: 'new-id-2', name: 'b name'},
}),
]);
done();
},
);
});
it('applies updates from `persist` hook', function(done) {
TestModel.observe(
'persist',
ctxRecorder.recordAndNext(function(ctxArr) {
// It's crucial to change `ctx.data` reference, not only data props
ctxArr.forEach(ctx => {
ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
});
}),
);
// By default, the instance passed to create callback is NOT updated
// with the changes made through persist/loaded hooks. To preserve
// backwards compatibility, we introduced a new setting updateOnLoad,
// which if set, will apply these changes to the model instance too.
TestModel.settings.updateOnLoad = true;
TestModel.createAll(
[{id: 'new-id', name: 'a name'}],
function(err, instances) {
if (err) return done(err);
instances.forEach(instance => {
instance.should.have.property('extra', 'hook data');
});
// Also query the database here to verify that, on `create`
// updates from `persist` hook are reflected into database
TestModel.findById('new-id', function(err, dbInstance) {
if (err) return done(err);
should.exists(dbInstance);
dbInstance.toObject(true).should.eql({
id: 'new-id',
name: 'a name',
extra: 'hook data',
});
done();
});
},
);
});
it('triggers `loaded` hook', function(done) {
TestModel.observe('loaded', ctxRecorder.recordAndNext());
// By default, the instance passed to create callback is NOT updated
// with the changes made through persist/loaded hooks. To preserve
// backwards compatibility, we introduced a new setting updateOnLoad,
// which if set, will apply these changes to the model instance too.
TestModel.settings.updateOnLoad = true;
TestModel.createAll(
[
{id: 'new-id-1', name: 'a name'},
{id: 'new-id-2', name: 'b name'},
],
function(err) {
if (err) return done(err);
ctxRecorder.records.sort(function(c1, c2) {
return c1.data.name - c2.data.name;
});
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
data: {id: 'new-id-1', name: 'a name'},
isNewInstance: true,
}),
aCtxForModel(TestModel, {
data: {id: 'new-id-2', name: 'b name'},
isNewInstance: true,
}),
]);
done();
},
);
});
it('emits error when `loaded` hook fails', function(done) {
TestModel.observe('loaded', nextWithError(expectedError));
TestModel.createAll(
[{id: 'new-id', name: 'a name'}],
function(err) {
err.should.eql(expectedError);
done();
},
);
});
it('applies updates from `loaded` hook', function(done) {
TestModel.observe(
'loaded',
ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
}),
);
// By default, the instance passed to create callback is NOT updated
// with the changes made through persist/loaded hooks. To preserve
// backwards compatibility, we introduced a new setting updateOnLoad,
// which if set, will apply these changes to the model instance too.
TestModel.settings.updateOnLoad = true;
TestModel.create(
[{id: 'new-id', name: 'a name'}],
function(err, instances) {
if (err) return done(err);
instances.forEach((instance) => {
instance.should.have.property('extra', 'hook data');
});
done();
},
);
});
it('triggers `after save` hook', function(done) {
TestModel.observe('after save', ctxRecorder.recordAndNext());
TestModel.createAll([{name: '1'}, {name: '2'}], function(err, list) {
if (err) return done(err);
ctxRecorder.records.sort(function(c1, c2) {
return c1.instance.name - c2.instance.name;
});
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
instance: {id: list[0].id, name: '1', extra: undefined},
isNewInstance: true,
}),
aCtxForModel(TestModel, {
instance: {id: list[1].id, name: '2', extra: undefined},
isNewInstance: true,
}),
]);
done();
});
});
it('aborts when `after save` hook fails', function(done) {
TestModel.observe('after save', nextWithError(expectedError));
TestModel.createAll([{name: 'created'}], function(err) {
err.should.eql(expectedError);
done();
});
});
it('applies updates from `after save` hook', function(done) {
TestModel.observe('after save', function(ctx, next) {
ctx.instance.should.be.instanceOf(TestModel);
ctx.instance.extra = 'hook data';
next();
});
TestModel.createAll([
{name: 'a-name'},
{name: 'b-name'},
], function(err, instances) {
if (err) return done(err);
instances.forEach((instance) => {
instance.should.have.property('extra', 'hook data');
});
done();
});
});
it('do not emit `after save` when before save fails for even one', function(done) {
TestModel.observe('before save', function(ctx, next) {
if (ctx.instance.name === 'fail') next(expectedError);
else next();
});
TestModel.observe('after save', ctxRecorder.recordAndNext());
TestModel.createAll([{name: 'ok'}, {name: 'fail'}], function(err, list) {
err.should.eql(expectedError);
done();
});
});
});
describe('PersistedModel.findOrCreate', function() {
it('triggers `access` hook', function(done) {
TestModel.observe('access', ctxRecorder.recordAndNext());

View File

@ -47,6 +47,21 @@ export declare class PersistedModel extends ModelBase {
callback?: Callback<PersistedModel[]>,
): PromiseOrVoid<PersistedModel[]>;
/**
* Creates an array of new instances of Model, and save to database in one DB query.
*
* @param {Object[]} [data] Optional data argument. An array of instances.
*
* @callback {Function} callback Callback function called with `cb(err, obj)` signature.
* @param {Error} err Error object; see [Error object](http://loopback.io/doc/en/lb2/Error-object.html).
* @param {Object} models Model instances or null.
*/
static createAll(
data: PersistedData[],
options?: Options,
callback?: Callback<PersistedModel[]>,
): PromiseOrVoid<PersistedModel[]>;
/**
* Update or insert a model instance
* @param {Object} data The model instance data to insert.