Skip to content

Commit

Permalink
Add tests to cover cascade operations
Browse files Browse the repository at this point in the history
  • Loading branch information
sam9291 committed Dec 21, 2016
1 parent d9e88ef commit 986d28e
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 16 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"SqlTransactionFactory": true,
"SqlTransactionHandler": true,
"Dog": true,
"Bone": true
"Bone": true,
"Food": true
}
}
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,64 @@ You need to provide a callback that receives a transaction object and that must
> This connects the opened transaction to the sails model to prepare the query for the transaction


We also need to wrap our models using the method `this.cascadeOperationForModel` when accessing them in any cascade operations in case we are in a transaction to ensure using the same connection:

- `beforeValidate`
- `afterValidate`
- `beforeCreate`
- `afterCreate`

- `beforeValidate`
- `afterValidate`
- `beforeUpdate`
- `afterUpdate`

- `beforeDestroy`
- `afterDestroy`

```
// Dog.js
module.exports = {
attributes: {
name: { type: 'string', unique: true },
bones: {
collection: 'bone',
via: 'dogs',
dominant: true
},
mainBones: {
collection: 'bone',
via: 'owner'
}
},
beforeDestroy: beforeDestroy
};
function beforeDestroy(criteria, cb) {
// we may be in a transaction, wrap all models using this method
// to bind the right connection if we are in a transaction
const forModel = this.cascadeOperationForModel;
forModel(Dog).find(criteria, { select: ['id'] })
.then(dogIds => {
if (dogIds.length) {
// if we are in a transaction, we want to be able to rollback
// if something goes wrong later
return forModel(Bone)
.update({ owner: _.map(dogIds, 'id') }, { owner: null });
}
})
.then(() => cb())
.catch(cb);
}
```

