Coverage

43%
243
105
138

/home/pmcnr/work/jsonapi-store-relationaldb/lib/sqlHandler.js

43%
243
105
138
LineHitsSource
11"use strict";
2// http://docs.sequelizejs.com/en/latest/
31var Sequelize = require("sequelize");
41var async = require("async");
51var crypto = require("crypto");
61var debug = require("debug")("jsonApi:store:relationaldb");
71var _ = {
8 pick: require("lodash.pick"),
9 assign: require("lodash.assign"),
10 omit: require("lodash.omit")
11};
12
131var SqlStore = module.exports = function SqlStore(config) {
145 this.config = config;
15};
16
171SqlStore._sequelizeInstances = Object.create(null);
18
19/**
20 Handlers readiness status. This should be set to `true` once all handlers are ready to process requests.
21 */
221SqlStore.prototype.ready = false;
23
24/**
25 initialise gets invoked once for each resource that uses this hander.
26 In this instance, we're instantiating a Sequelize instance and building models.
27 */
281SqlStore.prototype.initialise = function(resourceConfig) {
295 var self = this;
305 self.resourceConfig = resourceConfig;
31
325 var database = self.config.database || resourceConfig.resource;
335 var sequelizeArgs = [database, self.config.username, self.config.password, {
34 dialect: self.config.dialect,
35 host: self.config.host,
36 port: self.config.port,
37 logging: self.config.logging || debug,
38 freezeTableName: true
39 }];
40
41 // To prevent too many open connections, we will store all Sequelize instances in a hash map.
42 // Index the hash map by a hash of the entire config object. If the same config is passed again,
43 // reuse the existing Sequelize connection resource instead of opening a new one.
44
455 var md5sum = crypto.createHash('md5');
465 var instanceId = md5sum.update(JSON.stringify(sequelizeArgs)).digest('hex');
475 var instances = SqlStore._sequelizeInstances;
48
495 if (!instances[instanceId]) {
501 var sequelize = Object.create(Sequelize.prototype);
511 Sequelize.apply(sequelize, sequelizeArgs);
521 instances[instanceId] = sequelize;
53 }
54
555 self.sequelize = instances[instanceId];
56
575 self._buildModels();
58
595 self.ready = true;
60};
61
621SqlStore.prototype.populate = function(callback) {
635 var self = this;
64
655 var tasks = [
66 function(cb) {
675 self.baseModel.sync().asCallback(cb);
68 },
69 function(cb) {
705 async.each(self.relationArray, function(model, ecb) {
717 model.sync().asCallback(ecb);
72 }, cb);
73 },
74 function(cb) {
752 async.each(self.resourceConfig.examples, function(exampleJson, ecb) {
768 self.create({ request: { type: self.resourceConfig.resource } }, exampleJson, ecb);
77 }, cb);
78 }
79 ];
80
815 async.series(tasks, callback);
82};
83
841SqlStore.prototype._buildModels = function() {
855 var self = this;
86
875 var localAttributes = Object.keys(self.resourceConfig.attributes).filter(function(attributeName) {
8826 var settings = self.resourceConfig.attributes[attributeName]._settings;
8939 if (!settings) return true;
9013 return !(settings.__one || settings.__many);
91 });
925 localAttributes = _.pick(self.resourceConfig.attributes, localAttributes);
935 var relations = Object.keys(self.resourceConfig.attributes).filter(function(attributeName) {
9426 var settings = self.resourceConfig.attributes[attributeName]._settings;
9539 if (!settings) return false;
9613 return (settings.__one || settings.__many) && !settings.__as;
97 });
985 relations = _.pick(self.resourceConfig.attributes, relations);
99
1005 var modelAttributes = self._joiSchemaToSequelizeModel(localAttributes);
1015 self.baseModel = self.sequelize.define(self.resourceConfig.resource, modelAttributes, { timestamps: false });
102
1035 self.relations = { };
1045 self.relationArray = [ ];
1055 Object.keys(relations).forEach(function(relationName) {
1067 var relation = relations[relationName];
1077 var otherModel = self._defineRelationModel(relationName, relation._settings.__many);
1087 self.relations[relationName] = otherModel;
1097 self.relationArray.push(otherModel);
110 });
111};
112
1131SqlStore.prototype._joiSchemaToSequelizeModel = function(joiSchema) {
1145 var model = {
115 id: { type: new Sequelize.STRING(38), primaryKey: true },
116 type: Sequelize.STRING,
117 meta: {
118 type: Sequelize.STRING,
119 get: function() {
1200 var data = this.getDataValue("meta");
1210 if (!data) return undefined;
1220 return JSON.parse(data);
123 },
124 set: function(val) {
1250 return this.setDataValue("meta", JSON.stringify(val));
126 }
127 }
128 };
129
1305 Object.keys(joiSchema).forEach(function(attributeName) {
13113 var attribute = joiSchema[attributeName];
13222 if (attribute._type === "string") model[attributeName] = { type: Sequelize.STRING, allowNull: true };
13315 if (attribute._type === "date") model[attributeName] = { type: Sequelize.STRING, allowNull: true };
13415 if (attribute._type === "number") model[attributeName] = { type: Sequelize.INTEGER, allowNull: true };
135 });
136
1375 return model;
138};
139
1401SqlStore.prototype._defineRelationModel = function(relationName, many) {
1417 var self = this;
142
1437 var modelName = self.resourceConfig.resource + "-" + relationName;
1447 var modelProperties = {
145 uid: {
146 type: Sequelize.INTEGER,
147 primaryKey: true,
148 autoIncrement: true
149 },
150 id: {
151 type: new Sequelize.STRING(38),
152 allowNull: false
153 },
154 type: {
155 type: new Sequelize.STRING(38),
156 allowNull: false
157 },
158 meta: {
159 type: Sequelize.STRING,
160 get: function() {
1610 var data = this.getDataValue("meta");
1620 if (!data) return undefined;
1630 return JSON.parse(data);
164 },
165 set: function(val) {
1660 return this.setDataValue("meta", JSON.stringify(val));
167 }
168 }
169 };
170
1717 var relatedModel = self.sequelize.define(modelName, modelProperties, {
172 timestamps: false,
173 indexes: [ { fields: [ "id" ] } ],
174 freezeTableName: true
175 });
176
1777 if (many) {
1783 self.baseModel.hasMany(relatedModel, { onDelete: "CASCADE", foreignKey: self.resourceConfig.resource + "Id" });
179 } else {
1804 self.baseModel.hasOne(relatedModel, { onDelete: "CASCADE", foreignKey: self.resourceConfig.resource + "Id" });
181 }
182
1837 return relatedModel;
184};
185
1861SqlStore.prototype._fixObject = function(json) {
1870 var self = this;
1880 var resourceId = self.resourceConfig.resource + "Id";
189
1900 Object.keys(json).forEach(function(attribute) {
1910 if (attribute.indexOf(self.resourceConfig.resource + "-") !== 0) return;
192
1930 var fixedName = attribute.split(self.resourceConfig.resource + "-").pop();
1940 json[fixedName] = json[attribute];
195
1960 var val = json[attribute];
1970 delete json[attribute];
1980 if (!val) return;
199
2000 if (!(val instanceof Array)) val = [ val ];
2010 val.forEach(function(j) {
2020 if (j.uid) delete j.uid;
2030 if (j[resourceId]) delete j[resourceId];
204 });
205 });
206
2070 return json;
208};
209
2101SqlStore.prototype._errorHandler = function(e, callback) {
211 // console.log(e, e.stack);
2121 return callback({
213 status: "500",
214 code: "EUNKNOWN",
215 title: "An unknown error has occured",
216 detail: "Something broke when connecting to the database - " + e.message
217 });
218};
219
2201SqlStore.prototype._filterInclude = function(relationships) {
2210 var self = this;
2220 relationships = _.pick(relationships, Object.keys(self.relations));
223
2240 var includeBlock = Object.keys(self.relations).map(function(relationName) {
2250 var model = self.relations[relationName];
2260 var matchingValue = relationships[relationName];
2270 if (!matchingValue) return model;
228
2290 if (matchingValue instanceof Array) {
2300 matchingValue = matchingValue.filter(function(i) {
2310 return !(i instanceof Object);
232 });
2330 } else if (matchingValue instanceof Object) {
2340 return model;
235 }
236
2370 return {
238 model: model,
239 where: { id: matchingValue }
240 };
241 });
242
2430 return includeBlock;
244};
245
2461SqlStore.prototype._generateSearchBlock = function(request) {
2470 var self = this;
248
2490 var attributesToFilter = _.omit(request.params.filter, Object.keys(self.relations));
2500 var searchBlock = self._recurseOverSearchBlock(attributesToFilter);
2510 return searchBlock;
252};
253
2541SqlStore.prototype._recurseOverSearchBlock = function(obj) {
2550 var self = this;
2560 if (!obj) return { };
2570 var searchBlock = { };
258
2590 Object.keys(obj).forEach(function(attributeName) {
2600 var textToMatch = obj[attributeName];
2610 if (textToMatch instanceof Array) {
2620 searchBlock[attributeName] = { $or: textToMatch.map(function(i) {
2630 return self._recurseOverSearchBlock({ i: i }).i;
264 }) };
2650 } else if (textToMatch instanceof Object) {
266 // Do nothing, its a nested filter
2670 } else if (textToMatch[0] === ">") {
2680 searchBlock[attributeName] = { $gt: textToMatch.substring(1) };
2690 } else if (textToMatch[0] === "<") {
2700 searchBlock[attributeName] = { $lt: textToMatch.substring(1) };
2710 } else if (textToMatch[0] === "~") {
2720 searchBlock[attributeName] = { $like: textToMatch.substring(1) };
2730 } else if (textToMatch[0] === ":") {
2740 searchBlock[attributeName] = { $like: "%" + textToMatch.substring(1) + "%" };
275 } else {
2760 searchBlock[attributeName] = textToMatch;
277 }
278 });
279
2800 return searchBlock;
281};
282
2831SqlStore.prototype._dealWithTransaction = function(done, callback) {
2848 var self = this;
2858 var transactionOptions = {
286 isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED,
287 autocommit: false
288 };
2898 self.sequelize.transaction(transactionOptions).asCallback(function(err1, transaction) {
2904 if (err1) return done(err1);
291
2924 var t = { transaction: transaction };
2934 var commit = function() {
2940 var args = arguments;
2950 transaction.commit().asCallback(function(err2) {
2960 if (err2) return done(err2);
2970 return done.apply(null, Array.prototype.slice.call(args));
298 });
299 };
3004 var rollback = function(e) {
3012 debug("Err", e);
3022 var a = function() {
3032 if (e instanceof Error) return self._errorHandler(e, done);
3040 return done(e);
305 };
3062 transaction.rollback().then(a, a);
307 };
3084 var finishTransaction = function(err) {
3094 if (err) return rollback(err);
3100 return commit.apply(null, Array.prototype.slice.call(arguments));
311 };
312
3134 return callback(t, finishTransaction);
314 });
315};
316
3171SqlStore.prototype._clearAndSetRelationTables = function(theResource, partialResource, t, callback) {
3180 var self = this;
319
3200 var tasks = { };
3210 Object.keys(self.relations).forEach(function(relationName) {
3220 var prop = partialResource[relationName];
3230 if (!partialResource.hasOwnProperty(relationName)) return;
3240 var relationModel = self.relations[relationName];
325
3260 var keyName = self.resourceConfig.resource + "-" + relationName;
3270 var uc = keyName[0].toUpperCase() + keyName.slice(1, keyName.length);
328
3290 tasks[relationName] = function(taskCallback) {
3300 if (prop instanceof Array) {
3310 (theResource[keyName] || []).map(function(deadRow) {
3320 deadRow.destroy(t);
333 });
334
3350 async.map(prop, function(item, acallback) {
3360 relationModel.create(item, t).asCallback(function(err4, newRelationModel) {
3370 if (err4) return acallback(err4);
338
3390 theResource["add" + uc](newRelationModel, t).asCallback(acallback);
340 });
341 }, taskCallback);
342 } else {
3430 if (theResource[keyName]) {
3440 theResource[keyName].destroy(t);
345 }
3460 if (!prop) {
3470 theResource["set" + uc](null, t).asCallback(taskCallback);
348 } else {
3490 relationModel.create(prop, t).asCallback(function(err3, newRelationModel) {
3500 if (err3) return taskCallback(err3);
351
3520 theResource["set" + uc](newRelationModel, t).asCallback(taskCallback);
353 });
354 }
355 }
356 };
357 });
358
3590 async.parallel(tasks, callback);
360};
361
3621SqlStore.prototype._generateSearchOrdering = function(request) {
3630 if (!request.params.sort) return undefined;
364
3650 var attribute = request.params.sort;
3660 var order = "ASC";
3670 attribute = String(attribute);
3680 if (attribute[0] === "-") {
3690 order = "DESC";
3700 attribute = attribute.substring(1, attribute.length);
371 }
3720 return [ [ attribute, order ] ];
373};
374
3751SqlStore.prototype._generateSearchPagination = function(request) {
3760 if (!request.params.page) {
3770 return { };
378 }
379
3800 return {
381 limit: request.params.page.limit,
382 offset: request.params.page.offset
383 };
384};
385
386/**
387 Search for a list of resources, given a resource type.
388 */
3891SqlStore.prototype.search = function(request, callback) {
3900 var self = this;
391
3920 var base = {
393 where: self._generateSearchBlock(request),
394 include: self._filterInclude(request.params.filter),
395 order: self._generateSearchOrdering(request)
396 };
3970 var query = _.assign(base, self._generateSearchPagination(request));
398
3990 self.baseModel.findAndCount(query).asCallback(function(err, result) {
4000 debug(query, err, JSON.stringify(result));
4010 if (err) return self._errorHandler(err, callback);
402
4030 var records = result.rows.map(function(i){ return self._fixObject(i.toJSON()); });
4040 debug("Produced", JSON.stringify(records));
4050 return callback(null, records, result.count);
406 });
407};
408
409/**
410 Find a specific resource, given a resource type and and id.
411 */
4121SqlStore.prototype.find = function(request, callback) {
4130 var self = this;
414
4150 self.baseModel.findOne({
416 where: { id: request.params.id },
417 include: self.relationArray
418 }).asCallback(function(err, theResource) {
4190 if (err) return self._errorHandler(err, callback);
420
421 // If the resource doesn't exist, error
4220 if (!theResource) {
4230 return callback({
424 status: "404",
425 code: "ENOTFOUND",
426 title: "Requested resource does not exist",
427 detail: "There is no " + request.params.type + " with id " + request.params.id
428 });
429 }
430
4310 theResource = self._fixObject(theResource.toJSON());
4320 debug("Produced", JSON.stringify(theResource));
4330 return callback(null, theResource);
434 });
435};
436
437/**
438 Create (store) a new resource give a resource type and an object.
439 */
4401SqlStore.prototype.create = function(request, newResource, finishedCallback) {
4418 var self = this;
442
4438 self._dealWithTransaction(finishedCallback, function(t, finishTransaction) {
444
4454 self.baseModel.create(newResource, t).asCallback(function(err2, theResource) {
4464 if (err2) return finishTransaction(err2);
447
4480 self._clearAndSetRelationTables(theResource, newResource, t, function(err){
4490 if (err) return finishTransaction(err);
450
4510 return finishTransaction(null, newResource);
452 });
453 });
454 });
455};
456
457/**
458 Delete a resource, given a resource type and and id.
459 */
4601SqlStore.prototype.delete = function(request, callback) {
4610 var self = this;
462
4630 self.baseModel.findAll({
464 where: { id: request.params.id },
465 include: self.relationArray
466 }).asCallback(function(findErr, results) {
4670 if (findErr) return self._errorHandler(findErr, callback);
468
4690 var theResource = results[0];
470
471 // If the resource doesn't exist, error
4720 if (!theResource) {
4730 return callback({
474 status: "404",
475 code: "ENOTFOUND",
476 title: "Requested resource does not exist",
477 detail: "There is no " + request.params.type + " with id " + request.params.id
478 });
479 }
480
4810 theResource.destroy().asCallback(function(deleteErr) {
4820 return callback(deleteErr);
483 });
484 });
485};
486
487/**
488 Update a resource, given a resource type and id, along with a partialResource.
489 partialResource contains a subset of changes that need to be merged over the original.
490 */
4911SqlStore.prototype.update = function(request, partialResource, finishedCallback) {
4920 var self = this;
493
4940 self._dealWithTransaction(finishedCallback, function(t, finishTransaction) {
495
4960 self.baseModel.findOne({
497 where: { id: request.params.id },
498 include: self.relationArray,
499 transaction: t.transaction
500 }).asCallback(function(err2, theResource) {
5010 if (err2) return finishTransaction(err2);
502
503 // If the resource doesn't exist, error
5040 if (!theResource) {
5050 return finishTransaction({
506 status: "404",
507 code: "ENOTFOUND",
508 title: "Requested resource does not exist",
509 detail: "There is no " + request.params.type + " with id " + request.params.id
510 });
511 }
512
5130 self._clearAndSetRelationTables(theResource, partialResource, t, function(err){
5140 if (err) return finishTransaction(err);
515
5160 theResource.update(partialResource, t).asCallback(function(err3) {
5170 if (err) return finishTransaction(err3);
5180 return finishTransaction(null, partialResource);
519 });
520 });
521 });
522 });
523};
524