UNPKG

21.2 kBJavaScriptView Raw
1'use strict';
2
3const { EventEmitter } = require('events');
4const cloneDeep = require('rfdc')();
5const Promise = require('bluebird');
6const { parseArgs, getProp, setGetter, shuffle } = require('./util');
7const Document = require('./document');
8const Query = require('./query');
9const Schema = require('./schema');
10const Types = require('./types');
11const WarehouseError = require('./error');
12const PopulationError = require('./error/population');
13const Mutex = require('./mutex');
14
15class Model extends EventEmitter {
16
17 /**
18 * Model constructor.
19 *
20 * @param {string} name Model name
21 * @param {Schema|object} [schema] Schema
22 */
23 constructor(name, schema_) {
24 super();
25
26 let schema;
27
28 // Define schema
29 if (schema_ instanceof Schema) {
30 schema = schema_;
31 } else if (typeof schema_ === 'object') {
32 schema = new Schema(schema_);
33 } else {
34 schema = new Schema();
35 }
36
37 // Set `_id` path for schema
38 if (!schema.path('_id')) {
39 schema.path('_id', {type: Types.CUID, required: true});
40 }
41
42 this.name = name;
43 this.data = {};
44 this._mutex = new Mutex();
45 this.schema = schema;
46 this.length = 0;
47
48 class _Document extends Document {
49 constructor(data) {
50 super(data);
51
52 // Apply getters
53 schema._applyGetters(this);
54 }
55 }
56
57 this.Document = _Document;
58
59 _Document.prototype._model = this;
60 _Document.prototype._schema = schema;
61
62 class _Query extends Query {}
63
64 this.Query = _Query;
65
66 _Query.prototype._model = this;
67 _Query.prototype._schema = schema;
68
69 // Apply static methods
70 Object.assign(this, schema.statics);
71
72 // Apply instance methods
73 Object.assign(_Document.prototype, schema.methods);
74 }
75
76 /**
77 * Creates a new document.
78 *
79 * @param {object} data
80 * @return {Document}
81 */
82 new(data) {
83 return new this.Document(data);
84 }
85
86 /**
87 * Finds a document by its identifier.
88 *
89 * @param {*} id
90 * @param {object} options
91 * @param {boolean} [options.lean=false] Returns a plain JavaScript object
92 * @return {Document|object}
93 */
94 findById(id, options_) {
95 const raw = this.data[id];
96 if (!raw) return;
97
98 const options = Object.assign({
99 lean: false
100 }, options_);
101
102 const data = cloneDeep(raw);
103 return options.lean ? data : this.new(data);
104 }
105
106 /**
107 * Checks if the model contains a document with the specified id.
108 *
109 * @param {*} id
110 * @return {boolean}
111 */
112 has(id) {
113 return Boolean(this.data[id]);
114 }
115
116 /**
117 * Acquires write lock.
118 *
119 * @param {*} id
120 * @return {Promise}
121 * @private
122 */
123 _acquireWriteLock(id) {
124 const mutex = this._mutex;
125
126 return new Promise((resolve, reject) => {
127 mutex.lock(resolve);
128 }).disposer(() => {
129 mutex.unlock();
130 });
131 }
132
133 /**
134 * Inserts a document.
135 *
136 * @param {Document|object} data
137 * @return {Promise}
138 * @private
139 */
140 _insertOne(data_) {
141 const schema = this.schema;
142
143 // Apply getters
144 const data = data_ instanceof this.Document ? data_ : this.new(data_);
145 const id = data._id;
146
147 // Check ID
148 if (!id) {
149 return Promise.reject(new WarehouseError('ID is not defined', WarehouseError.ID_UNDEFINED));
150 }
151
152 if (this.has(id)) {
153 return Promise.reject(new WarehouseError('ID `' + id + '` has been used', WarehouseError.ID_EXIST));
154 }
155
156 // Apply setters
157 const result = data.toObject();
158 schema._applySetters(result);
159
160 // Pre-hooks
161 return execHooks(schema, 'pre', 'save', data).then(data => {
162 // Insert data
163 this.data[id] = result;
164 this.length++;
165
166 this.emit('insert', data);
167 return execHooks(schema, 'post', 'save', data);
168 });
169 }
170
171 /**
172 * Inserts a document.
173 *
174 * @param {object} data
175 * @param {function} [callback]
176 * @return {Promise}
177 */
178 insertOne(data, callback) {
179 return Promise.using(this._acquireWriteLock(), () => this._insertOne(data)).asCallback(callback);
180 }
181
182 /**
183 * Inserts documents.
184 *
185 * @param {object|array} data
186 * @param {function} [callback]
187 * @return {Promise}
188 */
189 insert(data, callback) {
190 if (Array.isArray(data)) {
191 return Promise.mapSeries(data, item => this.insertOne(item)).asCallback(callback);
192 }
193
194 return this.insertOne(data, callback);
195 }
196
197 /**
198 * Inserts the document if it does not exist; otherwise updates it.
199 *
200 * @param {object} data
201 * @param {function} [callback]
202 * @return {Promise}
203 */
204 save(data, callback) {
205 const id = data._id;
206
207 if (!id) return this.insertOne(data, callback);
208
209 return Promise.using(this._acquireWriteLock(), () => {
210 if (this.has(id)) {
211 return this._replaceById(id, data);
212 }
213
214 return this._insertOne(data);
215 }).asCallback(callback);
216 }
217
218 /**
219 * Updates a document with a compiled stack.
220 *
221 * @param {*} id
222 * @param {array} stack
223 * @return {Promise}
224 * @private
225 */
226 _updateWithStack(id, stack) {
227 const schema = this.schema;
228
229 const data = this.data[id];
230
231 if (!data) {
232 return Promise.reject(new WarehouseError('ID `' + id + '` does not exist', WarehouseError.ID_NOT_EXIST));
233 }
234
235 // Clone data
236 let result = cloneDeep(data);
237
238 // Update
239 for (let i = 0, len = stack.length; i < len; i++) {
240 stack[i](result);
241 }
242
243 // Apply getters
244 const doc = this.new(result);
245
246 // Apply setters
247 result = doc.toObject();
248 schema._applySetters(result);
249
250 // Pre-hooks
251 return execHooks(schema, 'pre', 'save', doc).then(data => {
252 // Update data
253 this.data[id] = result;
254
255 this.emit('update', data);
256 return execHooks(schema, 'post', 'save', data);
257 });
258 }
259
260 /**
261 * Finds a document by its identifier and update it.
262 *
263 * @param {*} id
264 * @param {object} update
265 * @param {function} [callback]
266 * @return {Promise}
267 */
268 updateById(id, update, callback) {
269 return Promise.using(this._acquireWriteLock(), () => {
270 const stack = this.schema._parseUpdate(update);
271 return this._updateWithStack(id, stack);
272 }).asCallback(callback);
273 }
274
275 /**
276 * Updates matching documents.
277 *
278 * @param {object} query
279 * @param {object} data
280 * @param {function} [callback]
281 * @return {Promise}
282 */
283 update(query, data, callback) {
284 return this.find(query).update(data, callback);
285 }
286
287 /**
288 * Finds a document by its identifier and replace it.
289 *
290 * @param {*} id
291 * @param {object} data
292 * @return {Promise}
293 * @private
294 */
295 _replaceById(id, data_) {
296 const schema = this.schema;
297
298 if (!this.has(id)) {
299 return Promise.reject(new WarehouseError('ID `' + id + '` does not exist', WarehouseError.ID_NOT_EXIST));
300 }
301
302 data_._id = id;
303
304 // Apply getters
305 const data = data_ instanceof this.Document ? data_ : this.new(data_);
306
307 // Apply setters
308 const result = data.toObject();
309 schema._applySetters(result);
310
311 // Pre-hooks
312 return execHooks(schema, 'pre', 'save', data).then(data => {
313 // Replace data
314 this.data[id] = result;
315
316 this.emit('update', data);
317 return execHooks(schema, 'post', 'save', data);
318 });
319 }
320
321 /**
322 * Finds a document by its identifier and replace it.
323 *
324 * @param {*} id
325 * @param {object} data
326 * @param {function} [callback]
327 * @return {Promise}
328 */
329 replaceById(id, data, callback) {
330 return Promise.using(this._acquireWriteLock(), () => this._replaceById(id, data)).asCallback(callback);
331 }
332
333 /**
334 * Replaces matching documents.
335 *
336 * @param {object} query
337 * @param {object} data
338 * @param {function} [callback]
339 * @return {Promise}
340 */
341 replace(query, data, callback) {
342 return this.find(query).replace(data, callback);
343 }
344
345 /**
346 * Finds a document by its identifier and remove it.
347 *
348 * @param {*} id
349 * @param {function} [callback]
350 * @return {Promise}
351 * @private
352 */
353 _removeById(id) {
354 const schema = this.schema;
355
356 const data = this.data[id];
357
358 if (!data) {
359 return Promise.reject(new WarehouseError('ID `' + id + '` does not exist', WarehouseError.ID_NOT_EXIST));
360 }
361
362 // Pre-hooks
363 return execHooks(schema, 'pre', 'remove', data).then(data => {
364 // Remove data
365 this.data[id] = null;
366 this.length--;
367
368 this.emit('remove', data);
369 return execHooks(schema, 'post', 'remove', data);
370 });
371 }
372
373 /**
374 * Finds a document by its identifier and remove it.
375 *
376 * @param {*} id
377 * @param {function} [callback]
378 * @return {Promise}
379 */
380 removeById(id, callback) {
381 return Promise.using(this._acquireWriteLock(), () => this._removeById(id)).asCallback(callback);
382 }
383
384 /**
385 * Removes matching documents.
386 *
387 * @param {object} query
388 * @param {object} [callback]
389 * @return {Promise}
390 */
391 remove(query, callback) {
392 return this.find(query).remove(callback);
393 }
394
395 /**
396 * Deletes a model.
397 */
398 destroy() {
399 this._database._models[this.name] = null;
400 }
401
402 /**
403 * Returns the number of elements.
404 *
405 * @return {number}
406 */
407 count() {
408 return this.length;
409 }
410
411 /**
412 * Iterates over all documents.
413 *
414 * @param {function} iterator
415 * @param {object} [options] See {@link Model#findById}.
416 */
417 forEach(iterator, options) {
418 const keys = Object.keys(this.data);
419 let num = 0;
420
421 for (let i = 0, len = keys.length; i < len; i++) {
422 const data = this.findById(keys[i], options);
423 if (data) iterator(data, num++);
424 }
425 }
426
427 /**
428 * Returns an array containing all documents.
429 *
430 * @param {Object} [options] See {@link Model#findById}.
431 * @return {Array}
432 */
433 toArray(options) {
434 const result = new Array(this.length);
435
436 this.forEach((item, i) => {
437 result[i] = item;
438 }, options);
439
440 return result;
441 }
442
443 /**
444 * Finds matching documents.
445 *
446 * @param {Object} query
447 * @param {Object} [options]
448 * @param {Number} [options.limit=0] Limits the number of documents returned.
449 * @param {Number} [options.skip=0] Skips the first elements.
450 * @param {Boolean} [options.lean=false] Returns a plain JavaScript object.
451 * @return {Query|Array}
452 */
453 find(query, options_) {
454 const options = options_ || {};
455 const filter = this.schema._execQuery(query);
456 const keys = Object.keys(this.data);
457 const len = keys.length;
458 let limit = options.limit || this.length;
459 let skip = options.skip;
460 const data = this.data;
461 const arr = [];
462
463 for (let i = 0; limit && i < len; i++) {
464 const key = keys[i];
465 const item = data[key];
466
467 if (item && filter(item)) {
468 if (skip) {
469 skip--;
470 } else {
471 arr.push(this.findById(key, options));
472 limit--;
473 }
474 }
475 }
476
477 return options.lean ? arr : new this.Query(arr);
478 }
479
480 /**
481 * Finds the first matching documents.
482 *
483 * @param {Object} query
484 * @param {Object} [options]
485 * @param {Number} [options.skip=0] Skips the first elements.
486 * @param {Boolean} [options.lean=false] Returns a plain JavaScript object.
487 * @return {Document|Object}
488 */
489 findOne(query, options_) {
490 const options = options_ || {};
491 options.limit = 1;
492
493 const result = this.find(query, options);
494 return options.lean ? result[0] : result.data[0];
495 }
496
497 /**
498 * Sorts documents. See {@link Query#sort}.
499 *
500 * @param {String|Object} orderby
501 * @param {String|Number} [order]
502 * @return {Query}
503 */
504 sort(orderby, order) {
505 const sort = parseArgs(orderby, order);
506 const fn = this.schema._execSort(sort);
507
508 return new this.Query(this.toArray().sort(fn));
509 }
510
511 /**
512 * Returns the document at the specified index. `num` can be a positive or
513 * negative number.
514 *
515 * @param {Number} i
516 * @param {Object} [options] See {@link Model#findById}.
517 * @return {Document|Object}
518 */
519 eq(i_, options) {
520 let index = i_ < 0 ? this.length + i_ : i_;
521 const data = this.data;
522 const keys = Object.keys(data);
523
524 for (let i = 0, len = keys.length; i < len; i++) {
525 const key = keys[i];
526 const item = data[key];
527
528 if (!item) continue;
529
530 if (index) {
531 index--;
532 } else {
533 return this.findById(key, options);
534 }
535 }
536 }
537
538 /**
539 * Returns the first document.
540 *
541 * @param {Object} [options] See {@link Model#findById}.
542 * @return {Document|Object}
543 */
544 first(options) {
545 return this.eq(0, options);
546 }
547
548 /**
549 * Returns the last document.
550 *
551 * @param {Object} [options] See {@link Model#findById}.
552 * @return {Document|Object}
553 */
554 last(options) {
555 return this.eq(-1, options);
556 }
557
558 /**
559 * Returns the specified range of documents.
560 *
561 * @param {Number} start
562 * @param {Number} [end]
563 * @return {Query}
564 */
565 slice(start_, end_) {
566 const total = this.length;
567
568 let start = start_ | 0;
569 if (start < 0) start += total;
570 if (start > total - 1) return new this.Query([]);
571
572 let end = end_ | 0 || total;
573 if (end < 0) end += total;
574
575 let len = start > end ? 0 : end - start;
576 if (len > total) len = total - start;
577 if (!len) return new this.Query([]);
578
579 const arr = new Array(len);
580 const keys = Object.keys(this.data);
581 const keysLen = keys.length;
582 let num = 0;
583
584 for (let i = 0; num < len && i < keysLen; i++) {
585 const data = this.findById(keys[i]);
586 if (!data) continue;
587
588 if (start) {
589 start--;
590 } else {
591 arr[num++] = data;
592 }
593 }
594
595 return new this.Query(arr);
596 }
597
598 /**
599 * Limits the number of documents returned.
600 *
601 * @param {Number} i
602 * @return {Query}
603 */
604 limit(i) {
605 return this.slice(0, i);
606 }
607
608 /**
609 * Specifies the number of items to skip.
610 *
611 * @param {Number} i
612 * @return {Query}
613 */
614 skip(i) {
615 return this.slice(i);
616 }
617
618 /**
619 * Returns documents in a reversed order.
620 *
621 * @return {Query}
622 */
623 reverse() {
624 return new this.Query(this.toArray().reverse());
625 }
626
627 /**
628 * Returns documents in random order.
629 *
630 * @return {Query}
631 */
632 shuffle() {
633 return new this.Query(shuffle(this.toArray()));
634 }
635
636 /**
637 * Creates an array of values by iterating each element in the collection.
638 *
639 * @param {Function} iterator
640 * @param {Object} [options]
641 * @return {Array}
642 */
643 map(iterator, options) {
644 const result = new Array(this.length);
645 const keys = Object.keys(this.data);
646 const len = keys.length;
647
648 for (let i = 0, num = 0; i < len; i++) {
649 const data = this.findById(keys[i], options);
650 if (data) {
651 result[num] = iterator(data, num);
652 num++;
653 }
654 }
655
656 return result;
657 }
658
659 /**
660 * Reduces a collection to a value which is the accumulated result of iterating
661 * each element in the collection.
662 *
663 * @param {Function} iterator
664 * @param {*} [initial] By default, the initial value is the first document.
665 * @return {*}
666 */
667 reduce(iterator, initial) {
668 const arr = this.toArray();
669 const len = this.length;
670 let i, result;
671
672 if (initial === undefined) {
673 i = 1;
674 result = arr[0];
675 } else {
676 i = 0;
677 result = initial;
678 }
679
680 for (; i < len; i++) {
681 result = iterator(result, arr[i], i);
682 }
683
684 return result;
685 }
686
687 /**
688 * Reduces a collection to a value which is the accumulated result of iterating
689 * each element in the collection from right to left.
690 *
691 * @param {Function} iterator
692 * @param {*} [initial] By default, the initial value is the last document.
693 * @return {*}
694 */
695 reduceRight(iterator, initial) {
696 const arr = this.toArray();
697 const len = this.length;
698 let i, result;
699
700 if (initial === undefined) {
701 i = len - 2;
702 result = arr[len - 1];
703 } else {
704 i = len - 1;
705 result = initial;
706 }
707
708 for (; i >= 0; i--) {
709 result = iterator(result, arr[i], i);
710 }
711
712 return result;
713 }
714
715 /**
716 * Creates a new array with all documents that pass the test implemented by the
717 * provided function.
718 *
719 * @param {Function} iterator
720 * @param {Object} [options]
721 * @return {Query}
722 */
723 filter(iterator, options) {
724 const arr = [];
725
726 this.forEach((item, i) => {
727 if (iterator(item, i)) arr.push(item);
728 }, options);
729
730 return new this.Query(arr);
731 }
732
733 /**
734 * Tests whether all documents pass the test implemented by the provided
735 * function.
736 *
737 * @param {Function} iterator
738 * @return {Boolean}
739 */
740 every(iterator) {
741 const keys = Object.keys(this.data);
742 const len = keys.length;
743 let num = 0;
744
745 if (!len) return true;
746
747 for (let i = 0; i < len; i++) {
748 const data = this.findById(keys[i]);
749
750 if (data) {
751 if (!iterator(data, num++)) return false;
752 }
753 }
754
755 return true;
756 }
757
758 /**
759 * Tests whether some documents pass the test implemented by the provided
760 * function.
761 *
762 * @param {Function} iterator
763 * @return {Boolean}
764 */
765 some(iterator) {
766 const keys = Object.keys(this.data);
767 const len = keys.length;
768 let num = 0;
769
770 if (!len) return false;
771
772 for (let i = 0; i < len; i++) {
773 const data = this.findById(keys[i]);
774
775 if (data) {
776 if (iterator(data, num++)) return true;
777 }
778 }
779
780 return false;
781 }
782
783 /**
784 * Returns a getter function for normal population.
785 *
786 * @param {Object} data
787 * @param {Model} model
788 * @param {Object} options
789 * @return {Function}
790 * @private
791 */
792 _populateGetter(data, model, options) {
793 let hasCache = false;
794 let cache;
795
796 return () => {
797 if (!hasCache) {
798 cache = model.findById(data);
799 hasCache = true;
800 }
801
802 return cache;
803 };
804 }
805
806 /**
807 * Returns a getter function for array population.
808 *
809 * @param {Object} data
810 * @param {Model} model
811 * @param {Object} options
812 * @return {Function}
813 * @private
814 */
815 _populateGetterArray(data, model, options) {
816 const Query = model.Query;
817 let hasCache = false;
818 let cache;
819
820 return () => {
821 if (!hasCache) {
822 let arr = [];
823
824 for (let i = 0, len = data.length; i < len; i++) {
825 arr.push(model.findById(data[i]));
826 }
827
828 if (options.match) {
829 cache = new Query(arr).find(options.match, options);
830 } else if (options.skip) {
831 if (options.limit) {
832 arr = arr.slice(options.skip, options.skip + options.limit);
833 } else {
834 arr = arr.slice(options.skip);
835 }
836
837 cache = new Query(arr);
838 } else if (options.limit) {
839 cache = new Query(arr.slice(0, options.limit));
840 } else {
841 cache = new Query(arr);
842 }
843
844 if (options.sort) {
845 cache = cache.sort(options.sort);
846 }
847
848 hasCache = true;
849 }
850
851 return cache;
852 };
853 }
854
855 /**
856 * Populates document references with a compiled stack.
857 *
858 * @param {Object} data
859 * @param {Array} stack
860 * @return {Object}
861 * @private
862 */
863 _populate(data, stack) {
864 const models = this._database._models;
865
866 for (let i = 0, len = stack.length; i < len; i++) {
867 const item = stack[i];
868 const model = models[item.model];
869
870 if (!model) {
871 throw new PopulationError('Model `' + item.model + '` does not exist');
872 }
873
874 const path = item.path;
875 const prop = getProp(data, path);
876
877 if (Array.isArray(prop)) {
878 setGetter(data, path, this._populateGetterArray(prop, model, item));
879 } else {
880 setGetter(data, path, this._populateGetter(prop, model, item));
881 }
882 }
883
884 return data;
885 }
886
887 /**
888 * Populates document references.
889 *
890 * @param {String|Object} path
891 * @return {Query}
892 */
893 populate(path) {
894 if (!path) throw new TypeError('path is required');
895
896 const stack = this.schema._parsePopulate(path);
897 const arr = new Array(this.length);
898
899 this.forEach((item, i) => {
900 arr[i] = this._populate(item, stack);
901 });
902
903 return new Query(arr);
904 }
905
906 /**
907 * Imports data.
908 *
909 * @param {Array} arr
910 * @private
911 */
912 _import(arr) {
913 const len = arr.length;
914 const data = this.data;
915 const schema = this.schema;
916
917 for (let i = 0; i < len; i++) {
918 const item = arr[i];
919 data[item._id] = schema._parseDatabase(item);
920 }
921
922 this.length = len;
923 }
924
925 /**
926 * Exports data.
927 *
928 * @return {String}
929 * @private
930 */
931 _export() {
932 return JSON.stringify(this.toJSON());
933 }
934
935 toJSON() {
936 const result = new Array(this.length);
937 const { data, schema } = this;
938 const keys = Object.keys(data);
939 const { length } = keys;
940
941 for (let i = 0, num = 0; i < length; i++) {
942 const raw = data[keys[i]];
943 if (raw) {
944 result[num++] = schema._exportDatabase(cloneDeep(raw));
945 }
946 }
947 return result;
948 }
949}
950
951Model.prototype.get = Model.prototype.findById;
952
953function execHooks(schema, type, event, data) {
954 const hooks = schema.hooks[type][event];
955 if (!hooks.length) return Promise.resolve(data);
956
957 return Promise.each(hooks, hook => hook(data)).thenReturn(data);
958}
959
960Model.prototype.size = Model.prototype.count;
961
962Model.prototype.each = Model.prototype.forEach;
963
964Model.prototype.random = Model.prototype.shuffle;
965
966module.exports = Model;