UNPKG

19.4 kBJavaScriptView Raw
1'use strict';
2
3const _ = require('lodash');
4const deprecate = require('depd')('camo');
5const DB = require('./clients').getClient;
6const isSupportedType = require('./validate').isSupportedType;
7const isValidType = require('./validate').isValidType;
8const isEmptyValue = require('./validate').isEmptyValue;
9const isInChoices = require('./validate').isInChoices;
10const isArray = require('./validate').isArray;
11const isDocument = require('./validate').isDocument;
12const isEmbeddedDocument = require('./validate').isEmbeddedDocument;
13const isString = require('./validate').isString;
14const isNumber = require('./validate').isNumber;
15const isDate = require('./validate').isDate;
16const ValidationError = require('./errors').ValidationError;
17
18const normalizeType = function(property) {
19 // TODO: Only copy over stuff we support
20
21 let typeDeclaration = {};
22 if (property.type) {
23 typeDeclaration = property;
24 } else if (isSupportedType(property)) {
25 typeDeclaration.type = property;
26 } else {
27 throw new Error('Unsupported type or bad variable. ' +
28 'Remember, non-persisted objects must start with an underscore (_). Got:', property);
29 }
30
31 return typeDeclaration;
32};
33
34class BaseDocument {
35 constructor() {
36 this._schema = { // Defines document structure/properties
37 _id: { type: DB().nativeIdType() }, // Native ID to backend database
38 };
39
40 this._id = null;
41 }
42
43 // TODO: Is there a way to tell if a class is
44 // a subclass of something? Until I find out
45 // how, we'll be lazy use this.
46 static documentClass() {
47 throw new TypeError('You must override documentClass (static).');
48 }
49
50 documentClass() {
51 throw new TypeError('You must override documentClass.');
52 }
53
54 collectionName() {
55 // DEPRECATED
56 // Getting ready to remove this functionality
57 if (this._meta) {
58 return this._meta.collection;
59 }
60
61 return this.constructor.collectionName();
62 }
63
64 /**
65 * Get current collection name
66 *
67 * @returns {String}
68 */
69 static collectionName() {
70 // DEPRECATED
71 // Getting ready to remove this functionality
72 let instance = new this();
73 if (instance._meta) {
74 return instance._meta.collection;
75 }
76
77 return this.name.toLowerCase() + 's';
78 }
79
80 get id() {
81 deprecate('Document.id - use Document._id instead');
82 return this._id;
83 }
84
85 set id(id) {
86 deprecate('Document.id - use Document._id instead');
87 this._id = id;
88 }
89
90 /**
91 * set schema
92 * @param {Object} extension
93 */
94 schema(extension) {
95 const that = this;
96
97 if (!extension) return;
98 _.keys(extension).forEach(function(k) {
99 that[k] = extension[k];
100 });
101 }
102
103 /*
104 * Pre/post Hooks
105 *
106 * To add a hook, the extending class just needs
107 * to override the appropriate hook method below.
108 */
109
110 preValidate() { }
111
112 postValidate() { }
113
114 preSave() { }
115
116 postSave() { }
117
118 preDelete() { }
119
120 postDelete() { }
121
122 /**
123 * Generate this._schema from fields
124 *
125 * TODO : EMBEDDED
126 * Need to share this with embedded
127 */
128 generateSchema() {
129 const that = this;
130
131 _.keys(this).forEach(function(k) {
132 // Ignore private variables
133 if (_.startsWith(k, '_')) {
134 return;
135 }
136
137 // Normalize the type format
138 that._schema[k] = normalizeType(that[k]);
139
140 // Assign a default if needed
141 if (isArray(that._schema[k].type)) {
142 that[k] = that.getDefault(k) || [];
143 } else {
144 that[k] = that.getDefault(k);
145 }
146 });
147 }
148
149 /**
150 * Validate current document
151 *
152 * The method throw errors if document has invalid value
153 *
154 * TODO: This is not the right approach. The method needs to collect all
155 * errors in array and return them.
156 */
157 validate() {
158 const that = this;
159
160 _.keys(that._schema).forEach(function(key) {
161 let value = that[key];
162
163 // TODO: This should probably be in Document, not BaseDocument
164 if (value !== null && value !== undefined) {
165 if (isEmbeddedDocument(value)) {
166 value.validate();
167 return;
168 } else if (isArray(value) && value.length > 0 && isEmbeddedDocument(value[0])) {
169 value.forEach(function(v) {
170 if (v.validate) {
171 v.validate();
172 }
173 });
174 return;
175 }
176 }
177
178 if (!isValidType(value, that._schema[key].type)) {
179 // TODO: Formatting should probably be done somewhere else
180 let typeName = null;
181 let valueName = null;
182 if (Array.isArray(that._schema[key].type) && that._schema[key].type.length > 0) {
183 typeName = '[' + that._schema[key].type[0].name + ']';
184 } else if (Array.isArray(that._schema[key].type) && that._schema[key].type.length === 0) {
185 typeName = '[]';
186 } else {
187 typeName = that._schema[key].type.name;
188 }
189
190 if (Array.isArray(value)) {
191 // TODO: Not descriptive enough! Strings can look like numbers
192 valueName = '[' + value.toString() + ']';
193 } else {
194 valueName = typeof(value);
195 }
196 throw new ValidationError('Value assigned to ' + that.collectionName() + '.' + key +
197 ' should be ' + typeName + ', got ' + valueName);
198 }
199
200 if (that._schema[key].required && isEmptyValue(value)) {
201 throw new ValidationError('Key ' + that.collectionName() + '.' + key +
202 ' is required' + ', but got ' + value);
203 }
204
205 if (that._schema[key].match && isString(value) && !that._schema[key].match.test(value)) {
206 throw new ValidationError('Value assigned to ' + that.collectionName() + '.' + key +
207 ' does not match the regex/string ' + that._schema[key].match.toString() + '. Value was ' + value);
208 }
209
210 if (!isInChoices(that._schema[key].choices, value)) {
211 throw new ValidationError('Value assigned to ' + that.collectionName() + '.' + key +
212 ' should be in choices [' + that._schema[key].choices.join(', ') + '], got ' + value);
213 }
214
215 if (isNumber(that._schema[key].min) && value < that._schema[key].min) {
216 throw new ValidationError('Value assigned to ' + that.collectionName() + '.' + key +
217 ' is less than min, ' + that._schema[key].min + ', got ' + value);
218 }
219
220 if (isNumber(that._schema[key].max) && value > that._schema[key].max) {
221 throw new ValidationError('Value assigned to ' + that.collectionName() + '.' + key +
222 ' is less than max, ' + that._schema[key].max + ', got ' + value);
223 }
224
225 if (typeof(that._schema[key].validate) === 'function' && !that._schema[key].validate(value)) {
226 throw new ValidationError('Value assigned to ' + that.collectionName() + '.' + key +
227 ' failed custom validator. Value was ' + value);
228 }
229 });
230 }
231
232 /*
233 * Right now this only canonicalizes dates (integer timestamps
234 * get converted to Date objects), but maybe we should do the
235 * same for strings (UTF, Unicode, ASCII, etc)?
236 */
237 canonicalize() {
238 const that = this;
239
240 _.keys(that._schema).forEach(function(key) {
241 let value = that[key];
242
243 if (that._schema[key].type === Date && isDate(value)) {
244 that[key] = new Date(value);
245 } else if (value !== null && value !== undefined &&
246 value.documentClass && value.documentClass() === 'embedded') {
247 // TODO: This should probably be in Document, not BaseDocument
248 value.canonicalize();
249 return;
250 }
251 });
252 }
253
254 /**
255 * Create new document from data
256 *
257 * @param {Object} data
258 * @returns {Document}
259 */
260 static create(data) {
261 this.createIndexes();
262
263 if (typeof(data) !== 'undefined') {
264 return this._fromData(data);
265 }
266
267 return this._instantiate();
268 }
269
270 static createIndexes() { }
271
272 /**
273 * Create new document from self
274 *
275 * @returns {BaseDocument}
276 * @private
277 */
278 static _instantiate() {
279 let instance = new this();
280 instance.generateSchema();
281 return instance;
282 }
283
284 // TODO: Should probably move some of this to
285 // Embedded and Document classes since Base shouldn't
286 // need to know about child classes
287 static _fromData(datas) {
288 const that = this;
289
290 if (!isArray(datas)) {
291 datas = [datas];
292 }
293
294 let documents = [];
295 let embeddedPromises = [];
296 datas.forEach(function(d) {
297 let instance = that._instantiate();
298 _.keys(d).forEach(function(key) {
299 let value = null;
300 if (d[key] === null) {
301 value = instance.getDefault(key);
302 } else {
303 value = d[key];
304 }
305
306 // If its not in the schema, we don't care about it... right?
307 if (key in instance._schema) {
308
309 let type = instance._schema[key].type;
310
311 if (type.documentClass && type.documentClass() === 'embedded') {
312 // Initialize EmbeddedDocument
313 instance[key] = type._fromData(value);
314 } else if (isArray(type) && type.length > 0 &&
315 type[0].documentClass && type[0].documentClass() === 'embedded') {
316 // Initialize array of EmbeddedDocuments
317 instance[key] = [];
318 value.forEach(function(v, i) {
319 instance[key][i] = type[0]._fromData(v);
320 });
321 } else {
322 // Initialize primitive or array of primitives
323 instance[key] = value;
324 }
325 } else if (key in instance) {
326 // Handles virtual setters
327 instance[key] = value;
328 }
329 });
330
331 documents.push(instance);
332 });
333
334 if (documents.length === 1) {
335 return documents[0];
336 }
337 return documents;
338 }
339
340 populate() {
341 return BaseDocument.populate(this);
342 }
343
344 /**
345 * Populates document references
346 *
347 * TODO : EMBEDDED
348 * @param {Array|Document} docs
349 * @param {Array} fields
350 * @returns {Promise}
351 */
352 static populate(docs, fields) {
353 if (!docs) return Promise.all([]);
354
355 let documents = null;
356
357 if (!isArray(docs)) {
358 documents = [docs];
359 } else if (docs.length < 1) {
360 return Promise.all(docs);
361 } else {
362 documents = docs;
363 }
364
365 // Load all 1-level-deep references
366 // First, find all unique keys needed to be loaded...
367 let keys = [];
368
369 // TODO: Bad assumption: Not all documents in the database will have the same schema...
370 // Hmm, if this is true, thats an error on the user. Right?
371 let anInstance = documents[0];
372
373 _.keys(anInstance._schema).forEach(function(key) {
374 // Only populate specified fields
375 if (isArray(fields) && fields.indexOf(key) < 0) {
376 return;
377 }
378
379 // Handle array of references (ex: { type: [MyObject] })
380 if (isArray(anInstance._schema[key].type) &&
381 anInstance._schema[key].type.length > 0 &&
382 isDocument(anInstance._schema[key].type[0])) {
383 keys.push(key);
384 }
385 // Handle anInstance[key] being a string id, a native id, or a Document instance
386 else if ((isString(anInstance[key]) || DB().isNativeId(anInstance[key])) &&
387 isDocument(anInstance._schema[key].type)) {
388 keys.push(key);
389 }
390 });
391
392 // ...then get all ids for each type of reference to be loaded...
393 // ids = {
394 // houses: {
395 // 'abc123': ['ak23lj', '2kajlc', 'ckajl32'],
396 // 'l2jo99': ['28dsa0']
397 // },
398 // friends: {
399 // '1039da': ['lj0adf', 'k2jha']
400 // }
401 //}
402 let ids = {};
403 keys.forEach(function(k) {
404 ids[k] = {};
405 documents.forEach(function(d) {
406 ids[k][DB().toCanonicalId(d._id)] = [].concat(d[k]); // Handles values and arrays
407
408 // Also, initialize document member arrays
409 // to assign to later if needed
410 if (isArray(d[k])) {
411 d[k] = [];
412 }
413 });
414 });
415
416 // TODO: Is this really the most efficient
417 // way to do this? Maybe make a master list
418 // of all objects that need to be loaded (separated
419 // by type), load those, and then search through
420 // ids to see where dereferenced objects should
421 // go?
422
423 // ...then for each array of ids, load them all...
424 let loadPromises = [];
425 _.keys(ids).forEach(function(key) {
426 let keyIds = [];
427 _.keys(ids[key]).forEach(function(k) {
428 // Before adding to list, we convert id to the
429 // backend database's native ID format.
430 keyIds = keyIds.concat(ids[key][k]);
431 });
432
433 // Only want to load each reference once
434 keyIds = _.uniq(keyIds);
435
436 // Handle array of references (like [MyObject])
437 let type = null;
438 if (isArray(anInstance._schema[key].type)) {
439 type = anInstance._schema[key].type[0];
440 } else {
441 type = anInstance._schema[key].type;
442 }
443
444 // Bulk load dereferences
445 let p = type.find({ '_id': { $in: keyIds } }, { populate: false })
446 .then(function(dereferences) {
447 // Assign each dereferenced object to parent
448
449 _.keys(ids[key]).forEach(function(k) {
450 // TODO: Replace with documents.find when able
451 // Find the document to assign the derefs to
452 let doc;
453 documents.forEach(function(d) {
454 if (DB().toCanonicalId(d._id) === k) doc = d;
455 });
456
457 // For all ids to be dereferenced, find the
458 // deref and assign or push it
459 ids[key][k].forEach(function(id) {
460 // TODO: Replace with dereferences.find when able
461 // Find the right dereference
462 let deref;
463 dereferences.forEach(function(d) {
464 if (DB().toCanonicalId(d._id) === DB().toCanonicalId(id)) deref = d;
465 });
466
467 if (isArray(anInstance._schema[key].type)) {
468 doc[key].push(deref);
469 } else {
470 doc[key] = deref;
471 }
472 });
473 });
474 });
475
476 loadPromises.push(p);
477 });
478
479 // ...and finally execute all promises and return our
480 // fully loaded documents.
481 return Promise.all(loadPromises).then(function() {
482 return docs;
483 });
484 }
485
486 /**
487 * Get default value
488 *
489 * @param {String} schemaProp Key of current schema
490 * @returns {*}
491 */
492 getDefault(schemaProp) {
493 if (schemaProp in this._schema && 'default' in this._schema[schemaProp]) {
494 let def = this._schema[schemaProp].default;
495 let defVal = typeof(def) === 'function' ? def() : def;
496 this[schemaProp] = defVal; // TODO: Wait... should we be assigning it here?
497 return defVal;
498 } else if (schemaProp === '_id') {
499 return null;
500 }
501
502 return undefined;
503 }
504
505 /**
506 * For JSON.Stringify
507 *
508 * @returns {*}
509 */
510 toJSON() {
511 let values = this._toData({_id: true});
512 let schema = this._schema;
513 for (let key in schema) {
514 if (schema.hasOwnProperty(key)) {
515 if (schema[key].private){
516 delete values[key];
517 } else if (values[key] && values[key].toJSON) {
518 values[key] = values[key].toJSON();
519 } else if (isArray(values[key])) {
520 let newArray = [];
521 values[key].forEach(function(i) {
522 if (i && i.toJSON) {
523 newArray.push(i.toJSON());
524 } else {
525 newArray.push(i);
526 }
527 });
528 values[key] = newArray;
529 }
530 }
531 }
532
533 return values;
534 }
535
536 /**
537 *
538 * @param keep
539 * @returns {{}}
540 * @private
541 */
542 _toData(keep) {
543 const that = this;
544
545 if (keep === undefined || keep === null) {
546 keep = {};
547 } else if (keep._id === undefined) {
548 keep._id = true;
549 }
550
551 let values = {};
552 _.keys(this).forEach(function(k) {
553 if (_.startsWith(k, '_')) {
554 if (k !== '_id' || !keep._id) {
555 return;
556 } else {
557 values[k] = that[k];
558 }
559 } else if (isEmbeddedDocument(that[k])) {
560 values[k] = that[k]._toData();
561 } else if (isArray(that[k]) && that[k].length > 0 && isEmbeddedDocument(that[k][0])) {
562 values[k] = [];
563 that[k].forEach(function(v) {
564 values[k].push(v._toData());
565 });
566 } else {
567 values[k] = that[k];
568 }
569 });
570
571 return values;
572 }
573
574 _getEmbeddeds() {
575 const that = this;
576
577 let embeddeds = [];
578 _.keys(this._schema).forEach(function(v) {
579 if (isEmbeddedDocument(that._schema[v].type) ||
580 (isArray(that._schema[v].type) && isEmbeddedDocument(that._schema[v].type[0]))) {
581 embeddeds = embeddeds.concat(that[v]);
582 }
583 });
584 return embeddeds;
585 }
586
587 _getHookPromises(hookName) {
588 let embeddeds = this._getEmbeddeds();
589
590 let hookPromises = [];
591 hookPromises = hookPromises.concat(_.invoke(embeddeds, hookName));
592 hookPromises.push(this[hookName]());
593 return hookPromises;
594 }
595}
596
597module.exports = BaseDocument;