1 | 1 | "use strict"; |
2 | | // http://docs.sequelizejs.com/en/latest/ |
3 | 1 | var Sequelize = require("sequelize"); |
4 | 1 | var async = require("async"); |
5 | 1 | var crypto = require("crypto"); |
6 | 1 | var debug = require("debug")("jsonApi:store:relationaldb"); |
7 | 1 | var _ = { |
8 | | pick: require("lodash.pick"), |
9 | | assign: require("lodash.assign"), |
10 | | omit: require("lodash.omit") |
11 | | }; |
12 | | |
13 | 1 | var SqlStore = module.exports = function SqlStore(config) { |
14 | 5 | this.config = config; |
15 | | }; |
16 | | |
17 | 1 | SqlStore._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 | | */ |
22 | 1 | SqlStore.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 | | */ |
28 | 1 | SqlStore.prototype.initialise = function(resourceConfig) { |
29 | 5 | var self = this; |
30 | 5 | self.resourceConfig = resourceConfig; |
31 | | |
32 | 5 | var database = self.config.database || resourceConfig.resource; |
33 | 5 | 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 | | |
45 | 5 | var md5sum = crypto.createHash('md5'); |
46 | 5 | var instanceId = md5sum.update(JSON.stringify(sequelizeArgs)).digest('hex'); |
47 | 5 | var instances = SqlStore._sequelizeInstances; |
48 | | |
49 | 5 | if (!instances[instanceId]) { |
50 | 1 | var sequelize = Object.create(Sequelize.prototype); |
51 | 1 | Sequelize.apply(sequelize, sequelizeArgs); |
52 | 1 | instances[instanceId] = sequelize; |
53 | | } |
54 | | |
55 | 5 | self.sequelize = instances[instanceId]; |
56 | | |
57 | 5 | self._buildModels(); |
58 | | |
59 | 5 | self.ready = true; |
60 | | }; |
61 | | |
62 | 1 | SqlStore.prototype.populate = function(callback) { |
63 | 5 | var self = this; |
64 | | |
65 | 5 | var tasks = [ |
66 | | function(cb) { |
67 | 5 | self.baseModel.sync().asCallback(cb); |
68 | | }, |
69 | | function(cb) { |
70 | 5 | async.each(self.relationArray, function(model, ecb) { |
71 | 7 | model.sync().asCallback(ecb); |
72 | | }, cb); |
73 | | }, |
74 | | function(cb) { |
75 | 2 | async.each(self.resourceConfig.examples, function(exampleJson, ecb) { |
76 | 8 | self.create({ request: { type: self.resourceConfig.resource } }, exampleJson, ecb); |
77 | | }, cb); |
78 | | } |
79 | | ]; |
80 | | |
81 | 5 | async.series(tasks, callback); |
82 | | }; |
83 | | |
84 | 1 | SqlStore.prototype._buildModels = function() { |
85 | 5 | var self = this; |
86 | | |
87 | 5 | var localAttributes = Object.keys(self.resourceConfig.attributes).filter(function(attributeName) { |
88 | 26 | var settings = self.resourceConfig.attributes[attributeName]._settings; |
89 | 39 | if (!settings) return true; |
90 | 13 | return !(settings.__one || settings.__many); |
91 | | }); |
92 | 5 | localAttributes = _.pick(self.resourceConfig.attributes, localAttributes); |
93 | 5 | var relations = Object.keys(self.resourceConfig.attributes).filter(function(attributeName) { |
94 | 26 | var settings = self.resourceConfig.attributes[attributeName]._settings; |
95 | 39 | if (!settings) return false; |
96 | 13 | return (settings.__one || settings.__many) && !settings.__as; |
97 | | }); |
98 | 5 | relations = _.pick(self.resourceConfig.attributes, relations); |
99 | | |
100 | 5 | var modelAttributes = self._joiSchemaToSequelizeModel(localAttributes); |
101 | 5 | self.baseModel = self.sequelize.define(self.resourceConfig.resource, modelAttributes, { timestamps: false }); |
102 | | |
103 | 5 | self.relations = { }; |
104 | 5 | self.relationArray = [ ]; |
105 | 5 | Object.keys(relations).forEach(function(relationName) { |
106 | 7 | var relation = relations[relationName]; |
107 | 7 | var otherModel = self._defineRelationModel(relationName, relation._settings.__many); |
108 | 7 | self.relations[relationName] = otherModel; |
109 | 7 | self.relationArray.push(otherModel); |
110 | | }); |
111 | | }; |
112 | | |
113 | 1 | SqlStore.prototype._joiSchemaToSequelizeModel = function(joiSchema) { |
114 | 5 | var model = { |
115 | | id: { type: new Sequelize.STRING(38), primaryKey: true }, |
116 | | type: Sequelize.STRING, |
117 | | meta: { |
118 | | type: Sequelize.STRING, |
119 | | get: function() { |
120 | 0 | var data = this.getDataValue("meta"); |
121 | 0 | if (!data) return undefined; |
122 | 0 | return JSON.parse(data); |
123 | | }, |
124 | | set: function(val) { |
125 | 0 | return this.setDataValue("meta", JSON.stringify(val)); |
126 | | } |
127 | | } |
128 | | }; |
129 | | |
130 | 5 | Object.keys(joiSchema).forEach(function(attributeName) { |
131 | 13 | var attribute = joiSchema[attributeName]; |
132 | 22 | if (attribute._type === "string") model[attributeName] = { type: Sequelize.STRING, allowNull: true }; |
133 | 15 | if (attribute._type === "date") model[attributeName] = { type: Sequelize.STRING, allowNull: true }; |
134 | 15 | if (attribute._type === "number") model[attributeName] = { type: Sequelize.INTEGER, allowNull: true }; |
135 | | }); |
136 | | |
137 | 5 | return model; |
138 | | }; |
139 | | |
140 | 1 | SqlStore.prototype._defineRelationModel = function(relationName, many) { |
141 | 7 | var self = this; |
142 | | |
143 | 7 | var modelName = self.resourceConfig.resource + "-" + relationName; |
144 | 7 | 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() { |
161 | 0 | var data = this.getDataValue("meta"); |
162 | 0 | if (!data) return undefined; |
163 | 0 | return JSON.parse(data); |
164 | | }, |
165 | | set: function(val) { |
166 | 0 | return this.setDataValue("meta", JSON.stringify(val)); |
167 | | } |
168 | | } |
169 | | }; |
170 | | |
171 | 7 | var relatedModel = self.sequelize.define(modelName, modelProperties, { |
172 | | timestamps: false, |
173 | | indexes: [ { fields: [ "id" ] } ], |
174 | | freezeTableName: true |
175 | | }); |
176 | | |
177 | 7 | if (many) { |
178 | 3 | self.baseModel.hasMany(relatedModel, { onDelete: "CASCADE", foreignKey: self.resourceConfig.resource + "Id" }); |
179 | | } else { |
180 | 4 | self.baseModel.hasOne(relatedModel, { onDelete: "CASCADE", foreignKey: self.resourceConfig.resource + "Id" }); |
181 | | } |
182 | | |
183 | 7 | return relatedModel; |
184 | | }; |
185 | | |
186 | 1 | SqlStore.prototype._fixObject = function(json) { |
187 | 0 | var self = this; |
188 | 0 | var resourceId = self.resourceConfig.resource + "Id"; |
189 | | |
190 | 0 | Object.keys(json).forEach(function(attribute) { |
191 | 0 | if (attribute.indexOf(self.resourceConfig.resource + "-") !== 0) return; |
192 | | |
193 | 0 | var fixedName = attribute.split(self.resourceConfig.resource + "-").pop(); |
194 | 0 | json[fixedName] = json[attribute]; |
195 | | |
196 | 0 | var val = json[attribute]; |
197 | 0 | delete json[attribute]; |
198 | 0 | if (!val) return; |
199 | | |
200 | 0 | if (!(val instanceof Array)) val = [ val ]; |
201 | 0 | val.forEach(function(j) { |
202 | 0 | if (j.uid) delete j.uid; |
203 | 0 | if (j[resourceId]) delete j[resourceId]; |
204 | | }); |
205 | | }); |
206 | | |
207 | 0 | return json; |
208 | | }; |
209 | | |
210 | 1 | SqlStore.prototype._errorHandler = function(e, callback) { |
211 | | // console.log(e, e.stack); |
212 | 1 | 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 | | |
220 | 1 | SqlStore.prototype._filterInclude = function(relationships) { |
221 | 0 | var self = this; |
222 | 0 | relationships = _.pick(relationships, Object.keys(self.relations)); |
223 | | |
224 | 0 | var includeBlock = Object.keys(self.relations).map(function(relationName) { |
225 | 0 | var model = self.relations[relationName]; |
226 | 0 | var matchingValue = relationships[relationName]; |
227 | 0 | if (!matchingValue) return model; |
228 | | |
229 | 0 | if (matchingValue instanceof Array) { |
230 | 0 | matchingValue = matchingValue.filter(function(i) { |
231 | 0 | return !(i instanceof Object); |
232 | | }); |
233 | 0 | } else if (matchingValue instanceof Object) { |
234 | 0 | return model; |
235 | | } |
236 | | |
237 | 0 | return { |
238 | | model: model, |
239 | | where: { id: matchingValue } |
240 | | }; |
241 | | }); |
242 | | |
243 | 0 | return includeBlock; |
244 | | }; |
245 | | |
246 | 1 | SqlStore.prototype._generateSearchBlock = function(request) { |
247 | 0 | var self = this; |
248 | | |
249 | 0 | var attributesToFilter = _.omit(request.params.filter, Object.keys(self.relations)); |
250 | 0 | var searchBlock = self._recurseOverSearchBlock(attributesToFilter); |
251 | 0 | return searchBlock; |
252 | | }; |
253 | | |
254 | 1 | SqlStore.prototype._recurseOverSearchBlock = function(obj) { |
255 | 0 | var self = this; |
256 | 0 | if (!obj) return { }; |
257 | 0 | var searchBlock = { }; |
258 | | |
259 | 0 | Object.keys(obj).forEach(function(attributeName) { |
260 | 0 | var textToMatch = obj[attributeName]; |
261 | 0 | if (textToMatch instanceof Array) { |
262 | 0 | searchBlock[attributeName] = { $or: textToMatch.map(function(i) { |
263 | 0 | return self._recurseOverSearchBlock({ i: i }).i; |
264 | | }) }; |
265 | 0 | } else if (textToMatch instanceof Object) { |
266 | | // Do nothing, its a nested filter |
267 | 0 | } else if (textToMatch[0] === ">") { |
268 | 0 | searchBlock[attributeName] = { $gt: textToMatch.substring(1) }; |
269 | 0 | } else if (textToMatch[0] === "<") { |
270 | 0 | searchBlock[attributeName] = { $lt: textToMatch.substring(1) }; |
271 | 0 | } else if (textToMatch[0] === "~") { |
272 | 0 | searchBlock[attributeName] = { $like: textToMatch.substring(1) }; |
273 | 0 | } else if (textToMatch[0] === ":") { |
274 | 0 | searchBlock[attributeName] = { $like: "%" + textToMatch.substring(1) + "%" }; |
275 | | } else { |
276 | 0 | searchBlock[attributeName] = textToMatch; |
277 | | } |
278 | | }); |
279 | | |
280 | 0 | return searchBlock; |
281 | | }; |
282 | | |
283 | 1 | SqlStore.prototype._dealWithTransaction = function(done, callback) { |
284 | 8 | var self = this; |
285 | 8 | var transactionOptions = { |
286 | | isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED, |
287 | | autocommit: false |
288 | | }; |
289 | 8 | self.sequelize.transaction(transactionOptions).asCallback(function(err1, transaction) { |
290 | 4 | if (err1) return done(err1); |
291 | | |
292 | 4 | var t = { transaction: transaction }; |
293 | 4 | var commit = function() { |
294 | 0 | var args = arguments; |
295 | 0 | transaction.commit().asCallback(function(err2) { |
296 | 0 | if (err2) return done(err2); |
297 | 0 | return done.apply(null, Array.prototype.slice.call(args)); |
298 | | }); |
299 | | }; |
300 | 4 | var rollback = function(e) { |
301 | 2 | debug("Err", e); |
302 | 2 | var a = function() { |
303 | 2 | if (e instanceof Error) return self._errorHandler(e, done); |
304 | 0 | return done(e); |
305 | | }; |
306 | 2 | transaction.rollback().then(a, a); |
307 | | }; |
308 | 4 | var finishTransaction = function(err) { |
309 | 4 | if (err) return rollback(err); |
310 | 0 | return commit.apply(null, Array.prototype.slice.call(arguments)); |
311 | | }; |
312 | | |
313 | 4 | return callback(t, finishTransaction); |
314 | | }); |
315 | | }; |
316 | | |
317 | 1 | SqlStore.prototype._clearAndSetRelationTables = function(theResource, partialResource, t, callback) { |
318 | 0 | var self = this; |
319 | | |
320 | 0 | var tasks = { }; |
321 | 0 | Object.keys(self.relations).forEach(function(relationName) { |
322 | 0 | var prop = partialResource[relationName]; |
323 | 0 | if (!partialResource.hasOwnProperty(relationName)) return; |
324 | 0 | var relationModel = self.relations[relationName]; |
325 | | |
326 | 0 | var keyName = self.resourceConfig.resource + "-" + relationName; |
327 | 0 | var uc = keyName[0].toUpperCase() + keyName.slice(1, keyName.length); |
328 | | |
329 | 0 | tasks[relationName] = function(taskCallback) { |
330 | 0 | if (prop instanceof Array) { |
331 | 0 | (theResource[keyName] || []).map(function(deadRow) { |
332 | 0 | deadRow.destroy(t); |
333 | | }); |
334 | | |
335 | 0 | async.map(prop, function(item, acallback) { |
336 | 0 | relationModel.create(item, t).asCallback(function(err4, newRelationModel) { |
337 | 0 | if (err4) return acallback(err4); |
338 | | |
339 | 0 | theResource["add" + uc](newRelationModel, t).asCallback(acallback); |
340 | | }); |
341 | | }, taskCallback); |
342 | | } else { |
343 | 0 | if (theResource[keyName]) { |
344 | 0 | theResource[keyName].destroy(t); |
345 | | } |
346 | 0 | if (!prop) { |
347 | 0 | theResource["set" + uc](null, t).asCallback(taskCallback); |
348 | | } else { |
349 | 0 | relationModel.create(prop, t).asCallback(function(err3, newRelationModel) { |
350 | 0 | if (err3) return taskCallback(err3); |
351 | | |
352 | 0 | theResource["set" + uc](newRelationModel, t).asCallback(taskCallback); |
353 | | }); |
354 | | } |
355 | | } |
356 | | }; |
357 | | }); |
358 | | |
359 | 0 | async.parallel(tasks, callback); |
360 | | }; |
361 | | |
362 | 1 | SqlStore.prototype._generateSearchOrdering = function(request) { |
363 | 0 | if (!request.params.sort) return undefined; |
364 | | |
365 | 0 | var attribute = request.params.sort; |
366 | 0 | var order = "ASC"; |
367 | 0 | attribute = String(attribute); |
368 | 0 | if (attribute[0] === "-") { |
369 | 0 | order = "DESC"; |
370 | 0 | attribute = attribute.substring(1, attribute.length); |
371 | | } |
372 | 0 | return [ [ attribute, order ] ]; |
373 | | }; |
374 | | |
375 | 1 | SqlStore.prototype._generateSearchPagination = function(request) { |
376 | 0 | if (!request.params.page) { |
377 | 0 | return { }; |
378 | | } |
379 | | |
380 | 0 | 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 | | */ |
389 | 1 | SqlStore.prototype.search = function(request, callback) { |
390 | 0 | var self = this; |
391 | | |
392 | 0 | var base = { |
393 | | where: self._generateSearchBlock(request), |
394 | | include: self._filterInclude(request.params.filter), |
395 | | order: self._generateSearchOrdering(request) |
396 | | }; |
397 | 0 | var query = _.assign(base, self._generateSearchPagination(request)); |
398 | | |
399 | 0 | self.baseModel.findAndCount(query).asCallback(function(err, result) { |
400 | 0 | debug(query, err, JSON.stringify(result)); |
401 | 0 | if (err) return self._errorHandler(err, callback); |
402 | | |
403 | 0 | var records = result.rows.map(function(i){ return self._fixObject(i.toJSON()); }); |
404 | 0 | debug("Produced", JSON.stringify(records)); |
405 | 0 | return callback(null, records, result.count); |
406 | | }); |
407 | | }; |
408 | | |
409 | | /** |
410 | | Find a specific resource, given a resource type and and id. |
411 | | */ |
412 | 1 | SqlStore.prototype.find = function(request, callback) { |
413 | 0 | var self = this; |
414 | | |
415 | 0 | self.baseModel.findOne({ |
416 | | where: { id: request.params.id }, |
417 | | include: self.relationArray |
418 | | }).asCallback(function(err, theResource) { |
419 | 0 | if (err) return self._errorHandler(err, callback); |
420 | | |
421 | | // If the resource doesn't exist, error |
422 | 0 | if (!theResource) { |
423 | 0 | 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 | | |
431 | 0 | theResource = self._fixObject(theResource.toJSON()); |
432 | 0 | debug("Produced", JSON.stringify(theResource)); |
433 | 0 | return callback(null, theResource); |
434 | | }); |
435 | | }; |
436 | | |
437 | | /** |
438 | | Create (store) a new resource give a resource type and an object. |
439 | | */ |
440 | 1 | SqlStore.prototype.create = function(request, newResource, finishedCallback) { |
441 | 8 | var self = this; |
442 | | |
443 | 8 | self._dealWithTransaction(finishedCallback, function(t, finishTransaction) { |
444 | | |
445 | 4 | self.baseModel.create(newResource, t).asCallback(function(err2, theResource) { |
446 | 4 | if (err2) return finishTransaction(err2); |
447 | | |
448 | 0 | self._clearAndSetRelationTables(theResource, newResource, t, function(err){ |
449 | 0 | if (err) return finishTransaction(err); |
450 | | |
451 | 0 | return finishTransaction(null, newResource); |
452 | | }); |
453 | | }); |
454 | | }); |
455 | | }; |
456 | | |
457 | | /** |
458 | | Delete a resource, given a resource type and and id. |
459 | | */ |
460 | 1 | SqlStore.prototype.delete = function(request, callback) { |
461 | 0 | var self = this; |
462 | | |
463 | 0 | self.baseModel.findAll({ |
464 | | where: { id: request.params.id }, |
465 | | include: self.relationArray |
466 | | }).asCallback(function(findErr, results) { |
467 | 0 | if (findErr) return self._errorHandler(findErr, callback); |
468 | | |
469 | 0 | var theResource = results[0]; |
470 | | |
471 | | // If the resource doesn't exist, error |
472 | 0 | if (!theResource) { |
473 | 0 | 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 | | |
481 | 0 | theResource.destroy().asCallback(function(deleteErr) { |
482 | 0 | 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 | | */ |
491 | 1 | SqlStore.prototype.update = function(request, partialResource, finishedCallback) { |
492 | 0 | var self = this; |
493 | | |
494 | 0 | self._dealWithTransaction(finishedCallback, function(t, finishTransaction) { |
495 | | |
496 | 0 | self.baseModel.findOne({ |
497 | | where: { id: request.params.id }, |
498 | | include: self.relationArray, |
499 | | transaction: t.transaction |
500 | | }).asCallback(function(err2, theResource) { |
501 | 0 | if (err2) return finishTransaction(err2); |
502 | | |
503 | | // If the resource doesn't exist, error |
504 | 0 | if (!theResource) { |
505 | 0 | 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 | | |
513 | 0 | self._clearAndSetRelationTables(theResource, partialResource, t, function(err){ |
514 | 0 | if (err) return finishTransaction(err); |
515 | | |
516 | 0 | theResource.update(partialResource, t).asCallback(function(err3) { |
517 | 0 | if (err) return finishTransaction(err3); |
518 | 0 | return finishTransaction(null, partialResource); |
519 | | }); |
520 | | }); |
521 | | }); |
522 | | }); |
523 | | }; |
524 | | |