UNPKG

129 kBJavaScriptView Raw
1// Copyright IBM Corp. 2015,2019. All Rights Reserved.
2// Node module: loopback-datasource-juggler
3// This file is licensed under the MIT License.
4// License text available at https://opensource.org/licenses/MIT
5
6'use strict';
7const ValidationError = require('../').ValidationError;
8
9const async = require('async');
10const contextTestHelpers = require('./helpers/context-test-helpers');
11const ContextRecorder = contextTestHelpers.ContextRecorder;
12const deepCloneToObject = contextTestHelpers.deepCloneToObject;
13const aCtxForModel = contextTestHelpers.aCtxForModel;
14const GeoPoint = require('../lib/geo.js').GeoPoint;
15
16const uid = require('./helpers/uid-generator');
17const getLastGeneratedUid = uid.last;
18
19const HookMonitor = require('./helpers/hook-monitor');
20let isNewInstanceFlag;
21
22module.exports = function(dataSource, should, connectorCapabilities) {
23 isNewInstanceFlag = connectorCapabilities.replaceOrCreateReportsNewInstance;
24 if (!connectorCapabilities) connectorCapabilities = {};
25 if (isNewInstanceFlag === undefined) {
26 const warn = 'The connector does not support a recently added feature:' +
27 ' replaceOrCreateReportsNewInstance';
28 console.warn(warn);
29 }
30 describe('Persistence hooks', function() {
31 let ctxRecorder, hookMonitor, expectedError;
32 let TestModel, existingInstance, GeoModel;
33 let migrated = false;
34
35 let undefinedValue = undefined;
36
37 beforeEach(function setupDatabase(done) {
38 ctxRecorder = new ContextRecorder('hook not called');
39 hookMonitor = new HookMonitor({includeModelName: false});
40 expectedError = new Error('test error');
41
42 TestModel = dataSource.createModel('TestModel', {
43 // Set id.generated to false to honor client side values
44 id: {type: String, id: true, generated: false, default: uid.next},
45 name: {type: String, required: true},
46 extra: {type: String, required: false},
47 });
48
49 GeoModel = dataSource.createModel('GeoModel', {
50 id: {type: String, id: true, default: uid.next},
51 name: {type: String, required: false},
52 location: {type: GeoPoint, required: false},
53 });
54
55 uid.reset();
56
57 if (migrated) {
58 async.series([
59 function(cb) {
60 TestModel.deleteAll(cb);
61 },
62 function(cb) {
63 GeoModel.deleteAll(cb);
64 },
65 ], done);
66 } else {
67 dataSource.automigrate([TestModel.modelName, 'GeoModel'], function(err) {
68 migrated = true;
69 done(err);
70 });
71 }
72 });
73
74 beforeEach(function createTestData(done) {
75 TestModel.create({name: 'first'}, function(err, instance) {
76 if (err) return done(err);
77
78 // Look it up from DB so that default values are retrieved
79 TestModel.findById(instance.id, function(err, instance) {
80 existingInstance = instance;
81 undefinedValue = existingInstance.extra;
82
83 TestModel.create({name: 'second'}, function(err) {
84 if (err) return done(err);
85 const location1 = new GeoPoint({lat: 10.2, lng: 6.7});
86 const location2 = new GeoPoint({lat: 10.3, lng: 6.8});
87 GeoModel.create([
88 {name: 'Rome', location: location1},
89 {name: 'Tokyo', location: location2},
90 ], function(err) {
91 done(err);
92 });
93 });
94 });
95 });
96 });
97
98 describe('PersistedModel.find', function() {
99 it('triggers hooks in the correct order', function(done) {
100 monitorHookExecution();
101
102 TestModel.find(
103 {where: {id: '1'}},
104 function(err, list) {
105 if (err) return done(err);
106
107 hookMonitor.names.should.eql([
108 'access',
109 'loaded',
110 ]);
111 done();
112 },
113 );
114 });
115
116 it('triggers the loaded hook multiple times when multiple instances exist', function(done) {
117 monitorHookExecution();
118
119 TestModel.find(function(err, list) {
120 if (err) return done(err);
121
122 hookMonitor.names.should.eql([
123 'access',
124 'loaded',
125 'loaded',
126 ]);
127 done();
128 });
129 });
130
131 it('should not trigger hooks, if notify is false', function(done) {
132 monitorHookExecution();
133 TestModel.find(
134 {where: {id: '1'}},
135 {notify: false},
136 function(err, list) {
137 if (err) return done(err);
138 hookMonitor.names.should.be.empty();
139 done();
140 },
141 );
142 });
143
144 it('triggers the loaded hook multiple times when multiple instances exist when near filter is used',
145 function(done) {
146 const hookMonitorGeoModel = new HookMonitor({includeModelName: false});
147
148 function monitorHookExecutionGeoModel(hookNames) {
149 hookMonitorGeoModel.install(GeoModel, hookNames);
150 }
151
152 monitorHookExecutionGeoModel();
153
154 const query = {
155 where: {location: {near: '10,5'}},
156 };
157 GeoModel.find(query, function(err, list) {
158 if (err) return done(err);
159
160 hookMonitorGeoModel.names.should.eql(['access', 'loaded', 'loaded']);
161 done();
162 });
163 });
164
165 it('applies updates from `loaded` hook when near filter is used', function(done) {
166 GeoModel.observe('loaded', function(ctx, next) {
167 // It's crucial to change `ctx.data` reference, not only data props
168 ctx.data = Object.assign({}, ctx.data, {name: 'Berlin'});
169 next();
170 });
171
172 const query = {
173 where: {location: {near: '10,5'}},
174 };
175
176 GeoModel.find(query, function(err, list) {
177 if (err) return done(err);
178 list.map(get('name')).should.eql(['Berlin', 'Berlin']);
179 done();
180 });
181 });
182
183 it('applies updates to one specific instance from `loaded` hook when near filter is used',
184 function(done) {
185 GeoModel.observe('loaded', function(ctx, next) {
186 if (ctx.data.name === 'Rome') {
187 // It's crucial to change `ctx.data` reference, not only data props
188 ctx.data = Object.assign({}, ctx.data, {name: 'Berlin'});
189 }
190 next();
191 });
192
193 const query = {
194 where: {location: {near: '10,5'}},
195 };
196
197 GeoModel.find(query, function(err, list) {
198 if (err) return done(err);
199 list.map(get('name')).should.containEql('Berlin', 'Tokyo');
200 done();
201 });
202 });
203
204 it('applies updates from `loaded` hook when near filter is not used', function(done) {
205 TestModel.observe('loaded', function(ctx, next) {
206 // It's crucial to change `ctx.data` reference, not only data props
207 ctx.data = Object.assign({}, ctx.data, {name: 'Paris'});
208 next();
209 });
210
211 TestModel.find(function(err, list) {
212 if (err) return done(err);
213 list.map(get('name')).should.eql(['Paris', 'Paris']);
214 done();
215 });
216 });
217
218 it('applies updates to one specific instance from `loaded` hook when near filter is not used',
219 function(done) {
220 TestModel.observe('loaded', function(ctx, next) {
221 if (ctx.data.name === 'first') {
222 // It's crucial to change `ctx.data` reference, not only data props
223 ctx.data = Object.assign({}, ctx.data, {name: 'Paris'});
224 }
225 next();
226 });
227
228 TestModel.find(function(err, list) {
229 if (err) return done(err);
230 list.map(get('name')).should.eql(['Paris', 'second']);
231 done();
232 });
233 });
234
235 it('should not trigger hooks for geo queries, if notify is false',
236 function(done) {
237 monitorHookExecution();
238
239 TestModel.find(
240 {where: {geo: {near: '10,20'}}},
241 {notify: false},
242 function(err, list) {
243 if (err) return done(err);
244 hookMonitor.names.should.be.empty();
245 done();
246 },
247 );
248 });
249
250 it('should apply updates from `access` hook', function(done) {
251 TestModel.observe('access', function(ctx, next) {
252 ctx.query = {where: {name: 'second'}};
253 next();
254 });
255
256 TestModel.find({name: 'first'}, function(err, list) {
257 if (err) return done(err);
258 list.map(get('name')).should.eql(['second']);
259 done();
260 });
261 });
262
263 it('triggers `access` hook', function(done) {
264 TestModel.observe('access', ctxRecorder.recordAndNext());
265
266 TestModel.find({where: {id: '1'}}, function(err, list) {
267 if (err) return done(err);
268 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
269 query: {where: {id: '1'}},
270 }));
271 done();
272 });
273 });
274
275 it('aborts when `access` hook fails', function(done) {
276 TestModel.observe('access', nextWithError(expectedError));
277
278 TestModel.find(function(err, list) {
279 [err].should.eql([expectedError]);
280 done();
281 });
282 });
283
284 it('applies updates from `access` hook', function(done) {
285 TestModel.observe('access', function(ctx, next) {
286 ctx.query = {where: {id: existingInstance.id}};
287 next();
288 });
289
290 TestModel.find(function(err, list) {
291 if (err) return done(err);
292 list.map(get('name')).should.eql([existingInstance.name]);
293 done();
294 });
295 });
296
297 it('triggers `access` hook for geo queries', function(done) {
298 TestModel.observe('access', ctxRecorder.recordAndNext());
299
300 TestModel.find({where: {geo: {near: '10,20'}}}, function(err, list) {
301 if (err) return done(err);
302 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
303 query: {where: {geo: {near: '10,20'}}},
304 }));
305 done();
306 });
307 });
308
309 it('applies updates from `access` hook for geo queries', function(done) {
310 TestModel.observe('access', function(ctx, next) {
311 ctx.query = {where: {id: existingInstance.id}};
312 next();
313 });
314
315 TestModel.find({where: {geo: {near: '10,20'}}}, function(err, list) {
316 if (err) return done(err);
317 list.map(get('name')).should.eql([existingInstance.name]);
318 done();
319 });
320 });
321
322 it('applies updates from `loaded` hook', function(done) {
323 TestModel.observe('loaded', ctxRecorder.recordAndNext(function(ctx) {
324 // It's crucial to change `ctx.data` reference, not only data props
325 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
326 }));
327
328 TestModel.find(
329 {where: {id: 1}},
330 function(err, list) {
331 if (err) return done(err);
332
333 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
334 data: {
335 id: '1',
336 name: 'first',
337 extra: 'hook data',
338 },
339 isNewInstance: false,
340 options: {},
341 }));
342
343 list[0].should.have.property('extra', 'hook data');
344 done();
345 },
346 );
347 });
348
349 it('emits error when `loaded` hook fails', function(done) {
350 TestModel.observe('loaded', nextWithError(expectedError));
351 TestModel.find(
352 {where: {id: 1}},
353 function(err, list) {
354 [err].should.eql([expectedError]);
355 done();
356 },
357 );
358 });
359 });
360
361 describe('PersistedModel.create', function() {
362 it('triggers hooks in the correct order', function(done) {
363 monitorHookExecution();
364
365 TestModel.create(
366 {name: 'created'},
367 function(err, record, created) {
368 if (err) return done(err);
369
370 hookMonitor.names.should.eql([
371 'before save',
372 'persist',
373 'loaded',
374 'after save',
375 ]);
376 done();
377 },
378 );
379 });
380
381 it('aborts when `after save` fires when option to notify is false', function(done) {
382 monitorHookExecution();
383
384 TestModel.create({name: 'created'}, {notify: false}, function(err, record, created) {
385 if (err) return done(err);
386
387 hookMonitor.names.should.not.containEql('after save');
388 done();
389 });
390 });
391
392 it('triggers `before save` hook', function(done) {
393 TestModel.observe('before save', ctxRecorder.recordAndNext());
394
395 TestModel.create({name: 'created'}, function(err, instance) {
396 if (err) return done(err);
397 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
398 instance: {
399 id: instance.id,
400 name: 'created',
401 extra: undefined,
402 },
403 isNewInstance: true,
404 }));
405 done();
406 });
407 });
408
409 it('aborts when `before save` hook fails', function(done) {
410 TestModel.observe('before save', nextWithError(expectedError));
411
412 TestModel.create({name: 'created'}, function(err, instance) {
413 [err].should.eql([expectedError]);
414 done();
415 });
416 });
417
418 it('applies updates from `before save` hook', function(done) {
419 TestModel.observe('before save', function(ctx, next) {
420 ctx.instance.should.be.instanceOf(TestModel);
421 ctx.instance.extra = 'hook data';
422 next();
423 });
424
425 TestModel.create({id: uid.next(), name: 'a-name'}, function(err, instance) {
426 if (err) return done(err);
427 instance.should.have.property('extra', 'hook data');
428 done();
429 });
430 });
431
432 it('sends `before save` for each model in an array', function(done) {
433 TestModel.observe('before save', ctxRecorder.recordAndNext());
434
435 TestModel.create(
436 [{name: '1'}, {name: '2'}],
437 function(err, list) {
438 if (err) return done(err);
439 // Creation of multiple instances is executed in parallel
440 ctxRecorder.records.sort(function(c1, c2) {
441 return c1.instance.name - c2.instance.name;
442 });
443 ctxRecorder.records.should.eql([
444 aCtxForModel(TestModel, {
445 instance: {id: list[0].id, name: '1', extra: undefined},
446 isNewInstance: true,
447 }),
448 aCtxForModel(TestModel, {
449 instance: {id: list[1].id, name: '2', extra: undefined},
450 isNewInstance: true,
451 }),
452 ]);
453 done();
454 },
455 );
456 });
457
458 it('validates model after `before save` hook', function(done) {
459 TestModel.observe('before save', invalidateTestModel());
460
461 TestModel.create({name: 'created'}, function(err) {
462 (err || {}).should.be.instanceOf(ValidationError);
463 (err.details.codes || {}).should.eql({name: ['presence']});
464 done();
465 });
466 });
467
468 it('triggers `persist` hook', function(done) {
469 TestModel.observe('persist', ctxRecorder.recordAndNext());
470
471 TestModel.create(
472 {id: 'new-id', name: 'a name'},
473 function(err, instance) {
474 if (err) return done(err);
475
476 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
477 data: {id: 'new-id', name: 'a name'},
478 isNewInstance: true,
479 currentInstance: {extra: null, id: 'new-id', name: 'a name'},
480 }));
481
482 done();
483 },
484 );
485 });
486
487 it('applies updates from `persist` hook', function(done) {
488 TestModel.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
489 // It's crucial to change `ctx.data` reference, not only data props
490 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
491 }));
492
493 // By default, the instance passed to create callback is NOT updated
494 // with the changes made through persist/loaded hooks. To preserve
495 // backwards compatibility, we introduced a new setting updateOnLoad,
496 // which if set, will apply these changes to the model instance too.
497 TestModel.settings.updateOnLoad = true;
498 TestModel.create(
499 {id: 'new-id', name: 'a name'},
500 function(err, instance) {
501 if (err) return done(err);
502
503 instance.should.have.property('extra', 'hook data');
504
505 // Also query the database here to verify that, on `create`
506 // updates from `persist` hook are reflected into database
507 TestModel.findById('new-id', function(err, dbInstance) {
508 if (err) return done(err);
509 should.exists(dbInstance);
510 dbInstance.toObject(true).should.eql({
511 id: 'new-id',
512 name: 'a name',
513 extra: 'hook data',
514 });
515 done();
516 });
517 },
518 );
519 });
520
521 it('triggers `loaded` hook', function(done) {
522 TestModel.observe('loaded', ctxRecorder.recordAndNext());
523
524 // By default, the instance passed to create callback is NOT updated
525 // with the changes made through persist/loaded hooks. To preserve
526 // backwards compatibility, we introduced a new setting updateOnLoad,
527 // which if set, will apply these changes to the model instance too.
528 TestModel.settings.updateOnLoad = true;
529 TestModel.create(
530 {id: 'new-id', name: 'a name'},
531 function(err, instance) {
532 if (err) return done(err);
533
534 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
535 data: {id: 'new-id', name: 'a name'},
536 isNewInstance: true,
537 }));
538
539 done();
540 },
541 );
542 });
543
544 it('emits error when `loaded` hook fails', function(done) {
545 TestModel.observe('loaded', nextWithError(expectedError));
546 TestModel.create(
547 {id: 'new-id', name: 'a name'},
548 function(err, instance) {
549 [err].should.eql([expectedError]);
550 done();
551 },
552 );
553 });
554
555 it('applies updates from `loaded` hook', function(done) {
556 TestModel.observe('loaded', ctxRecorder.recordAndNext(function(ctx) {
557 // It's crucial to change `ctx.data` reference, not only data props
558 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
559 }));
560
561 // By default, the instance passed to create callback is NOT updated
562 // with the changes made through persist/loaded hooks. To preserve
563 // backwards compatibility, we introduced a new setting updateOnLoad,
564 // which if set, will apply these changes to the model instance too.
565 TestModel.settings.updateOnLoad = true;
566 TestModel.create(
567 {id: 'new-id', name: 'a name'},
568 function(err, instance) {
569 if (err) return done(err);
570
571 instance.should.have.property('extra', 'hook data');
572 done();
573 },
574 );
575 });
576
577 it('triggers `after save` hook', function(done) {
578 TestModel.observe('after save', ctxRecorder.recordAndNext());
579
580 TestModel.create({name: 'created'}, function(err, instance) {
581 if (err) return done(err);
582 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
583 instance: {
584 id: instance.id,
585 name: 'created',
586 extra: undefined,
587 },
588 isNewInstance: true,
589 }));
590 done();
591 });
592 });
593
594 it('aborts when `after save` hook fails', function(done) {
595 TestModel.observe('after save', nextWithError(expectedError));
596
597 TestModel.create({name: 'created'}, function(err, instance) {
598 [err].should.eql([expectedError]);
599 done();
600 });
601 });
602
603 it('applies updates from `after save` hook', function(done) {
604 TestModel.observe('after save', function(ctx, next) {
605 ctx.instance.should.be.instanceOf(TestModel);
606 ctx.instance.extra = 'hook data';
607 next();
608 });
609
610 TestModel.create({name: 'a-name'}, function(err, instance) {
611 if (err) return done(err);
612 instance.should.have.property('extra', 'hook data');
613 done();
614 });
615 });
616
617 it('sends `after save` for each model in an array', function(done) {
618 TestModel.observe('after save', ctxRecorder.recordAndNext());
619
620 TestModel.create(
621 [{name: '1'}, {name: '2'}],
622 function(err, list) {
623 if (err) return done(err);
624 // Creation of multiple instances is executed in parallel
625 ctxRecorder.records.sort(function(c1, c2) {
626 return c1.instance.name - c2.instance.name;
627 });
628 ctxRecorder.records.should.eql([
629 aCtxForModel(TestModel, {
630 instance: {id: list[0].id, name: '1', extra: undefined},
631 isNewInstance: true,
632 }),
633 aCtxForModel(TestModel, {
634 instance: {id: list[1].id, name: '2', extra: undefined},
635 isNewInstance: true,
636 }),
637 ]);
638 done();
639 },
640 );
641 });
642
643 it('emits `after save` when some models were not saved', function(done) {
644 TestModel.observe('before save', function(ctx, next) {
645 if (ctx.instance.name === 'fail')
646 next(expectedError);
647 else
648 next();
649 });
650
651 TestModel.observe('after save', ctxRecorder.recordAndNext());
652
653 TestModel.create(
654 [{name: 'ok'}, {name: 'fail'}],
655 function(err, list) {
656 (err || []).should.have.length(2);
657 err[1].should.eql(expectedError);
658
659 // NOTE(bajtos) The current implementation of `Model.create(array)`
660 // passes all models in the second callback argument, including
661 // the models that were not created due to an error.
662 list.map(get('name')).should.eql(['ok', 'fail']);
663
664 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
665 instance: {id: list[0].id, name: 'ok', extra: undefined},
666 isNewInstance: true,
667 }));
668 done();
669 },
670 );
671 });
672 });
673
674 describe('PersistedModel.findOrCreate', function() {
675 it('triggers `access` hook', function(done) {
676 TestModel.observe('access', ctxRecorder.recordAndNext());
677
678 TestModel.findOrCreate(
679 {where: {name: 'new-record'}},
680 {name: 'new-record'},
681 function(err, record, created) {
682 if (err) return done(err);
683 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
684 where: {name: 'new-record'},
685 limit: 1,
686 offset: 0,
687 skip: 0,
688 }}));
689 done();
690 },
691 );
692 });
693
694 if (dataSource.connector.findOrCreate) {
695 it('triggers `before save` hook when found', function(done) {
696 TestModel.observe('before save', ctxRecorder.recordAndNext());
697
698 TestModel.findOrCreate(
699 {where: {name: existingInstance.name}},
700 {name: existingInstance.name},
701 function(err, record, created) {
702 if (err) return done(err);
703 record.id.should.eql(existingInstance.id);
704 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
705 instance: {
706 id: getLastGeneratedUid(),
707 name: existingInstance.name,
708 extra: undefined,
709 },
710 isNewInstance: true,
711 }));
712 done();
713 },
714 );
715 });
716 }
717
718 it('triggers `before save` hook when not found', function(done) {
719 TestModel.observe('before save', ctxRecorder.recordAndNext());
720
721 TestModel.findOrCreate(
722 {where: {name: 'new-record'}},
723 {name: 'new-record'},
724 function(err, record, created) {
725 if (err) return done(err);
726 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
727 instance: {
728 id: record.id,
729 name: 'new-record',
730 extra: undefined,
731 },
732 isNewInstance: true,
733 }));
734 done();
735 },
736 );
737 });
738
739 it('validates model after `before save` hook', function(done) {
740 TestModel.observe('before save', invalidateTestModel());
741
742 TestModel.findOrCreate(
743 {where: {name: 'new-record'}},
744 {name: 'new-record'},
745 function(err) {
746 (err || {}).should.be.instanceOf(ValidationError);
747 (err.details.codes || {}).should.eql({name: ['presence']});
748 done();
749 },
750 );
751 });
752
753 it('triggers hooks in the correct order when not found', function(done) {
754 monitorHookExecution();
755
756 TestModel.findOrCreate(
757 {where: {name: 'new-record'}},
758 {name: 'new-record'},
759 function(err, record, created) {
760 if (err) return done(err);
761 hookMonitor.names.should.eql([
762 'access',
763 'before save',
764 'persist',
765 'loaded',
766 'after save',
767 ]);
768 done();
769 },
770 );
771 });
772
773 it('triggers hooks in the correct order when found', function(done) {
774 monitorHookExecution();
775
776 TestModel.findOrCreate(
777 {where: {name: existingInstance.name}},
778 {name: existingInstance.name},
779 function(err, record, created) {
780 if (err) return done(err);
781
782 if (dataSource.connector.findOrCreate) {
783 hookMonitor.names.should.eql([
784 'access',
785 'before save',
786 'persist',
787 'loaded',
788 ]);
789 } else {
790 hookMonitor.names.should.eql([
791 'access',
792 'loaded',
793 ]);
794 }
795 done();
796 },
797 );
798 });
799
800 it('aborts when `access` hook fails', function(done) {
801 TestModel.observe('access', nextWithError(expectedError));
802
803 TestModel.findOrCreate(
804 {where: {id: 'does-not-exist'}},
805 {name: 'does-not-exist'},
806 function(err, instance) {
807 [err].should.eql([expectedError]);
808 done();
809 },
810 );
811 });
812
813 it('aborts when `before save` hook fails', function(done) {
814 TestModel.observe('before save', nextWithError(expectedError));
815
816 TestModel.findOrCreate(
817 {where: {id: 'does-not-exist'}},
818 {name: 'does-not-exist'},
819 function(err, instance) {
820 [err].should.eql([expectedError]);
821 done();
822 },
823 );
824 });
825
826 if (dataSource.connector.findOrCreate) {
827 it('triggers `persist` hook when found', function(done) {
828 TestModel.observe('persist', ctxRecorder.recordAndNext());
829
830 TestModel.findOrCreate(
831 {where: {name: existingInstance.name}},
832 {name: existingInstance.name},
833 function(err, record, created) {
834 if (err) return done(err);
835
836 record.id.should.eql(existingInstance.id);
837
838 // `findOrCreate` creates a new instance of the object everytime.
839 // So, `data.id` as well as `currentInstance.id` always matches
840 // the newly generated UID.
841 // Hence, the test below asserts both `data.id` and
842 // `currentInstance.id` to match getLastGeneratedUid().
843 // On same lines, it also asserts `isNewInstance` to be true.
844 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
845 data: {
846 id: getLastGeneratedUid(),
847 name: existingInstance.name,
848 },
849 isNewInstance: true,
850 currentInstance: {
851 id: getLastGeneratedUid(),
852 name: record.name,
853 extra: null,
854 },
855 where: {name: existingInstance.name},
856 }));
857
858 done();
859 },
860 );
861 });
862 }
863
864 it('triggers `persist` hook when not found', function(done) {
865 TestModel.observe('persist', ctxRecorder.recordAndNext());
866
867 TestModel.findOrCreate(
868 {where: {name: 'new-record'}},
869 {name: 'new-record'},
870 function(err, record, created) {
871 if (err) return done(err);
872
873 // `context.where` is present in Optimized connector context,
874 // but, unoptimized connector does NOT have it.
875 if (dataSource.connector.findOrCreate) {
876 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
877 data: {
878 id: record.id,
879 name: 'new-record',
880 },
881 isNewInstance: true,
882 currentInstance: {
883 id: record.id,
884 name: record.name,
885 extra: null,
886 },
887 where: {name: 'new-record'},
888 }));
889 } else {
890 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
891 data: {
892 id: record.id,
893 name: 'new-record',
894 },
895 isNewInstance: true,
896 currentInstance: {id: record.id, name: record.name, extra: null},
897 }));
898 }
899 done();
900 },
901 );
902 });
903
904 if (dataSource.connector.findOrCreate) {
905 it('applies updates from `persist` hook when found', function(done) {
906 TestModel.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
907 // It's crucial to change `ctx.data` reference, not only data props
908 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
909 }));
910
911 TestModel.findOrCreate(
912 {where: {name: existingInstance.name}},
913 {name: existingInstance.name},
914 function(err, instance) {
915 if (err) return done(err);
916
917 // instance returned by `findOrCreate` context does not
918 // have the values updated from `persist` hook
919 instance.should.not.have.property('extra', 'hook data');
920
921 // Query the database. Here, since record already exists
922 // `findOrCreate`, does not update database for
923 // updates from `persist` hook
924 TestModel.findById(existingInstance.id, function(err, dbInstance) {
925 if (err) return done(err);
926 should.exists(dbInstance);
927 dbInstance.toObject(true).should.eql({
928 id: existingInstance.id,
929 name: existingInstance.name,
930 extra: undefined,
931 });
932 });
933
934 done();
935 },
936 );
937 });
938 }
939
940 it('applies updates from `persist` hook when not found', function(done) {
941 TestModel.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
942 // It's crucial to change `ctx.data` reference, not only data props
943 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
944 }));
945
946 TestModel.findOrCreate(
947 {where: {name: 'new-record'}},
948 {name: 'new-record'},
949 function(err, instance) {
950 if (err) return done(err);
951
952 if (dataSource.connector.findOrCreate) {
953 instance.should.have.property('extra', 'hook data');
954 } else {
955 // Unoptimized connector gives a call to `create. And during
956 // create the updates applied through persist hook are
957 // reflected into the database, but the same updates are
958 // NOT reflected in the instance object obtained in callback
959 // of create.
960 // So, this test asserts unoptimized connector to
961 // NOT have `extra` property. And then verifes that the
962 // property `extra` is actually updated in DB
963 instance.should.not.have.property('extra', 'hook data');
964 TestModel.findById(instance.id, function(err, dbInstance) {
965 if (err) return done(err);
966 should.exists(dbInstance);
967 dbInstance.toObject(true).should.eql({
968 id: instance.id,
969 name: instance.name,
970 extra: 'hook data',
971 });
972 });
973 }
974 done();
975 },
976 );
977 });
978
979 if (dataSource.connector.findOrCreate) {
980 it('triggers `loaded` hook when found', function(done) {
981 TestModel.observe('loaded', ctxRecorder.recordAndNext());
982
983 TestModel.findOrCreate(
984 {where: {name: existingInstance.name}},
985 {name: existingInstance.name},
986 function(err, record, created) {
987 if (err) return done(err);
988
989 record.id.should.eql(existingInstance.id);
990
991 // After the call to `connector.findOrCreate`, since the record
992 // already exists, `data.id` matches `existingInstance.id`
993 // as against the behaviour noted for `persist` hook
994 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
995 data: {
996 id: existingInstance.id,
997 name: existingInstance.name,
998 },
999 isNewInstance: false,
1000 }));
1001
1002 done();
1003 },
1004 );
1005 });
1006 }
1007
1008 it('triggers `loaded` hook when not found', function(done) {
1009 TestModel.observe('loaded', ctxRecorder.recordAndNext());
1010
1011 TestModel.findOrCreate(
1012 {where: {name: 'new-record'}},
1013 {name: 'new-record'},
1014 function(err, record, created) {
1015 if (err) return done(err);
1016
1017 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1018 data: {
1019 id: record.id,
1020 name: 'new-record',
1021 },
1022 isNewInstance: true,
1023 }));
1024
1025 done();
1026 },
1027 );
1028 });
1029
1030 it('emits error when `loaded` hook fails', function(done) {
1031 TestModel.observe('loaded', nextWithError(expectedError));
1032 TestModel.findOrCreate(
1033 {where: {name: 'new-record'}},
1034 {name: 'new-record'},
1035 function(err, instance) {
1036 [err].should.eql([expectedError]);
1037 done();
1038 },
1039 );
1040 });
1041
1042 if (dataSource.connector.findOrCreate) {
1043 it('applies updates from `loaded` hook when found', function(done) {
1044 TestModel.observe('loaded', ctxRecorder.recordAndNext(function(ctx) {
1045 // It's crucial to change `ctx.data` reference, not only data props
1046 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
1047 }));
1048
1049 TestModel.findOrCreate(
1050 {where: {name: existingInstance.name}},
1051 {name: existingInstance.name},
1052 function(err, instance) {
1053 if (err) return done(err);
1054
1055 instance.should.have.property('extra', 'hook data');
1056
1057 done();
1058 },
1059 );
1060 });
1061 }
1062
1063 it('applies updates from `loaded` hook when not found', function(done) {
1064 TestModel.observe('loaded', ctxRecorder.recordAndNext(function(ctx) {
1065 // It's crucial to change `ctx.data` reference, not only data props
1066 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
1067 }));
1068
1069 // Unoptimized connector gives a call to `create. But,
1070 // by default, the instance passed to create callback is NOT updated
1071 // with the changes made through persist/loaded hooks. To preserve
1072 // backwards compatibility, we introduced a new setting updateOnLoad,
1073 // which if set, will apply these changes to the model instance too.
1074 // Note - in case of findOrCreate, this setting is needed ONLY for
1075 // unoptimized connector.
1076 TestModel.settings.updateOnLoad = true;
1077 TestModel.findOrCreate(
1078 {where: {name: 'new-record'}},
1079 {name: 'new-record'},
1080 function(err, instance) {
1081 if (err) return done(err);
1082
1083 instance.should.have.property('extra', 'hook data');
1084 done();
1085 },
1086 );
1087 });
1088
1089 it('triggers `after save` hook when not found', function(done) {
1090 TestModel.observe('after save', ctxRecorder.recordAndNext());
1091
1092 TestModel.findOrCreate(
1093 {where: {name: 'new name'}},
1094 {name: 'new name'},
1095 function(err, instance) {
1096 if (err) return done(err);
1097 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1098 instance: {
1099 id: instance.id,
1100 name: 'new name',
1101 extra: undefined,
1102 },
1103 isNewInstance: true,
1104 }));
1105 done();
1106 },
1107 );
1108 });
1109
1110 it('does not trigger `after save` hook when found', function(done) {
1111 TestModel.observe('after save', ctxRecorder.recordAndNext());
1112
1113 TestModel.findOrCreate(
1114 {where: {id: existingInstance.id}},
1115 {name: existingInstance.name},
1116 function(err, instance) {
1117 if (err) return done(err);
1118 ctxRecorder.records.should.eql('hook not called');
1119 done();
1120 },
1121 );
1122 });
1123 });
1124
1125 describe('PersistedModel.count', function(done) {
1126 it('triggers `access` hook', function(done) {
1127 TestModel.observe('access', ctxRecorder.recordAndNext());
1128
1129 TestModel.count({id: existingInstance.id}, function(err, count) {
1130 if (err) return done(err);
1131 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
1132 where: {id: existingInstance.id},
1133 }}));
1134 done();
1135 });
1136 });
1137
1138 it('applies updates from `access` hook', function(done) {
1139 TestModel.observe('access', function(ctx, next) {
1140 ctx.query.where = {id: existingInstance.id};
1141 next();
1142 });
1143
1144 TestModel.count(function(err, count) {
1145 if (err) return done(err);
1146 count.should.equal(1);
1147 done();
1148 });
1149 });
1150 });
1151
1152 describe('PersistedModel.prototype.save', function() {
1153 it('triggers hooks in the correct order', function(done) {
1154 monitorHookExecution();
1155
1156 existingInstance.save(
1157 function(err, record, created) {
1158 if (err) return done(err);
1159 hookMonitor.names.should.eql([
1160 'before save',
1161 'persist',
1162 'loaded',
1163 'after save',
1164 ]);
1165 done();
1166 },
1167 );
1168 });
1169
1170 it('triggers `before save` hook', function(done) {
1171 TestModel.observe('before save', ctxRecorder.recordAndNext());
1172
1173 existingInstance.name = 'changed';
1174 existingInstance.save(function(err, instance) {
1175 if (err) return done(err);
1176 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {instance: {
1177 id: existingInstance.id,
1178 name: 'changed',
1179 extra: undefined,
1180 }, options: {throws: false, validate: true}}));
1181 done();
1182 });
1183 });
1184
1185 it('aborts when `before save` hook fails', function(done) {
1186 TestModel.observe('before save', nextWithError(expectedError));
1187
1188 existingInstance.save(function(err, instance) {
1189 [err].should.eql([expectedError]);
1190 done();
1191 });
1192 });
1193
1194 it('applies updates from `before save` hook', function(done) {
1195 TestModel.observe('before save', function(ctx, next) {
1196 ctx.instance.should.be.instanceOf(TestModel);
1197 ctx.instance.extra = 'hook data';
1198 next();
1199 });
1200
1201 existingInstance.save(function(err, instance) {
1202 if (err) return done(err);
1203 instance.should.have.property('extra', 'hook data');
1204 done();
1205 });
1206 });
1207
1208 it('validates model after `before save` hook', function(done) {
1209 TestModel.observe('before save', invalidateTestModel());
1210
1211 existingInstance.save(function(err) {
1212 (err || {}).should.be.instanceOf(ValidationError);
1213 (err.details.codes || {}).should.eql({name: ['presence']});
1214 done();
1215 });
1216 });
1217
1218 it('triggers `persist` hook', function(done) {
1219 TestModel.observe('persist', ctxRecorder.recordAndNext());
1220
1221 existingInstance.name = 'changed';
1222 existingInstance.save(function(err, instance) {
1223 if (err) return done(err);
1224
1225 // HACK: extra is undefined for NoSQL and null for SQL
1226 delete ctxRecorder.records.data.extra;
1227 delete ctxRecorder.records.currentInstance.extra;
1228 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1229 data: {
1230 id: existingInstance.id,
1231 name: 'changed',
1232 },
1233 currentInstance: {
1234 id: existingInstance.id,
1235 name: 'changed',
1236 },
1237 where: {id: existingInstance.id},
1238 options: {throws: false, validate: true},
1239 }));
1240
1241 done();
1242 });
1243 });
1244
1245 it('applies updates from `persist` hook', function(done) {
1246 TestModel.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
1247 // It's crucial to change `ctx.data` reference, not only data props
1248 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
1249 }));
1250
1251 existingInstance.save(function(err, instance) {
1252 if (err) return done(err);
1253 instance.should.have.property('extra', 'hook data');
1254 done();
1255 });
1256 });
1257
1258 it('triggers `loaded` hook', function(done) {
1259 TestModel.observe('loaded', ctxRecorder.recordAndNext());
1260
1261 existingInstance.extra = 'changed';
1262 existingInstance.save(function(err, instance) {
1263 if (err) return done(err);
1264
1265 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1266 data: {
1267 id: existingInstance.id,
1268 name: existingInstance.name,
1269 extra: 'changed',
1270 },
1271 isNewInstance: isNewInstanceFlag ? false : undefined,
1272 options: {throws: false, validate: true},
1273 }));
1274
1275 done();
1276 });
1277 });
1278
1279 it('emits error when `loaded` hook fails', function(done) {
1280 TestModel.observe('loaded', nextWithError(expectedError));
1281 existingInstance.save(
1282 function(err, instance) {
1283 [err].should.eql([expectedError]);
1284 done();
1285 },
1286 );
1287 });
1288
1289 it('applies updates from `loaded` hook', function(done) {
1290 TestModel.observe('loaded', ctxRecorder.recordAndNext(function(ctx) {
1291 // It's crucial to change `ctx.data` reference, not only data props
1292 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
1293 }));
1294
1295 existingInstance.save(function(err, instance) {
1296 if (err) return done(err);
1297 instance.should.have.property('extra', 'hook data');
1298 done();
1299 });
1300 });
1301
1302 it('triggers `after save` hook on update', function(done) {
1303 TestModel.observe('after save', ctxRecorder.recordAndNext());
1304
1305 existingInstance.name = 'changed';
1306 existingInstance.save(function(err, instance) {
1307 if (err) return done(err);
1308 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1309 instance: {
1310 id: existingInstance.id,
1311 name: 'changed',
1312 extra: undefined,
1313 },
1314 isNewInstance: isNewInstanceFlag ? false : undefined,
1315 options: {throws: false, validate: true},
1316 }));
1317 done();
1318 });
1319 });
1320
1321 it('triggers `after save` hook on create', function(done) {
1322 TestModel.observe('after save', ctxRecorder.recordAndNext());
1323
1324 // The rationale behind passing { persisted: true } is to bypass the check
1325 // made by DAO to determine whether the instance should be saved via
1326 // PersistedModel.create and force it to call connector.save()
1327 const instance = new TestModel(
1328 {id: 'new-id', name: 'created'},
1329 {persisted: true},
1330 );
1331
1332 instance.save(function(err, instance) {
1333 if (err) return done(err);
1334 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1335 instance: {
1336 id: instance.id,
1337 name: 'created',
1338 extra: undefined,
1339 },
1340 isNewInstance: isNewInstanceFlag ? true : undefined,
1341 options: {throws: false, validate: true},
1342 }));
1343 done();
1344 });
1345 });
1346
1347 it('aborts when `after save` hook fails', function(done) {
1348 TestModel.observe('after save', nextWithError(expectedError));
1349
1350 existingInstance.save(function(err, instance) {
1351 [err].should.eql([expectedError]);
1352 done();
1353 });
1354 });
1355
1356 it('applies updates from `after save` hook', function(done) {
1357 TestModel.observe('after save', function(ctx, next) {
1358 ctx.instance.should.be.instanceOf(TestModel);
1359 ctx.instance.extra = 'hook data';
1360 next();
1361 });
1362
1363 existingInstance.save(function(err, instance) {
1364 if (err) return done(err);
1365 instance.should.have.property('extra', 'hook data');
1366 done();
1367 });
1368 });
1369 });
1370
1371 describe('PersistedModel.prototype.updateAttributes', function() {
1372 it('triggers hooks in the correct order', function(done) {
1373 monitorHookExecution();
1374
1375 existingInstance.updateAttributes(
1376 {name: 'changed'},
1377 function(err, record, created) {
1378 if (err) return done(err);
1379 hookMonitor.names.should.eql([
1380 'before save',
1381 'persist',
1382 'loaded',
1383 'after save',
1384 ]);
1385 done();
1386 },
1387 );
1388 });
1389
1390 it('triggers `before save` hook', function(done) {
1391 TestModel.observe('before save', ctxRecorder.recordAndNext());
1392
1393 const currentInstance = deepCloneToObject(existingInstance);
1394
1395 existingInstance.updateAttributes({name: 'changed'}, function(err) {
1396 if (err) return done(err);
1397 existingInstance.name.should.equal('changed');
1398 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1399 where: {id: existingInstance.id},
1400 data: {name: 'changed'},
1401 currentInstance: currentInstance,
1402 }));
1403 done();
1404 });
1405 });
1406
1407 it('aborts when `before save` hook fails', function(done) {
1408 TestModel.observe('before save', nextWithError(expectedError));
1409
1410 existingInstance.updateAttributes({name: 'updated'}, function(err) {
1411 [err].should.eql([expectedError]);
1412 done();
1413 });
1414 });
1415
1416 it('applies updates from `before save` hook', function(done) {
1417 TestModel.observe('before save', function(ctx, next) {
1418 // It's crucial to change `ctx.data` reference, not only data props
1419 ctx.data = Object.assign({}, ctx.data, {
1420 extra: 'extra data',
1421 name: 'hooked name',
1422 });
1423 next();
1424 });
1425
1426 existingInstance.updateAttributes({name: 'updated'}, function(err) {
1427 if (err) return done(err);
1428 // We must query the database here because `updateAttributes`
1429 // returns effectively `this`, not the data from the datasource
1430 TestModel.findById(existingInstance.id, function(err, instance) {
1431 if (err) return done(err);
1432 should.exists(instance);
1433 instance.toObject(true).should.eql({
1434 id: existingInstance.id,
1435 name: 'hooked name',
1436 extra: 'extra data',
1437 });
1438 done();
1439 });
1440 });
1441 });
1442
1443 it('validates model after `before save` hook', function(done) {
1444 TestModel.observe('before save', invalidateTestModel());
1445
1446 existingInstance.updateAttributes({name: 'updated'}, function(err) {
1447 (err || {}).should.be.instanceOf(ValidationError);
1448 (err.details.codes || {}).should.eql({name: ['presence']});
1449 done();
1450 });
1451 });
1452
1453 it('triggers `persist` hook', function(done) {
1454 TestModel.observe('persist', ctxRecorder.recordAndNext());
1455 existingInstance.updateAttributes({name: 'changed'}, function(err) {
1456 if (err) return done(err);
1457
1458 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1459 where: {id: existingInstance.id},
1460 data: {name: 'changed'},
1461 currentInstance: {
1462 id: existingInstance.id,
1463 name: 'changed',
1464 extra: null,
1465 },
1466 isNewInstance: false,
1467 }));
1468
1469 done();
1470 });
1471 });
1472
1473 it('applies updates from `persist` hook', function(done) {
1474 TestModel.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
1475 // It's crucial to change `ctx.data` reference, not only data props
1476 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
1477 }));
1478
1479 // By default, the instance passed to updateAttributes callback is NOT updated
1480 // with the changes made through persist/loaded hooks. To preserve
1481 // backwards compatibility, we introduced a new setting updateOnLoad,
1482 // which if set, will apply these changes to the model instance too.
1483 TestModel.settings.updateOnLoad = true;
1484 existingInstance.updateAttributes({name: 'changed'}, function(err, instance) {
1485 if (err) return done(err);
1486 instance.should.have.property('extra', 'hook data');
1487 TestModel.findById(existingInstance.id, (err, found) => {
1488 if (err) return done(err);
1489 found.should.have.property('extra', 'hook data');
1490 done();
1491 });
1492 });
1493 });
1494
1495 it('applies updates from `persist` hook - for nested model instance', function(done) {
1496 const Address = dataSource.createModel('NestedAddress', {
1497 id: {type: String, id: true, default: 1},
1498 city: {type: String, required: true},
1499 country: {type: String, required: true},
1500 });
1501
1502 const User = dataSource.createModel('UserWithAddress', {
1503 id: {type: String, id: true, default: uid.next},
1504 name: {type: String, required: true},
1505 address: {type: Address, required: false},
1506 extra: {type: String},
1507 });
1508
1509 dataSource.automigrate(['UserWithAddress', 'NestedAddress'], function(err) {
1510 if (err) return done(err);
1511 User.create({name: 'Joe'}, function(err, instance) {
1512 if (err) return done(err);
1513
1514 const existingUser = instance;
1515
1516 User.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
1517 should.exist(ctx.data.address);
1518 ctx.data.address.should.be.type('object');
1519 ctx.data.address.should.not.be.instanceOf(Address);
1520
1521 // It's crucial to change `ctx.data` reference, not only data props
1522 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
1523 }));
1524
1525 // By default, the instance passed to updateAttributes callback is NOT updated
1526 // with the changes made through persist/loaded hooks. To preserve
1527 // backwards compatibility, we introduced a new setting updateOnLoad,
1528 // which if set, will apply these changes to the model instance too.
1529 User.settings.updateOnLoad = true;
1530 existingUser.updateAttributes(
1531 {address: new Address({city: 'Springfield', country: 'USA'})},
1532 function(err, inst) {
1533 if (err) return done(err);
1534
1535 inst.should.have.property('extra', 'hook data');
1536
1537 User.findById(existingUser.id, function(err, dbInstance) {
1538 if (err) return done(err);
1539 dbInstance.toObject(true).should.eql({
1540 id: existingUser.id,
1541 name: existingUser.name,
1542 address: {id: '1', city: 'Springfield', country: 'USA'},
1543 extra: 'hook data',
1544 });
1545 done();
1546 });
1547 },
1548 );
1549 });
1550 });
1551 });
1552
1553 it('emits error when `persist` hook fails', function(done) {
1554 TestModel.observe('persist', nextWithError(expectedError));
1555
1556 TestModel.settings.updateOnLoad = true;
1557 existingInstance.updateAttributes({name: 'test'}, function(err, instance) {
1558 [err].should.eql([expectedError]);
1559 done();
1560 });
1561 });
1562
1563 it('triggers `loaded` hook', function(done) {
1564 TestModel.observe('loaded', ctxRecorder.recordAndNext());
1565 existingInstance.updateAttributes({name: 'changed'}, function(err) {
1566 if (err) return done(err);
1567
1568 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1569 data: {name: 'changed'},
1570 isNewInstance: false,
1571 }));
1572
1573 done();
1574 });
1575 });
1576
1577 it('emits error when `loaded` hook fails', function(done) {
1578 TestModel.observe('loaded', nextWithError(expectedError));
1579 existingInstance.updateAttributes(
1580 {name: 'changed'},
1581 function(err, instance) {
1582 [err].should.eql([expectedError]);
1583 done();
1584 },
1585 );
1586 });
1587
1588 it('applies updates from `loaded` hook updateAttributes', function(done) {
1589 TestModel.observe('loaded', ctxRecorder.recordAndNext(function(ctx) {
1590 // It's crucial to change `ctx.data` reference, not only data props
1591 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
1592 }));
1593
1594 // By default, the instance passed to updateAttributes callback is NOT updated
1595 // with the changes made through persist/loaded hooks. To preserve
1596 // backwards compatibility, we introduced a new setting updateOnLoad,
1597 // which if set, will apply these changes to the model instance too.
1598 TestModel.settings.updateOnLoad = true;
1599 existingInstance.updateAttributes({name: 'changed'}, function(err, instance) {
1600 if (err) return done(err);
1601 instance.should.have.property('extra', 'hook data');
1602 done();
1603 });
1604 });
1605
1606 it('triggers `after save` hook', function(done) {
1607 TestModel.observe('after save', ctxRecorder.recordAndNext());
1608
1609 existingInstance.name = 'changed';
1610 existingInstance.updateAttributes({name: 'changed'}, function(err) {
1611 if (err) return done(err);
1612 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1613 instance: {
1614 id: existingInstance.id,
1615 name: 'changed',
1616 extra: undefined,
1617 },
1618 isNewInstance: false,
1619 }));
1620 done();
1621 });
1622 });
1623
1624 it('aborts when `after save` hook fails', function(done) {
1625 TestModel.observe('after save', nextWithError(expectedError));
1626
1627 existingInstance.updateAttributes({name: 'updated'}, function(err) {
1628 [err].should.eql([expectedError]);
1629 done();
1630 });
1631 });
1632
1633 it('applies updates from `after save` hook', function(done) {
1634 TestModel.observe('after save', function(ctx, next) {
1635 ctx.instance.should.be.instanceOf(TestModel);
1636 ctx.instance.extra = 'hook data';
1637 next();
1638 });
1639
1640 existingInstance.updateAttributes({name: 'updated'}, function(err, instance) {
1641 if (err) return done(err);
1642 instance.should.have.property('extra', 'hook data');
1643 done();
1644 });
1645 });
1646 });
1647
1648 if (!dataSource.connector.replaceById) {
1649 describe.skip('replaceAttributes - not implemented', function() {});
1650 } else {
1651 describe('PersistedModel.prototype.replaceAttributes', function() {
1652 it('triggers hooks in the correct order', function(done) {
1653 monitorHookExecution();
1654
1655 existingInstance.replaceAttributes(
1656 {name: 'replaced'},
1657 function(err, record, created) {
1658 if (err) return done(err);
1659 hookMonitor.names.should.eql([
1660 'before save',
1661 'persist',
1662 'loaded',
1663 'after save',
1664 ]);
1665 done();
1666 },
1667 );
1668 });
1669
1670 it('triggers `before save` hook', function(done) {
1671 TestModel.observe('before save', ctxRecorder.recordAndNext());
1672
1673 existingInstance.replaceAttributes({name: 'changed'}, function(err) {
1674 if (err) return done(err);
1675 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1676 instance: {
1677 id: existingInstance.id,
1678 name: 'changed',
1679 extra: undefined,
1680 },
1681 isNewInstance: false,
1682 }));
1683 done();
1684 });
1685 });
1686
1687 it('aborts when `before save` hook fails', function(done) {
1688 TestModel.observe('before save', nextWithError(expectedError));
1689
1690 existingInstance.replaceAttributes({name: 'replaced'}, function(err) {
1691 [err].should.eql([expectedError]);
1692 done();
1693 });
1694 });
1695
1696 it('applies updates from `before save` hook', function(done) {
1697 TestModel.observe('before save', function(ctx, next) {
1698 ctx.instance.extra = 'extra data';
1699 ctx.instance.name = 'hooked name';
1700 next();
1701 });
1702
1703 existingInstance.replaceAttributes({name: 'updated'}, function(err) {
1704 if (err) return done(err);
1705 TestModel.findById(existingInstance.id, function(err, instance) {
1706 if (err) return done(err);
1707 should.exists(instance);
1708 instance.toObject(true).should.eql({
1709 id: existingInstance.id,
1710 name: 'hooked name',
1711 extra: 'extra data',
1712 });
1713 done();
1714 });
1715 });
1716 });
1717
1718 it('validates model after `before save` hook', function(done) {
1719 TestModel.observe('before save', invalidateTestModel());
1720
1721 existingInstance.replaceAttributes({name: 'updated'}, function(err) {
1722 (err || {}).should.be.instanceOf(ValidationError);
1723 (err.details.codes || {}).should.eql({name: ['presence']});
1724 done();
1725 });
1726 });
1727
1728 it('triggers `persist` hook', function(done) {
1729 TestModel.observe('persist', ctxRecorder.recordAndNext());
1730 existingInstance.replaceAttributes({name: 'replacedName'}, function(err) {
1731 if (err) return done(err);
1732
1733 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1734 where: {id: existingInstance.id},
1735 data: {
1736 name: 'replacedName',
1737 id: existingInstance.id,
1738 },
1739 currentInstance: {
1740 id: existingInstance.id,
1741 name: 'replacedName',
1742 extra: null,
1743 },
1744 isNewInstance: false,
1745 }));
1746
1747 done();
1748 });
1749 });
1750
1751 it('applies delete from `persist` hook', function(done) {
1752 TestModel.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
1753 delete ctx.data.extra;
1754 }));
1755
1756 existingInstance.replaceAttributes({name: 'changed'}, function(err, instance) {
1757 if (err) return done(err);
1758 instance.should.not.have.property('extra', 'hook data');
1759 done();
1760 });
1761 });
1762
1763 it('applies updates from `persist` hook - for nested model instance', function(done) {
1764 const Address = dataSource.createModel('NestedAddress', {
1765 id: {type: String, id: true, default: 1},
1766 city: {type: String, required: true},
1767 country: {type: String, required: true},
1768 });
1769
1770 const User = dataSource.createModel('UserWithAddress', {
1771 id: {type: String, id: true, default: uid.next},
1772 name: {type: String, required: true},
1773 address: {type: Address, required: false},
1774 extra: {type: String},
1775 });
1776
1777 dataSource.automigrate(['UserWithAddress', 'NestedAddress'], function(err) {
1778 if (err) return done(err);
1779 User.create({name: 'Joe'}, function(err, instance) {
1780 if (err) return done(err);
1781
1782 const existingUser = instance;
1783
1784 User.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
1785 should.exist(ctx.data.address);
1786 ctx.data.address.should.be.type('object');
1787 ctx.data.address.should.not.be.instanceOf(Address);
1788
1789 // It's crucial to change `ctx.data` reference, not only data props
1790 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
1791 }));
1792
1793 existingUser.replaceAttributes(
1794 {name: 'John', address: new Address({city: 'Springfield', country: 'USA'})},
1795 function(err, inst) {
1796 if (err) return done(err);
1797
1798 inst.should.have.property('extra', 'hook data');
1799
1800 User.findById(existingUser.id, function(err, dbInstance) {
1801 if (err) return done(err);
1802 dbInstance.toObject(true).should.eql({
1803 id: existingUser.id,
1804 name: 'John',
1805 address: {id: '1', city: 'Springfield', country: 'USA'},
1806 extra: 'hook data',
1807 });
1808 done();
1809 });
1810 },
1811 );
1812 });
1813 });
1814 });
1815
1816 it('triggers `loaded` hook', function(done) {
1817 TestModel.observe('loaded', ctxRecorder.recordAndNext());
1818 existingInstance.replaceAttributes({name: 'changed'}, function(err, data) {
1819 if (err) return done(err);
1820
1821 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1822 data: {
1823 name: 'changed',
1824 id: data.id,
1825 },
1826 isNewInstance: false,
1827 }));
1828 done();
1829 });
1830 });
1831
1832 it('emits error when `loaded` hook fails', function(done) {
1833 TestModel.observe('loaded', nextWithError(expectedError));
1834 existingInstance.replaceAttributes(
1835 {name: 'replaced'},
1836 function(err, instance) {
1837 [err].should.eql([expectedError]);
1838 done();
1839 },
1840 );
1841 });
1842
1843 it('applies updates from `loaded` hook replaceAttributes', function(done) {
1844 TestModel.observe('loaded', ctxRecorder.recordAndNext(function(ctx) {
1845 // It's crucial to change `ctx.data` reference, not only data props
1846 ctx.data = Object.assign({}, ctx.data, {name: 'changed in hook'});
1847 }));
1848
1849 existingInstance.replaceAttributes({name: 'changed'}, function(err, instance) {
1850 if (err) return done(err);
1851 instance.should.have.property('name', 'changed in hook');
1852 done();
1853 });
1854 });
1855
1856 it('triggers `after save` hook', function(done) {
1857 TestModel.observe('after save', ctxRecorder.recordAndNext());
1858
1859 existingInstance.name = 'replaced';
1860 existingInstance.replaceAttributes({name: 'replaced'}, function(err) {
1861 if (err) return done(err);
1862 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
1863 instance: {
1864 id: existingInstance.id,
1865 name: 'replaced',
1866 extra: undefined,
1867 },
1868 isNewInstance: false,
1869 }));
1870 done();
1871 });
1872 });
1873
1874 it('aborts when `after save` hook fails', function(done) {
1875 TestModel.observe('after save', nextWithError(expectedError));
1876
1877 existingInstance.replaceAttributes({name: 'replaced'}, function(err) {
1878 [err].should.eql([expectedError]);
1879 done();
1880 });
1881 });
1882
1883 it('applies updates from `after save` hook', function(done) {
1884 TestModel.observe('after save', function(ctx, next) {
1885 ctx.instance.should.be.instanceOf(TestModel);
1886 ctx.instance.extra = 'hook data';
1887 next();
1888 });
1889
1890 existingInstance.replaceAttributes({name: 'updated'}, function(err, instance) {
1891 if (err) return done(err);
1892 instance.should.have.property('extra', 'hook data');
1893 done();
1894 });
1895 });
1896 });
1897 }
1898
1899 describe('PersistedModel.updateOrCreate', function() {
1900 it('triggers hooks in the correct order on create', function(done) {
1901 monitorHookExecution();
1902
1903 TestModel.updateOrCreate(
1904 {id: 'not-found', name: 'not found'},
1905 function(err, record, created) {
1906 if (err) return done(err);
1907 hookMonitor.names.should.eql([
1908 'access',
1909 'before save',
1910 'persist',
1911 'loaded',
1912 'after save',
1913 ]);
1914 done();
1915 },
1916 );
1917 });
1918
1919 it('triggers hooks in the correct order on update', function(done) {
1920 monitorHookExecution();
1921
1922 TestModel.updateOrCreate(
1923 {id: existingInstance.id, name: 'new name'},
1924 function(err, record, created) {
1925 if (err) return done(err);
1926 hookMonitor.names.should.eql([
1927 'access',
1928 'before save',
1929 'persist',
1930 'loaded',
1931 'after save',
1932 ]);
1933 done();
1934 },
1935 );
1936 });
1937
1938 it('triggers `access` hook on create', function(done) {
1939 TestModel.observe('access', ctxRecorder.recordAndNext());
1940
1941 TestModel.updateOrCreate(
1942 {id: 'not-found', name: 'not found'},
1943 function(err, instance) {
1944 if (err) return done(err);
1945 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
1946 where: {id: 'not-found'},
1947 }}));
1948 done();
1949 },
1950 );
1951 });
1952
1953 it('triggers `access` hook on update', function(done) {
1954 TestModel.observe('access', ctxRecorder.recordAndNext());
1955
1956 TestModel.updateOrCreate(
1957 {id: existingInstance.id, name: 'new name'},
1958 function(err, instance) {
1959 if (err) return done(err);
1960 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
1961 where: {id: existingInstance.id},
1962 }}));
1963 done();
1964 },
1965 );
1966 });
1967
1968 it('does not trigger `access` on missing id', function(done) {
1969 TestModel.observe('access', ctxRecorder.recordAndNext());
1970
1971 TestModel.updateOrCreate(
1972 {name: 'new name'},
1973 function(err, instance) {
1974 if (err) return done(err);
1975 ctxRecorder.records.should.equal('hook not called');
1976 done();
1977 },
1978 );
1979 });
1980
1981 it('applies updates from `access` hook when found', function(done) {
1982 TestModel.observe('access', function(ctx, next) {
1983 ctx.query = {where: {id: {neq: existingInstance.id}}};
1984 next();
1985 });
1986
1987 TestModel.updateOrCreate(
1988 {id: existingInstance.id, name: 'new name'},
1989 function(err, instance) {
1990 if (err) return done(err);
1991 findTestModels({fields: ['id', 'name']}, function(err, list) {
1992 if (err) return done(err);
1993 (list || []).map(toObject).should.eql([
1994 {id: existingInstance.id, name: existingInstance.name, extra: undefined},
1995 {id: instance.id, name: 'new name', extra: undefined},
1996 ]);
1997 done();
1998 });
1999 },
2000 );
2001 });
2002
2003 it('applies updates from `access` hook when not found', function(done) {
2004 TestModel.observe('access', function(ctx, next) {
2005 ctx.query = {where: {id: 'not-found'}};
2006 next();
2007 });
2008
2009 TestModel.updateOrCreate(
2010 {id: existingInstance.id, name: 'new name'},
2011 function(err, instance) {
2012 if (err) return done(err);
2013 findTestModels({fields: ['id', 'name']}, function(err, list) {
2014 if (err) return done(err);
2015 (list || []).map(toObject).should.eql([
2016 {id: existingInstance.id, name: existingInstance.name, extra: undefined},
2017 {id: list[1].id, name: 'second', extra: undefined},
2018 {id: instance.id, name: 'new name', extra: undefined},
2019 ]);
2020 done();
2021 });
2022 },
2023 );
2024 });
2025
2026 it('triggers hooks only once', function(done) {
2027 monitorHookExecution(['access', 'before save']);
2028
2029 TestModel.observe('access', function(ctx, next) {
2030 ctx.query = {where: {id: {neq: existingInstance.id}}};
2031 next();
2032 });
2033
2034 TestModel.updateOrCreate(
2035 {id: 'ignored', name: 'new name'},
2036 function(err, instance) {
2037 if (err) return done(err);
2038 hookMonitor.names.should.eql(['access', 'before save']);
2039 done();
2040 },
2041 );
2042 });
2043
2044 it('triggers `before save` hook on update', function(done) {
2045 TestModel.observe('before save', ctxRecorder.recordAndNext());
2046
2047 TestModel.updateOrCreate(
2048 {id: existingInstance.id, name: 'updated name'},
2049 function(err, instance) {
2050 if (err) return done(err);
2051 if (dataSource.connector.updateOrCreate) {
2052 // Atomic implementations of `updateOrCreate` cannot
2053 // provide full instance as that depends on whether
2054 // UPDATE or CREATE will be triggered
2055 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2056 where: {id: existingInstance.id},
2057 data: {id: existingInstance.id, name: 'updated name'},
2058 }));
2059 } else {
2060 // currentInstance is set, because a non-atomic `updateOrCreate`
2061 // will use `prototype.updateAttributes` internally, which
2062 // exposes this to the context
2063 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2064 where: {id: existingInstance.id},
2065 data: {id: existingInstance.id, name: 'updated name'},
2066 currentInstance: existingInstance,
2067 }));
2068 }
2069 done();
2070 },
2071 );
2072 });
2073
2074 it('triggers `before save` hook on create', function(done) {
2075 TestModel.observe('before save', ctxRecorder.recordAndNext());
2076
2077 TestModel.updateOrCreate(
2078 {id: 'new-id', name: 'a name'},
2079 function(err, instance) {
2080 if (err) return done(err);
2081
2082 if (dataSource.connector.updateOrCreate) {
2083 // Atomic implementations of `updateOrCreate` cannot
2084 // provide full instance as that depends on whether
2085 // UPDATE or CREATE will be triggered
2086 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2087 where: {id: 'new-id'},
2088 data: {id: 'new-id', name: 'a name'},
2089 }));
2090 } else {
2091 // The default unoptimized implementation runs
2092 // `instance.save` and thus a full instance is availalbe
2093 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2094 instance: {id: 'new-id', name: 'a name', extra: undefined},
2095 isNewInstance: true,
2096 }));
2097 }
2098
2099 done();
2100 },
2101 );
2102 });
2103
2104 it('applies updates from `before save` hook on update', function(done) {
2105 TestModel.observe('before save', function(ctx, next) {
2106 // It's crucial to change `ctx.data` reference, not only data props
2107 ctx.data = Object.assign({}, ctx.data, {name: 'hooked'});
2108 next();
2109 });
2110
2111 TestModel.updateOrCreate(
2112 {id: existingInstance.id, name: 'updated name'},
2113 function(err, instance) {
2114 if (err) return done(err);
2115 instance.name.should.equal('hooked');
2116 done();
2117 },
2118 );
2119 });
2120
2121 it('applies updates from `before save` hook on create', function(done) {
2122 TestModel.observe('before save', function(ctx, next) {
2123 if (ctx.instance) {
2124 ctx.instance.name = 'hooked';
2125 } else {
2126 // It's crucial to change `ctx.data` reference, not only data props
2127 ctx.data = Object.assign({}, ctx.data, {name: 'hooked'});
2128 }
2129 next();
2130 });
2131
2132 TestModel.updateOrCreate(
2133 {id: 'new-id', name: 'new name'},
2134 function(err, instance) {
2135 if (err) return done(err);
2136 instance.name.should.equal('hooked');
2137 done();
2138 },
2139 );
2140 });
2141
2142 // FIXME(bajtos) this fails with connector-specific updateOrCreate
2143 // implementations, see the comment inside lib/dao.js (updateOrCreate)
2144 it.skip('validates model after `before save` hook on update', function(done) {
2145 TestModel.observe('before save', invalidateTestModel());
2146
2147 TestModel.updateOrCreate(
2148 {id: existingInstance.id, name: 'updated name'},
2149 function(err, instance) {
2150 (err || {}).should.be.instanceOf(ValidationError);
2151 (err.details.codes || {}).should.eql({name: ['presence']});
2152 done();
2153 },
2154 );
2155 });
2156
2157 // FIXME(bajtos) this fails with connector-specific updateOrCreate
2158 // implementations, see the comment inside lib/dao.js (updateOrCreate)
2159 it.skip('validates model after `before save` hook on create', function(done) {
2160 TestModel.observe('before save', invalidateTestModel());
2161
2162 TestModel.updateOrCreate(
2163 {id: 'new-id', name: 'new name'},
2164 function(err, instance) {
2165 (err || {}).should.be.instanceOf(ValidationError);
2166 (err.details.codes || {}).should.eql({name: ['presence']});
2167 done();
2168 },
2169 );
2170 });
2171
2172 it('triggers `persist` hook on create', function(done) {
2173 TestModel.observe('persist', ctxRecorder.recordAndNext());
2174
2175 TestModel.updateOrCreate(
2176 {id: 'new-id', name: 'a name'},
2177 function(err, instance) {
2178 if (err) return done(err);
2179
2180 if (dataSource.connector.updateOrCreate) {
2181 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2182 where: {id: 'new-id'},
2183 data: {id: 'new-id', name: 'a name'},
2184 currentInstance: {
2185 id: 'new-id',
2186 name: 'a name',
2187 extra: undefined,
2188 },
2189 }));
2190 } else {
2191 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2192 data: {
2193 id: 'new-id',
2194 name: 'a name',
2195 },
2196 isNewInstance: true,
2197 currentInstance: {
2198 id: 'new-id',
2199 name: 'a name',
2200 extra: undefined,
2201 },
2202 }));
2203 }
2204 done();
2205 },
2206 );
2207 });
2208
2209 it('triggers `persist` hook on update', function(done) {
2210 TestModel.observe('persist', ctxRecorder.recordAndNext());
2211
2212 TestModel.updateOrCreate(
2213 {id: existingInstance.id, name: 'updated name'},
2214 function(err, instance) {
2215 if (err) return done(err);
2216
2217 const expectedContext = aCtxForModel(TestModel, {
2218 where: {id: existingInstance.id},
2219 data: {
2220 id: existingInstance.id,
2221 name: 'updated name',
2222 },
2223 currentInstance: {
2224 id: existingInstance.id,
2225 name: 'updated name',
2226 extra: undefined,
2227 },
2228 });
2229
2230 if (!dataSource.connector.updateOrCreate) {
2231 // When the connector does not provide updateOrCreate,
2232 // DAO falls back to updateAttributes which sets this flag
2233 expectedContext.isNewInstance = false;
2234 }
2235
2236 ctxRecorder.records.should.eql(expectedContext);
2237 done();
2238 },
2239 );
2240 });
2241
2242 it('triggers `loaded` hook on create', function(done) {
2243 TestModel.observe('loaded', ctxRecorder.recordAndNext());
2244
2245 TestModel.updateOrCreate(
2246 {id: 'new-id', name: 'a name'},
2247 function(err, instance) {
2248 if (err) return done(err);
2249
2250 if (dataSource.connector.updateOrCreate) {
2251 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2252 data: {id: 'new-id', name: 'a name'},
2253 isNewInstance: isNewInstanceFlag ? true : undefined,
2254 }));
2255 } else {
2256 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2257 data: {
2258 id: 'new-id',
2259 name: 'a name',
2260 },
2261 isNewInstance: true,
2262 }));
2263 }
2264 done();
2265 },
2266 );
2267 });
2268
2269 it('triggers `loaded` hook on update', function(done) {
2270 TestModel.observe('loaded', ctxRecorder.recordAndNext());
2271
2272 TestModel.updateOrCreate(
2273 {id: existingInstance.id, name: 'updated name'},
2274 function(err, instance) {
2275 if (err) return done(err);
2276 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2277 data: {
2278 id: existingInstance.id,
2279 name: 'updated name',
2280 },
2281 isNewInstance: isNewInstanceFlag ? false : undefined,
2282 }));
2283 done();
2284 },
2285 );
2286 });
2287
2288 it('emits error when `loaded` hook fails', function(done) {
2289 TestModel.observe('loaded', nextWithError(expectedError));
2290 TestModel.updateOrCreate(
2291 {id: 'new-id', name: 'a name'},
2292 function(err, instance) {
2293 [err].should.eql([expectedError]);
2294 done();
2295 },
2296 );
2297 });
2298
2299 it('triggers `after save` hook on update', function(done) {
2300 TestModel.observe('after save', ctxRecorder.recordAndNext());
2301
2302 TestModel.updateOrCreate(
2303 {id: existingInstance.id, name: 'updated name'},
2304 function(err, instance) {
2305 if (err) return done(err);
2306 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2307 instance: {
2308 id: existingInstance.id,
2309 name: 'updated name',
2310 extra: undefined,
2311 },
2312 isNewInstance: isNewInstanceFlag ? false : undefined,
2313 }));
2314 done();
2315 },
2316 );
2317 });
2318
2319 it('aborts when `after save` fires on update or create when option to notify is false', function(done) {
2320 monitorHookExecution();
2321
2322 TestModel.updateOrCreate({name: 'created'}, {notify: false}, function(err, record, created) {
2323 if (err) return done(err);
2324
2325 hookMonitor.names.should.not.containEql('after save');
2326 done();
2327 });
2328 });
2329
2330 it('triggers `after save` hook on create', function(done) {
2331 TestModel.observe('after save', ctxRecorder.recordAndNext());
2332
2333 TestModel.updateOrCreate(
2334 {id: 'new-id', name: 'a name'},
2335 function(err, instance) {
2336 if (err) return done(err);
2337 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2338 instance: {
2339 id: instance.id,
2340 name: 'a name',
2341 extra: undefined,
2342 },
2343 isNewInstance: isNewInstanceFlag ? true : undefined,
2344 }));
2345 done();
2346 },
2347 );
2348 });
2349 });
2350
2351 if (!dataSource.connector.replaceById) {
2352 describe.skip('replaceOrCreate - not implemented', function() {});
2353 } else {
2354 describe('PersistedModel.replaceOrCreate', function() {
2355 it('triggers hooks in the correct order on create', function(done) {
2356 monitorHookExecution();
2357
2358 TestModel.replaceOrCreate(
2359 {id: 'not-found', name: 'not found'},
2360 function(err, record, created) {
2361 if (err) return done(err);
2362 hookMonitor.names.should.eql([
2363 'access',
2364 'before save',
2365 'persist',
2366 'loaded',
2367 'after save',
2368 ]);
2369 done();
2370 },
2371 );
2372 });
2373
2374 it('triggers hooks in the correct order on replace', function(done) {
2375 monitorHookExecution();
2376
2377 TestModel.replaceOrCreate(
2378 {id: existingInstance.id, name: 'new name'},
2379 function(err, record, created) {
2380 if (err) return done(err);
2381 hookMonitor.names.should.eql([
2382 'access',
2383 'before save',
2384 'persist',
2385 'loaded',
2386 'after save',
2387 ]);
2388 done();
2389 },
2390 );
2391 });
2392
2393 it('triggers `access` hook on create', function(done) {
2394 TestModel.observe('access', ctxRecorder.recordAndNext());
2395
2396 TestModel.replaceOrCreate(
2397 {id: 'not-found', name: 'not found'},
2398 function(err, instance) {
2399 if (err) return done(err);
2400 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
2401 where: {id: 'not-found'},
2402 }}));
2403 done();
2404 },
2405 );
2406 });
2407
2408 it('triggers `access` hook on replace', function(done) {
2409 TestModel.observe('access', ctxRecorder.recordAndNext());
2410
2411 TestModel.replaceOrCreate(
2412 {id: existingInstance.id, name: 'new name'},
2413 function(err, instance) {
2414 if (err) return done(err);
2415 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
2416 where: {id: existingInstance.id},
2417 }}));
2418 done();
2419 },
2420 );
2421 });
2422
2423 it('does not trigger `access` on missing id', function(done) {
2424 TestModel.observe('access', ctxRecorder.recordAndNext());
2425
2426 TestModel.replaceOrCreate(
2427 {name: 'new name'},
2428 function(err, instance) {
2429 if (err) return done(err);
2430 ctxRecorder.records.should.equal('hook not called');
2431 done();
2432 },
2433 );
2434 });
2435
2436 it('applies updates from `access` hook when found', function(done) {
2437 TestModel.observe('access', function(ctx, next) {
2438 ctx.query = {where: {id: {neq: existingInstance.id}}};
2439 next();
2440 });
2441
2442 TestModel.replaceOrCreate(
2443 {id: existingInstance.id, name: 'new name'},
2444 function(err, instance) {
2445 if (err) return done(err);
2446 findTestModels({fields: ['id', 'name']}, function(err, list) {
2447 if (err) return done(err);
2448 (list || []).map(toObject).should.eql([
2449 {id: existingInstance.id, name: existingInstance.name, extra: undefined},
2450 {id: instance.id, name: 'new name', extra: undefined},
2451 ]);
2452 done();
2453 });
2454 },
2455 );
2456 });
2457
2458 it('applies updates from `access` hook when not found', function(done) {
2459 TestModel.observe('access', function(ctx, next) {
2460 ctx.query = {where: {id: 'not-found'}};
2461 next();
2462 });
2463
2464 TestModel.replaceOrCreate(
2465 {id: existingInstance.id, name: 'new name'},
2466 function(err, instance) {
2467 if (err) return done(err);
2468 findTestModels({fields: ['id', 'name']}, function(err, list) {
2469 if (err) return done(err);
2470 (list || []).map(toObject).should.eql([
2471 {id: existingInstance.id, name: existingInstance.name, extra: undefined},
2472 {id: list[1].id, name: 'second', extra: undefined},
2473 {id: instance.id, name: 'new name', extra: undefined},
2474 ]);
2475 done();
2476 });
2477 },
2478 );
2479 });
2480
2481 it('triggers hooks only once', function(done) {
2482 monitorHookExecution(['access', 'before save']);
2483
2484 TestModel.observe('access', function(ctx, next) {
2485 ctx.query = {where: {id: {neq: existingInstance.id}}};
2486 next();
2487 });
2488
2489 TestModel.replaceOrCreate(
2490 {id: 'ignored', name: 'new name'},
2491 function(err, instance) {
2492 if (err) return done(err);
2493 hookMonitor.names.should.eql(['access', 'before save']);
2494 done();
2495 },
2496 );
2497 });
2498
2499 it('triggers `before save` hookon create', function(done) {
2500 TestModel.observe('before save', ctxRecorder.recordAndNext());
2501 TestModel.replaceOrCreate({id: existingInstance.id, name: 'new name'},
2502 function(err, instance) {
2503 if (err)
2504 return done(err);
2505
2506 const expectedContext = aCtxForModel(TestModel, {
2507 instance: instance,
2508 });
2509
2510 if (!dataSource.connector.replaceOrCreate) {
2511 expectedContext.isNewInstance = false;
2512 }
2513 done();
2514 });
2515 });
2516
2517 it('triggers `before save` hook on replace', function(done) {
2518 TestModel.observe('before save', ctxRecorder.recordAndNext());
2519 TestModel.replaceOrCreate(
2520 {id: existingInstance.id, name: 'replaced name'},
2521 function(err, instance) {
2522 if (err) return done(err);
2523
2524 const expectedContext = aCtxForModel(TestModel, {
2525 instance: {
2526 id: existingInstance.id,
2527 name: 'replaced name',
2528 extra: undefined,
2529 },
2530 });
2531
2532 if (!dataSource.connector.replaceOrCreate) {
2533 expectedContext.isNewInstance = false;
2534 }
2535 ctxRecorder.records.should.eql(expectedContext);
2536
2537 done();
2538 },
2539 );
2540 });
2541
2542 it('triggers `before save` hook on create', function(done) {
2543 TestModel.observe('before save', ctxRecorder.recordAndNext());
2544
2545 TestModel.replaceOrCreate(
2546 {id: 'new-id', name: 'a name'},
2547 function(err, instance) {
2548 if (err) return done(err);
2549
2550 const expectedContext = aCtxForModel(TestModel, {
2551 instance: {
2552 id: 'new-id',
2553 name: 'a name',
2554 extra: undefined,
2555 },
2556 });
2557
2558 if (!dataSource.connector.replaceOrCreate) {
2559 expectedContext.isNewInstance = true;
2560 }
2561 ctxRecorder.records.should.eql(expectedContext);
2562
2563 done();
2564 },
2565 );
2566 });
2567
2568 it('applies updates from `before save` hook on create', function(done) {
2569 TestModel.observe('before save', function(ctx, next) {
2570 ctx.instance.name = 'hooked';
2571 next();
2572 });
2573
2574 TestModel.replaceOrCreate(
2575 {id: 'new-id', name: 'new name'},
2576 function(err, instance) {
2577 if (err) return done(err);
2578 instance.name.should.equal('hooked');
2579 done();
2580 },
2581 );
2582 });
2583
2584 it('validates model after `before save` hook on create', function(done) {
2585 TestModel.observe('before save', invalidateTestModel());
2586
2587 TestModel.replaceOrCreate(
2588 {id: 'new-id', name: 'new name'},
2589 function(err, instance) {
2590 (err || {}).should.be.instanceOf(ValidationError);
2591 (err.details.codes || {}).should.eql({name: ['presence']});
2592 done();
2593 },
2594 );
2595 });
2596
2597 it('triggers `persist` hook on create', function(done) {
2598 TestModel.observe('persist', ctxRecorder.recordAndNext());
2599
2600 TestModel.replaceOrCreate(
2601 {id: 'new-id', name: 'a name'},
2602 function(err, instance) {
2603 if (err) return done(err);
2604
2605 const expectedContext = aCtxForModel(TestModel, {
2606 currentInstance: {
2607 id: 'new-id',
2608 name: 'a name',
2609 extra: undefined,
2610 },
2611 data: {
2612 id: 'new-id',
2613 name: 'a name',
2614 },
2615 });
2616
2617 if (dataSource.connector.replaceOrCreate) {
2618 expectedContext.where = {id: 'new-id'};
2619 } else {
2620 // non-atomic implementation does not provide ctx.where
2621 // because a new instance is being created, so there
2622 // are not records to match where filter.
2623 expectedContext.isNewInstance = true;
2624 }
2625 ctxRecorder.records.should.eql(expectedContext);
2626 done();
2627 },
2628 );
2629 });
2630
2631 it('triggers `persist` hook on replace', function(done) {
2632 TestModel.observe('persist', ctxRecorder.recordAndNext());
2633
2634 TestModel.replaceOrCreate(
2635 {id: existingInstance.id, name: 'replaced name'},
2636 function(err, instance) {
2637 if (err) return done(err);
2638
2639 const expected = {
2640 where: {id: existingInstance.id},
2641 data: {
2642 id: existingInstance.id,
2643 name: 'replaced name',
2644 },
2645 currentInstance: {
2646 id: existingInstance.id,
2647 name: 'replaced name',
2648 extra: undefined,
2649 },
2650 };
2651
2652 const expectedContext = aCtxForModel(TestModel, expected);
2653
2654 if (!dataSource.connector.replaceOrCreate) {
2655 expectedContext.isNewInstance = false;
2656 }
2657
2658 ctxRecorder.records.should.eql(expectedContext);
2659 done();
2660 },
2661 );
2662 });
2663
2664 it('applies updates from `persist` hook on create', function(done) {
2665 TestModel.observe('persist', (ctx, next) => {
2666 // it's crucial to change `ctx.data` reference, not only data props
2667 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
2668 next();
2669 });
2670
2671 // By default, the instance passed to create callback is NOT updated
2672 // with the changes made through persist/loaded hooks. To preserve
2673 // backwards compatibility, we introduced a new setting updateOnLoad,
2674 // which if set, will apply these changes to the model instance too.
2675 TestModel.settings.updateOnLoad = true;
2676
2677 TestModel.replaceOrCreate(
2678 {name: 'a name'},
2679 function(err, instance) {
2680 if (err) return done(err);
2681 instance.should.have.property('extra', 'hook data');
2682 TestModel.findById(instance.id, (err, found) => {
2683 if (err) return done(err);
2684 found.should.have.property('extra', 'hook data');
2685 done();
2686 });
2687 },
2688 );
2689 });
2690
2691 it('applies updates from `persist` hook on update', function(done) {
2692 TestModel.observe('persist', (ctx, next) => {
2693 // It's crucial to change `ctx.data` reference, not only data props
2694 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
2695 next();
2696 });
2697
2698 existingInstance.name = 'changed';
2699 const data = existingInstance.toObject();
2700
2701 TestModel.replaceOrCreate(data, function(err, instance) {
2702 if (err) return done(err);
2703 instance.should.have.property('extra', 'hook data');
2704 TestModel.findById(existingInstance.id, (err, found) => {
2705 if (err) return done(err);
2706 found.should.have.property('extra', 'hook data');
2707 done();
2708 });
2709 });
2710 });
2711
2712 it('triggers `loaded` hook on create', function(done) {
2713 TestModel.observe('loaded', ctxRecorder.recordAndNext());
2714
2715 TestModel.replaceOrCreate(
2716 {id: 'new-id', name: 'a name'},
2717 function(err, instance) {
2718 if (err) return done(err);
2719
2720 const expected = {
2721 data: {
2722 id: 'new-id',
2723 name: 'a name',
2724 },
2725 };
2726
2727 expected.isNewInstance =
2728 isNewInstanceFlag ?
2729 true : undefined;
2730
2731 ctxRecorder.records.should.eql(aCtxForModel(TestModel, expected));
2732 done();
2733 },
2734 );
2735 });
2736
2737 it('triggers `loaded` hook on replace', function(done) {
2738 TestModel.observe('loaded', ctxRecorder.recordAndNext());
2739
2740 TestModel.replaceOrCreate(
2741 {id: existingInstance.id, name: 'replaced name'},
2742 function(err, instance) {
2743 if (err) return done(err);
2744
2745 const expected = {
2746 data: {
2747 id: existingInstance.id,
2748 name: 'replaced name',
2749 },
2750 };
2751
2752 expected.isNewInstance =
2753 isNewInstanceFlag ?
2754 false : undefined;
2755
2756 ctxRecorder.records.should.eql(aCtxForModel(TestModel, expected));
2757 done();
2758 },
2759 );
2760 });
2761
2762 it('emits error when `loaded` hook fails', function(done) {
2763 TestModel.observe('loaded', nextWithError(expectedError));
2764 TestModel.replaceOrCreate(
2765 {id: 'new-id', name: 'a name'},
2766 function(err, instance) {
2767 [err].should.eql([expectedError]);
2768 done();
2769 },
2770 );
2771 });
2772
2773 it('triggers `after save` hook on replace', function(done) {
2774 TestModel.observe('after save', ctxRecorder.recordAndNext());
2775
2776 TestModel.replaceOrCreate(
2777 {id: existingInstance.id, name: 'replaced name'},
2778 function(err, instance) {
2779 if (err) return done(err);
2780
2781 const expected = {
2782 instance: {
2783 id: existingInstance.id,
2784 name: 'replaced name',
2785 extra: undefined,
2786 },
2787 };
2788
2789 expected.isNewInstance =
2790 isNewInstanceFlag ?
2791 false : undefined;
2792
2793 ctxRecorder.records.should.eql(aCtxForModel(TestModel, expected));
2794 done();
2795 },
2796 );
2797 });
2798
2799 it('triggers `after save` hook on create', function(done) {
2800 TestModel.observe('after save', ctxRecorder.recordAndNext());
2801
2802 TestModel.replaceOrCreate(
2803 {id: 'new-id', name: 'a name'},
2804 function(err, instance) {
2805 if (err) return done(err);
2806
2807 const expected = {
2808 instance: {
2809 id: instance.id,
2810 name: 'a name',
2811 extra: undefined,
2812 },
2813 };
2814 expected.isNewInstance =
2815 isNewInstanceFlag ?
2816 true : undefined;
2817
2818 ctxRecorder.records.should.eql(aCtxForModel(TestModel, expected));
2819 done();
2820 },
2821 );
2822 });
2823 });
2824 }
2825
2826 if (!dataSource.connector.replaceById) {
2827 describe.skip('replaceById - not implemented', function() {});
2828 } else {
2829 describe('PersistedModel.replaceById', function() {
2830 it('triggers hooks in the correct order on create', function(done) {
2831 monitorHookExecution();
2832
2833 existingInstance.name = 'replaced name';
2834 TestModel.replaceById(
2835 existingInstance.id,
2836 existingInstance.toObject(),
2837 function(err, record, created) {
2838 if (err) return done(err);
2839 hookMonitor.names.should.eql([
2840 'before save',
2841 'persist',
2842 'loaded',
2843 'after save',
2844 ]);
2845 done();
2846 },
2847 );
2848 });
2849
2850 it('triggers `persist` hook', function(done) {
2851 // "extra" property is undefined by default. As a result,
2852 // NoSQL connectors omit this property from the data. Because
2853 // SQL connectors store it as null, we have different results
2854 // depending on the database used.
2855 // By enabling "persistUndefinedAsNull", we force NoSQL connectors
2856 // to store unset properties using "null" value and thus match SQL.
2857 TestModel.settings.persistUndefinedAsNull = true;
2858
2859 TestModel.observe('persist', ctxRecorder.recordAndNext());
2860
2861 existingInstance.name = 'replaced name';
2862 TestModel.replaceById(
2863 existingInstance.id,
2864 existingInstance.toObject(),
2865 function(err, instance) {
2866 if (err) return done(err);
2867
2868 const expected = {
2869 where: {id: existingInstance.id},
2870 data: {
2871 id: existingInstance.id,
2872 name: 'replaced name',
2873 extra: null,
2874 },
2875 currentInstance: {
2876 id: existingInstance.id,
2877 name: 'replaced name',
2878 extra: null,
2879 },
2880 };
2881
2882 const expectedContext = aCtxForModel(TestModel, expected);
2883 expectedContext.isNewInstance = false;
2884
2885 ctxRecorder.records.should.eql(expectedContext);
2886 done();
2887 },
2888 );
2889 });
2890
2891 it('applies updates from `persist` hook', function(done) {
2892 TestModel.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
2893 // It's crucial to change `ctx.data` reference, not only data props
2894 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
2895 }));
2896
2897 existingInstance.name = 'changed';
2898 TestModel.replaceById(
2899 existingInstance.id,
2900 existingInstance.toObject(),
2901 function(err, instance) {
2902 if (err) return done(err);
2903 instance.should.have.property('extra', 'hook data');
2904 TestModel.findById(existingInstance.id, (err, found) => {
2905 if (err) return done(err);
2906 found.should.have.property('extra', 'hook data');
2907 done();
2908 });
2909 },
2910 );
2911 });
2912 });
2913 }
2914
2915 describe('PersistedModel.deleteAll', function() {
2916 it('triggers `access` hook with query', function(done) {
2917 TestModel.observe('access', ctxRecorder.recordAndNext());
2918
2919 TestModel.deleteAll({name: existingInstance.name}, function(err) {
2920 if (err) return done(err);
2921 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2922 query: {where: {name: existingInstance.name}},
2923 }));
2924 done();
2925 });
2926 });
2927
2928 it('triggers `access` hook without query', function(done) {
2929 TestModel.observe('access', ctxRecorder.recordAndNext());
2930
2931 TestModel.deleteAll(function(err) {
2932 if (err) return done(err);
2933 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {where: {}}}));
2934 done();
2935 });
2936 });
2937
2938 it('applies updates from `access` hook', function(done) {
2939 TestModel.observe('access', function(ctx, next) {
2940 ctx.query = {where: {id: {neq: existingInstance.id}}};
2941 next();
2942 });
2943
2944 TestModel.deleteAll(function(err) {
2945 if (err) return done(err);
2946 findTestModels(function(err, list) {
2947 if (err) return done(err);
2948 (list || []).map(get('id')).should.eql([existingInstance.id]);
2949 done();
2950 });
2951 });
2952 });
2953
2954 it('triggers `before delete` hook with query', function(done) {
2955 TestModel.observe('before delete', ctxRecorder.recordAndNext());
2956
2957 TestModel.deleteAll({name: existingInstance.name}, function(err) {
2958 if (err) return done(err);
2959 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
2960 where: {name: existingInstance.name},
2961 }));
2962 done();
2963 });
2964 });
2965
2966 it('triggers `before delete` hook without query', function(done) {
2967 TestModel.observe('before delete', ctxRecorder.recordAndNext());
2968
2969 TestModel.deleteAll(function(err) {
2970 if (err) return done(err);
2971 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {where: {}}));
2972 done();
2973 });
2974 });
2975
2976 it('applies updates from `before delete` hook', function(done) {
2977 TestModel.observe('before delete', function(ctx, next) {
2978 ctx.where = {id: {neq: existingInstance.id}};
2979 next();
2980 });
2981
2982 TestModel.deleteAll(function(err) {
2983 if (err) return done(err);
2984 findTestModels(function(err, list) {
2985 if (err) return done(err);
2986 (list || []).map(get('id')).should.eql([existingInstance.id]);
2987 done();
2988 });
2989 });
2990 });
2991
2992 it('aborts when `before delete` hook fails', function(done) {
2993 TestModel.observe('before delete', nextWithError(expectedError));
2994
2995 TestModel.deleteAll(function(err, list) {
2996 [err].should.eql([expectedError]);
2997 TestModel.findById(existingInstance.id, function(err, inst) {
2998 if (err) return done(err);
2999 (inst ? inst.toObject() : 'null').should.
3000 eql(existingInstance.toObject());
3001 done();
3002 });
3003 });
3004 });
3005
3006 it('triggers `after delete` hook without query', function(done) {
3007 TestModel.observe('after delete', ctxRecorder.recordAndNext());
3008
3009 TestModel.deleteAll(function(err) {
3010 if (err) return done(err);
3011 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3012 where: {},
3013 info: {count: 2},
3014 }));
3015 done();
3016 });
3017 });
3018
3019 it('triggers `after delete` hook with query', function(done) {
3020 TestModel.observe('after delete', ctxRecorder.recordAndNext());
3021
3022 TestModel.deleteAll({name: existingInstance.name}, function(err) {
3023 if (err) return done(err);
3024 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3025 where: {name: existingInstance.name},
3026 info: {count: 1},
3027 }));
3028 done();
3029 });
3030 });
3031
3032 it('aborts when `after delete` hook fails', function(done) {
3033 TestModel.observe('after delete', nextWithError(expectedError));
3034
3035 TestModel.deleteAll(function(err) {
3036 [err].should.eql([expectedError]);
3037 done();
3038 });
3039 });
3040 });
3041
3042 describe('PersistedModel.prototype.delete', function() {
3043 it('triggers `access` hook', function(done) {
3044 TestModel.observe('access', ctxRecorder.recordAndNext());
3045
3046 existingInstance.delete(function(err) {
3047 if (err) return done(err);
3048 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3049 query: {where: {id: existingInstance.id}},
3050 }));
3051 done();
3052 });
3053 });
3054
3055 it('applies updated from `access` hook', function(done) {
3056 TestModel.observe('access', function(ctx, next) {
3057 ctx.query = {where: {id: {neq: existingInstance.id}}};
3058 next();
3059 });
3060
3061 existingInstance.delete(function(err) {
3062 if (err) return done(err);
3063 findTestModels(function(err, list) {
3064 if (err) return done(err);
3065 (list || []).map(get('id')).should.eql([existingInstance.id]);
3066 done();
3067 });
3068 });
3069 });
3070
3071 it('triggers `before delete` hook', function(done) {
3072 TestModel.observe('before delete', ctxRecorder.recordAndNext());
3073
3074 existingInstance.delete(function(err) {
3075 if (err) return done(err);
3076 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3077 where: {id: existingInstance.id},
3078 instance: existingInstance,
3079 }));
3080 done();
3081 });
3082 });
3083
3084 it('applies updated from `before delete` hook', function(done) {
3085 TestModel.observe('before delete', function(ctx, next) {
3086 ctx.where = {id: {neq: existingInstance.id}};
3087 next();
3088 });
3089
3090 existingInstance.delete(function(err) {
3091 if (err) return done(err);
3092 findTestModels(function(err, list) {
3093 if (err) return done(err);
3094 (list || []).map(get('id')).should.eql([existingInstance.id]);
3095 done();
3096 });
3097 });
3098 });
3099
3100 it('aborts when `before delete` hook fails', function(done) {
3101 TestModel.observe('before delete', nextWithError(expectedError));
3102
3103 existingInstance.delete(function(err, list) {
3104 [err].should.eql([expectedError]);
3105 TestModel.findById(existingInstance.id, function(err, inst) {
3106 if (err) return done(err);
3107 (inst ? inst.toObject() : 'null').should.eql(
3108 existingInstance.toObject(),
3109 );
3110 done();
3111 });
3112 });
3113 });
3114
3115 it('triggers `after delete` hook', function(done) {
3116 TestModel.observe('after delete', ctxRecorder.recordAndNext());
3117
3118 existingInstance.delete(function(err) {
3119 if (err) return done(err);
3120 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3121 where: {id: existingInstance.id},
3122 instance: existingInstance,
3123 info: {count: 1},
3124 }));
3125 done();
3126 });
3127 });
3128
3129 it('triggers `after delete` hook without query', function(done) {
3130 TestModel.observe('after delete', ctxRecorder.recordAndNext());
3131
3132 TestModel.deleteAll({name: existingInstance.name}, function(err) {
3133 if (err) return done(err);
3134 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3135 where: {name: existingInstance.name},
3136 info: {count: 1},
3137 }));
3138 done();
3139 });
3140 });
3141
3142 it('aborts when `after delete` hook fails', function(done) {
3143 TestModel.observe('after delete', nextWithError(expectedError));
3144
3145 TestModel.deleteAll(function(err) {
3146 [err].should.eql([expectedError]);
3147 done();
3148 });
3149 });
3150
3151 it('propagates hookState from `before delete` to `after delete`', function(done) {
3152 TestModel.observe('before delete', ctxRecorder.recordAndNext(function(ctx) {
3153 ctx.hookState.foo = 'bar';
3154 }));
3155
3156 TestModel.observe('after delete', ctxRecorder.recordAndNext(function(ctx) {
3157 ctx.hookState.foo = ctx.hookState.foo.toUpperCase();
3158 }));
3159
3160 existingInstance.delete(function(err) {
3161 if (err) return done(err);
3162 ctxRecorder.records.should.eql([
3163 aCtxForModel(TestModel, {
3164 hookState: {foo: 'bar'},
3165 where: {id: '1'},
3166 instance: existingInstance,
3167 }),
3168 aCtxForModel(TestModel, {
3169 hookState: {foo: 'BAR'},
3170 info: {count: 1},
3171 where: {id: '1'},
3172 instance: existingInstance,
3173 }),
3174 ]);
3175 done();
3176 });
3177 });
3178
3179 it('triggers hooks only once', function(done) {
3180 monitorHookExecution();
3181 TestModel.observe('access', function(ctx, next) {
3182 ctx.query = {where: {id: {neq: existingInstance.id}}};
3183 next();
3184 });
3185
3186 existingInstance.delete(function(err) {
3187 if (err) return done(err);
3188 hookMonitor.names.should.eql(['access', 'before delete', 'after delete']);
3189 done();
3190 });
3191 });
3192 });
3193
3194 describe('PersistedModel.updateAll', function() {
3195 it('triggers `access` hook', function(done) {
3196 TestModel.observe('access', ctxRecorder.recordAndNext());
3197
3198 TestModel.updateAll(
3199 {name: 'searched'},
3200 {name: 'updated'},
3201 function(err, instance) {
3202 if (err) return done(err);
3203 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
3204 where: {name: 'searched'},
3205 }}));
3206 done();
3207 },
3208 );
3209 });
3210
3211 it('applies updates from `access` hook', function(done) {
3212 TestModel.observe('access', function(ctx, next) {
3213 ctx.query = {where: {id: {neq: existingInstance.id}}};
3214 next();
3215 });
3216
3217 TestModel.updateAll(
3218 {id: existingInstance.id},
3219 {name: 'new name'},
3220 function(err) {
3221 if (err) return done(err);
3222 findTestModels({fields: ['id', 'name']}, function(err, list) {
3223 if (err) return done(err);
3224 (list || []).map(toObject).should.eql([
3225 {id: existingInstance.id, name: existingInstance.name, extra: undefined},
3226 {id: '2', name: 'new name', extra: undefined},
3227 ]);
3228 done();
3229 });
3230 },
3231 );
3232 });
3233
3234 it('triggers `before save` hook', function(done) {
3235 TestModel.observe('before save', ctxRecorder.recordAndNext());
3236
3237 TestModel.updateAll(
3238 {name: 'searched'},
3239 {name: 'updated'},
3240 function(err, instance) {
3241 if (err) return done(err);
3242 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3243 where: {name: 'searched'},
3244 data: {name: 'updated'},
3245 }));
3246 done();
3247 },
3248 );
3249 });
3250
3251 it('applies updates from `before save` hook', function(done) {
3252 TestModel.observe('before save', function(ctx, next) {
3253 ctx.data = {name: 'hooked', extra: 'added'};
3254 next();
3255 });
3256
3257 TestModel.updateAll(
3258 {id: existingInstance.id},
3259 {name: 'updated name'},
3260 function(err) {
3261 if (err) return done(err);
3262 loadTestModel(existingInstance.id, function(err, instance) {
3263 if (err) return done(err);
3264 instance.should.have.property('name', 'hooked');
3265 instance.should.have.property('extra', 'added');
3266 done();
3267 });
3268 },
3269 );
3270 });
3271
3272 it('triggers `persist` hook', function(done) {
3273 TestModel.observe('persist', ctxRecorder.recordAndNext());
3274
3275 TestModel.updateAll(
3276 {name: existingInstance.name},
3277 {name: 'changed'},
3278 function(err, instance) {
3279 if (err) return done(err);
3280
3281 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3282 data: {name: 'changed'},
3283 where: {name: existingInstance.name},
3284 }));
3285
3286 done();
3287 },
3288 );
3289 });
3290
3291 it('applies updates from `persist` hook', function(done) {
3292 TestModel.observe('persist', ctxRecorder.recordAndNext(function(ctx) {
3293 // It's crucial to change `ctx.data` reference, not only data props
3294 ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
3295 }));
3296
3297 TestModel.updateAll(
3298 {id: existingInstance.id},
3299 {name: 'changed'},
3300 function(err) {
3301 if (err) return done(err);
3302 loadTestModel(existingInstance.id, function(err, instance) {
3303 instance.should.have.property('extra', 'hook data');
3304 done();
3305 });
3306 },
3307 );
3308 });
3309
3310 it('does not trigger `loaded`', function(done) {
3311 TestModel.observe('loaded', ctxRecorder.recordAndNext());
3312
3313 TestModel.updateAll(
3314 {id: existingInstance.id},
3315 {name: 'changed'},
3316 function(err, instance) {
3317 if (err) return done(err);
3318 ctxRecorder.records.should.eql('hook not called');
3319 done();
3320 },
3321 );
3322 });
3323
3324 it('triggers `after save` hook', function(done) {
3325 TestModel.observe('after save', ctxRecorder.recordAndNext());
3326
3327 TestModel.updateAll(
3328 {id: existingInstance.id},
3329 {name: 'updated name'},
3330 function(err) {
3331 if (err) return done(err);
3332 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3333 where: {id: existingInstance.id},
3334 data: {name: 'updated name'},
3335 info: {count: 1},
3336 }));
3337 done();
3338 },
3339 );
3340 });
3341
3342 it('accepts hookState from options', function(done) {
3343 TestModel.observe('after save', ctxRecorder.recordAndNext());
3344
3345 TestModel.updateAll(
3346 {id: existingInstance.id},
3347 {name: 'updated name'},
3348 {foo: 'bar'},
3349 function(err) {
3350 if (err) return done(err);
3351 ctxRecorder.records.options.should.eql({
3352 foo: 'bar',
3353 });
3354 done();
3355 },
3356 );
3357 });
3358 });
3359
3360 describe('PersistedModel.upsertWithWhere', function() {
3361 it('triggers hooks in the correct order on create', function(done) {
3362 monitorHookExecution();
3363 TestModel.upsertWithWhere({extra: 'not-found'},
3364 {id: 'not-found', name: 'not found', extra: 'not-found'},
3365 function(err, record, created) {
3366 if (err) return done(err);
3367 hookMonitor.names.should.eql([
3368 'access',
3369 'before save',
3370 'persist',
3371 'loaded',
3372 'after save',
3373 ]);
3374 TestModel.findById('not-found', function(err, data) {
3375 if (err) return done(err);
3376 data.name.should.equal('not found');
3377 data.extra.should.equal('not-found');
3378 done();
3379 });
3380 });
3381 });
3382
3383 it('triggers hooks in the correct order on update', function(done) {
3384 monitorHookExecution();
3385 TestModel.upsertWithWhere({id: existingInstance.id},
3386 {name: 'new name', extra: 'new extra'},
3387 function(err, record, created) {
3388 if (err) return done(err);
3389 hookMonitor.names.should.eql([
3390 'access',
3391 'before save',
3392 'persist',
3393 'loaded',
3394 'after save',
3395 ]);
3396 TestModel.findById(existingInstance.id, function(err, data) {
3397 if (err) return done(err);
3398 data.name.should.equal('new name');
3399 data.extra.should.equal('new extra');
3400 done();
3401 });
3402 });
3403 });
3404
3405 it('triggers `access` hook on create', function(done) {
3406 TestModel.observe('access', ctxRecorder.recordAndNext());
3407
3408 TestModel.upsertWithWhere({extra: 'not-found'},
3409 {id: 'not-found', name: 'not found'},
3410 function(err, instance) {
3411 if (err) return done(err);
3412 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
3413 where: {extra: 'not-found'},
3414 }}));
3415 done();
3416 });
3417 });
3418
3419 it('triggers `access` hook on update', function(done) {
3420 TestModel.observe('access', ctxRecorder.recordAndNext());
3421
3422 TestModel.upsertWithWhere({id: existingInstance.id},
3423 {name: 'new name', extra: 'new extra'},
3424 function(err, instance) {
3425 if (err) return done(err);
3426 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {query: {
3427 where: {id: existingInstance.id},
3428 }}));
3429 done();
3430 });
3431 });
3432
3433 it('triggers hooks only once', function(done) {
3434 monitorHookExecution(['access', 'before save']);
3435
3436 TestModel.observe('access', function(ctx, next) {
3437 ctx.query = {where: {id: {neq: existingInstance.id}}};
3438 next();
3439 });
3440
3441 TestModel.upsertWithWhere({id: existingInstance.id},
3442 {name: 'new name'},
3443 function(err, instance) {
3444 if (err) return done(err);
3445 hookMonitor.names.should.eql(['access', 'before save']);
3446 done();
3447 });
3448 });
3449
3450 it('applies updates from `access` hook when found', function(done) {
3451 TestModel.observe('access', function(ctx, next) {
3452 ctx.query = {where: {id: {neq: existingInstance.id}}};
3453 next();
3454 });
3455
3456 TestModel.upsertWithWhere({id: existingInstance.id},
3457 {name: 'new name'},
3458 function(err, instance) {
3459 if (err) return done(err);
3460 findTestModels({fields: ['id', 'name']}, function(err, list) {
3461 if (err) return done(err);
3462 (list || []).map(toObject).should.eql([
3463 {id: existingInstance.id, name: existingInstance.name, extra: undefined},
3464 {id: instance.id, name: 'new name', extra: undefined},
3465 ]);
3466 done();
3467 });
3468 });
3469 });
3470
3471 it('applies updates from `access` hook when not found', function(done) {
3472 TestModel.observe('access', function(ctx, next) {
3473 ctx.query = {where: {id: 'not-found'}};
3474 next();
3475 });
3476
3477 TestModel.upsertWithWhere({id: existingInstance.id},
3478 {name: 'new name'},
3479 function(err, instance) {
3480 if (err) return done(err);
3481 findTestModels({fields: ['id', 'name']}, function(err, list) {
3482 if (err) return done(err);
3483 (list || []).map(toObject).should.eql([
3484 {id: existingInstance.id, name: existingInstance.name, extra: undefined},
3485 {id: list[1].id, name: 'second', extra: undefined},
3486 {id: instance.id, name: 'new name', extra: undefined},
3487 ]);
3488 done();
3489 });
3490 });
3491 });
3492
3493 it('triggers `before save` hook on update', function(done) {
3494 TestModel.observe('before save', ctxRecorder.recordAndNext());
3495
3496 TestModel.upsertWithWhere({id: existingInstance.id},
3497 {id: existingInstance.id, name: 'updated name'},
3498 function(err, instance) {
3499 if (err) return done(err);
3500 const expectedContext = aCtxForModel(TestModel, {
3501 where: {id: existingInstance.id},
3502 data: {
3503 id: existingInstance.id,
3504 name: 'updated name',
3505 },
3506 });
3507 if (!dataSource.connector.upsertWithWhere) {
3508 // the difference between `existingInstance` and the following
3509 // plain-data object is `currentInstance` the missing fields are
3510 // null in `currentInstance`, wehere as in `existingInstance` they
3511 // are undefined; please see other tests for example see:
3512 // test for "PersistedModel.create triggers `persist` hook"
3513 expectedContext.currentInstance = {id: existingInstance.id, name: 'first', extra: null};
3514 }
3515 ctxRecorder.records.should.eql(expectedContext);
3516 done();
3517 });
3518 });
3519
3520 it('triggers `before save` hook on create', function(done) {
3521 TestModel.observe('before save', ctxRecorder.recordAndNext());
3522
3523 TestModel.upsertWithWhere({id: 'new-id'},
3524 {id: 'new-id', name: 'a name'},
3525 function(err, instance) {
3526 if (err) return done(err);
3527 const expectedContext = aCtxForModel(TestModel, {});
3528
3529 if (dataSource.connector.upsertWithWhere) {
3530 expectedContext.data = {id: 'new-id', name: 'a name'};
3531 expectedContext.where = {id: 'new-id'};
3532 } else {
3533 expectedContext.instance = {id: 'new-id', name: 'a name', extra: null};
3534 expectedContext.isNewInstance = true;
3535 }
3536 ctxRecorder.records.should.eql(expectedContext);
3537 done();
3538 });
3539 });
3540
3541 it('applies updates from `before save` hook on update', function(done) {
3542 TestModel.observe('before save', function(ctx, next) {
3543 // It's crucial to change `ctx.data` reference, not only data props
3544 ctx.data = Object.assign({}, ctx.data, {name: 'hooked'});
3545 next();
3546 });
3547
3548 TestModel.upsertWithWhere({id: existingInstance.id},
3549 {name: 'updated name'},
3550 function(err, instance) {
3551 if (err) return done(err);
3552 instance.name.should.equal('hooked');
3553 done();
3554 });
3555 });
3556
3557 it('applies updates from `before save` hook on create', function(done) {
3558 TestModel.observe('before save', function(ctx, next) {
3559 if (ctx.instance) {
3560 ctx.instance.name = 'hooked';
3561 } else {
3562 // It's crucial to change `ctx.data` reference, not only data props
3563 ctx.data = Object.assign({}, ctx.data, {name: 'hooked'});
3564 }
3565 next();
3566 });
3567
3568 TestModel.upsertWithWhere({id: 'new-id'},
3569 {id: 'new-id', name: 'new name'},
3570 function(err, instance) {
3571 if (err) return done(err);
3572 instance.name.should.equal('hooked');
3573 done();
3574 });
3575 });
3576
3577 it('validates model after `before save` hook on create', function(done) {
3578 TestModel.observe('before save', invalidateTestModel());
3579
3580 TestModel.upsertWithWhere({id: 'new-id'},
3581 {id: 'new-id', name: 'new name'},
3582 function(err, instance) {
3583 (err || {}).should.be.instanceOf(ValidationError);
3584 (err.details.codes || {}).should.eql({name: ['presence']});
3585 done();
3586 });
3587 });
3588
3589 it('validates model after `before save` hook on update', function(done) {
3590 TestModel.observe('before save', invalidateTestModel());
3591
3592 TestModel.upsertWithWhere({id: existingInstance.id},
3593 {id: existingInstance.id, name: 'updated name'},
3594 function(err, instance) {
3595 (err || {}).should.be.instanceOf(ValidationError);
3596 (err.details.codes || {}).should.eql({name: ['presence']});
3597 done();
3598 });
3599 });
3600
3601 it('triggers `persist` hook on create', function(done) {
3602 TestModel.observe('persist', ctxRecorder.recordAndNext());
3603
3604 TestModel.upsertWithWhere({id: 'new-id'},
3605 {id: 'new-id', name: 'a name'},
3606 function(err, instance) {
3607 if (err) return done(err);
3608
3609 const expectedContext = aCtxForModel(TestModel, {
3610 data: {id: 'new-id', name: 'a name'},
3611 currentInstance: {
3612 id: 'new-id',
3613 name: 'a name',
3614 extra: undefined,
3615 },
3616 });
3617 if (dataSource.connector.upsertWithWhere) {
3618 expectedContext.where = {id: 'new-id'};
3619 } else {
3620 expectedContext.isNewInstance = true;
3621 }
3622
3623 ctxRecorder.records.should.eql(expectedContext);
3624 done();
3625 });
3626 });
3627
3628 it('triggers persist hook on update', function(done) {
3629 TestModel.observe('persist', ctxRecorder.recordAndNext());
3630
3631 TestModel.upsertWithWhere({id: existingInstance.id},
3632 {id: existingInstance.id, name: 'updated name'},
3633 function(err, instance) {
3634 if (err) return done(err);
3635 const expectedContext = aCtxForModel(TestModel, {
3636 where: {id: existingInstance.id},
3637 data: {
3638 id: existingInstance.id,
3639 name: 'updated name',
3640 },
3641 currentInstance: {
3642 id: existingInstance.id,
3643 name: 'updated name',
3644 extra: undefined,
3645 },
3646 });
3647 if (!dataSource.connector.upsertWithWhere) {
3648 expectedContext.isNewInstance = false;
3649 }
3650 ctxRecorder.records.should.eql(expectedContext);
3651 done();
3652 });
3653 });
3654
3655 it('triggers `loaded` hook on create', function(done) {
3656 TestModel.observe('loaded', ctxRecorder.recordAndNext());
3657
3658 TestModel.upsertWithWhere({id: 'new-id'},
3659 {id: 'new-id', name: 'a name'},
3660 function(err, instance) {
3661 if (err) return done(err);
3662 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3663 data: {id: 'new-id', name: 'a name'},
3664 isNewInstance: true,
3665 }));
3666 done();
3667 });
3668 });
3669
3670 it('triggers `loaded` hook on update', function(done) {
3671 TestModel.observe('loaded', ctxRecorder.recordAndNext());
3672
3673 TestModel.upsertWithWhere({id: existingInstance.id},
3674 {id: existingInstance.id, name: 'updated name'},
3675 function(err, instance) {
3676 if (err) return done(err);
3677 const expectedContext = aCtxForModel(TestModel, {
3678 data: {
3679 id: existingInstance.id,
3680 name: 'updated name',
3681 },
3682 isNewInstance: false,
3683 });
3684 ctxRecorder.records.should.eql(aCtxForModel(TestModel, expectedContext));
3685 done();
3686 });
3687 });
3688
3689 it('emits error when `loaded` hook fails', function(done) {
3690 TestModel.observe('loaded', nextWithError(expectedError));
3691 TestModel.upsertWithWhere({id: 'new-id'},
3692 {id: 'new-id', name: 'a name'},
3693 function(err, instance) {
3694 [err].should.eql([expectedError]);
3695 done();
3696 });
3697 });
3698
3699 it('triggers `after save` hook on update', function(done) {
3700 TestModel.observe('after save', ctxRecorder.recordAndNext());
3701
3702 TestModel.upsertWithWhere({id: existingInstance.id},
3703 {id: existingInstance.id, name: 'updated name'},
3704 function(err, instance) {
3705 if (err) return done(err);
3706 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3707 instance: {
3708 id: existingInstance.id,
3709 name: 'updated name',
3710 extra: undefined,
3711 },
3712 isNewInstance: false,
3713 }));
3714 done();
3715 });
3716 });
3717
3718 it('triggers `after save` hook on create', function(done) {
3719 TestModel.observe('after save', ctxRecorder.recordAndNext());
3720
3721 TestModel.upsertWithWhere({id: 'new-id'},
3722 {id: 'new-id', name: 'a name'}, function(err, instance) {
3723 if (err) return done(err);
3724 ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
3725 instance: {
3726 id: instance.id,
3727 name: 'a name',
3728 extra: undefined,
3729 },
3730 isNewInstance: true,
3731 }));
3732 done();
3733 });
3734 });
3735 });
3736
3737 function nextWithError(err) {
3738 return function(context, next) {
3739 next(err);
3740 };
3741 }
3742
3743 function invalidateTestModel() {
3744 return function(context, next) {
3745 if (context.instance) {
3746 context.instance.name = '';
3747 } else {
3748 context.data.name = '';
3749 }
3750 next();
3751 };
3752 }
3753
3754 function findTestModels(query, cb) {
3755 if (cb === undefined && typeof query === 'function') {
3756 cb = query;
3757 query = null;
3758 }
3759
3760 TestModel.find(query, {notify: false}, cb);
3761 }
3762
3763 function loadTestModel(id, cb) {
3764 TestModel.findOne({where: {id: id}}, {notify: false}, cb);
3765 }
3766
3767 function monitorHookExecution(hookNames) {
3768 hookMonitor.install(TestModel, hookNames);
3769 }
3770
3771 require('./operation-hooks.suite')(dataSource, should, connectorCapabilities);
3772 });
3773
3774 function get(propertyName) {
3775 return function(obj) {
3776 return obj[propertyName];
3777 };
3778 }
3779
3780 function toObject(obj) {
3781 return obj.toObject ? obj.toObject() : obj;
3782 }
3783};