# Example (taken from my tests):
```
//note here we are not wraping the function with
Expand Down
22 changes: 18 additions & 4 deletions api/adapters/adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@ const mysqlAdapter = require('sails-mysql/lib/adapter.js'),
defaultMethods = require('waterline/lib/waterline/model/lib/defaultMethods'),
basicFinders = require('waterline/lib/waterline/query/finders/basic'),
aggregates = require('waterline/lib/waterline/query/aggregate'),
composites = require('waterline/lib/waterline/query/composite')
composites = require('waterline/lib/waterline/query/composite'),
Collection = require('waterline').Collection;

_.extend(adapterWrapper, {
identity: 'sails-mysql-atomic'
});

// Extend waterline Collection prototype
_.extend(Collection.prototype, {
// setup default return method
cascadeOperationForModel: function(model) {return model;}
});

// Deferred extensions
Deferred.prototype.toPromiseWithConnection = function (functionName, connection) {
Deferred.prototype.toPromiseWithConnection = function (functionName, connection, sqlTransaction) {
if (!this._deferred) {
// here we are starting by cloning the context so we can overwrite safely
// our query methods to allow injecting the current transaction id in the adapter
Expand All @@ -32,10 +39,13 @@ Deferred.prototype.toPromiseWithConnection = function (functionName, connection)
const context = this._context;
context._model = function (values) {
const newModel = new originalModel(values);
// overwrite the model save method to pass the current context
// overwrite the model save and destroy method to pass the current context
newModel.save = function (options, cb) {
return new defaultMethods.save(context, this, options, cb);
};
newModel.destroy = function (cb) {
return new defaultMethods.destroy(context, this, cb);
};
return newModel;
};
// overwrite the dql methods to remove lodash bind wrapper to allow the context
Expand All @@ -52,7 +62,11 @@ Deferred.prototype.toPromiseWithConnection = function (functionName, connection)
this._context[key] = definition[key];
// we need to overwrite it in the waterline collections as well since it is used when creating
// associations
_.each(this._context.waterline.collections, collection => collection[key] = definition[key]);
_.each(this._context.waterline.collections, collection => {
collection[key] = definition[key];
// overwrite the default cascade operation method to use this transaction
collection.cascadeOperationForModel = sqlTransaction.forModel;
});
});
});

Expand Down
4 changes: 4 additions & 0 deletions api/models/Bone.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ module.exports = {
dogs: {
collection: 'dog',
via:'bones'
},

owner: {
model:'dog'
}
}
};
Expand Down
44 changes: 37 additions & 7 deletions api/models/Dog.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,45 @@
module.exports = {

attributes: {

name: { type: 'string', unique: true },

bones: {
collection: 'bone',
via:'dogs',
dominant: true
}
collection: 'bone',
via: 'dogs',
dominant: true
},

favoriteFoodTypes: {
collection: 'food',
via: 'dogs',
dominant: true
},

mainBones: {
collection: 'bone',
via: 'owner'
},

}


},

beforeDestroy: beforeDestroy
};

function beforeDestroy(criteria, cb) {
// we may be in a transaction, wrap all models using this method
// to bind the right connection if we are in a transaction
const forModel = this.cascadeOperationForModel;
forModel(Dog).find(criteria, { select: ['id'] })
.then(dogIds => {
if (dogIds.length) {
return forModel(Bone)
.update({ owner: _.map(dogIds, 'id') }, { owner: null });
}
})
.then(() => cb())
.catch(cb);
}

13 changes: 13 additions & 0 deletions api/models/Food.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module.exports = {

attributes: {

name: { type: 'string' },

dogs: {
collection: 'dog',
via: 'favoriteFoodTypes'
}

}
};
5 changes: 3 additions & 2 deletions api/services/SqlTransactionFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ function SqlTransactionFactory() {
* @param {MySqlConnection} connection the mysql connection
*/
function SqlTransaction(connection) {

const self = this;

let committed = false;
let rolledBack = false;
let resolveAfterTransactionPromise;
Expand Down Expand Up @@ -76,7 +77,7 @@ function SqlTransaction(connection) {
modelClone[functionName] = function () {

const deferred = originalFunction.apply(modelClone, arguments);
deferred.toPromiseWithConnection(functionName, connection);
deferred.toPromiseWithConnection(functionName, connection, self);
return deferred;
};
});
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sails-mysql-atomic",
"version": "1.3.3",
"version": "1.3.4",
"description": "Helper service to facilitate the usage of mysql transactions",
"main": "index.js",
"sails": {
Expand Down
114 changes: 113 additions & 1 deletion test/transactions.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ describe('SqlTransaction ::', () => {

describe('beginTransaction ::', () => {
beforeEach(done => {
Dog.destroy({}).then(() => done());
Promise.all([
Dog.destroy({}),
Bone.destroy({}),
Food.destroy({})
]).then(() => done());
});

const Promise = require('bluebird');
Expand Down Expand Up @@ -385,6 +389,114 @@ describe('SqlTransaction ::', () => {

});

it('should not crash when creating object with empty association defined', done => {

SqlHelper.beginTransaction(transaction =>
transaction.forModel(Dog).create({ name: 'fido', bones: [] })
)
.then(() => done())
.catch(err => done('failed: ' + err));

});


it('should handle one-to-many associations', done => {

SqlHelper.beginTransaction(transaction =>
transaction.forModel(Dog).create({ name: 'fido', mainBones: [{ size: 'small' }] })
)
.then(() => Dog.count({}))
.then(count => count.should.be.equal(1))
.then(() => Bone.count({}))
.then(count => count.should.be.equal(1))
.then(() => Bone.findOne({ size: 'small' }))
.then(bone => (!bone.owner).should.be.equal(false))
.then(() => SqlHelper.beginTransaction(transaction => {
return transaction.forModel(Dog).destroy({ name: 'fido' });
}
))
.then(() => Dog.count({}))
.then(count => count.should.be.equal(0))
.then(() => Bone.count({}))
.then(count => count.should.be.equal(1))
.then(() => Bone.findOne({ size: 'small' }))
.then(bone => {
(!bone.owner).should.be.equal(true);
})
.then(() => done())
.catch(err => done('failed: ' + err));

});

it('should handle one-to-many associations', done => {

SqlHelper.beginTransaction(transaction =>
transaction.forModel(Dog).create({ name: 'fido', mainBones: [{ size: 'small' }] })
)
.then(() => Dog.count({}))
.then(count => count.should.be.equal(1))
.then(() => Bone.count({}))
.then(count => count.should.be.equal(1))
.then(() => Bone.findOne({ size: 'small' }))
.then(bone => (!bone.owner).should.be.equal(false))
.then(() => SqlHelper.beginTransaction(transaction => {
return transaction.forModel(Dog).destroy({ name: 'fido' })
.then(() => transaction.rollback());
}
))
.catch(() => { })
.then(() => Dog.count({}))
.then(count => count.should.be.equal(1))
.then(() => Bone.count({}))
.then(count => count.should.be.equal(1))
.then(() => Bone.findOne({ size: 'small' }))
.then(bone => {
(!bone.owner).should.be.equal(false);
})
.then(() => done())
.catch(err => done('failed: ' + err));

});

it('should rollback multiple many-to-many association creation', done => {
SqlHelper.beginTransaction(transaction =>
transaction.forModel(Dog)
.create({
name: 'fido',
bones: [{ size: 'small' }, { size: 'big' }],
favoriteFoodTypes: [{ name: 'bone' }, { name: 'poutine' }]
})
.then(() => transaction.rollback())
.then(() => Dog.count({}))
.then(count => count.should.be.equal(0))
.then(() => Bone.count({}))
.then(count => count.should.be.equal(0))
.then(() => Food.count({}))
.then(count => count.should.be.equal(0))
.then(() => done())
.catch(done)
);
});

it('should commit multiple many-to-many association creation', done => {
SqlHelper.beginTransaction(transaction =>
transaction.forModel(Dog)
.create({
name: 'fido',
bones: [{ size: 'small' }, { size: 'big' }],
favoriteFoodTypes: [{ name: 'bone' }, { name: 'poutine' }]
})
.then(() => transaction.commit())
.then(() => Dog.count({}))
.then(count => count.should.be.equal(1))
.then(() => Bone.count({}))
.then(count => count.should.be.equal(2))
.then(() => Food.count({}))
.then(count => count.should.be.equal(2))
.then(() => done())
.catch(done)
);
});

});

Expand Down

0 comments on commit 986d28e

Please sign in to comment.