diff --git a/src/Container.ts b/src/Container.ts index 57ff7e4..af44899 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -4,7 +4,7 @@ import {EventEmitter} from 'nomatic-events'; import {Adapter} from './adapters/index'; import AlreadyExistsError from './errors/AlreadyExistsError'; import ValidationError from './errors/ValidationError'; -import Mapper, {MapperHookFunction, MapperOptions} from './Mapper'; +import Mapper, {MapperHookFunction, MapperOptions, MapperValidateHookFunction} from './Mapper'; import Query from './Query'; import Record, {RecordData, RecordVirtualProperties} from './Record'; @@ -13,10 +13,13 @@ export interface ContainerMapperOptions { required?: string[]; additionalProperties?: boolean | object; virtuals?: RecordVirtualProperties; + afterGet?: MapperHookFunction | MapperHookFunction[]; afterInsert?: MapperHookFunction | MapperHookFunction[]; afterUpdate?: MapperHookFunction | MapperHookFunction[]; + afterValidate?: MapperValidateHookFunction | MapperValidateHookFunction[]; beforeInsert?: MapperHookFunction | MapperHookFunction[]; beforeUpdate?: MapperHookFunction | MapperHookFunction[]; + beforeValidate?: MapperValidateHookFunction | MapperValidateHookFunction[]; } export interface ContainerMappers { @@ -26,10 +29,14 @@ export interface ContainerMappers { export interface ContainerOptions { adapter: Adapter; + afterGet?: MapperHookFunction | MapperHookFunction[]; afterInsert?: MapperHookFunction | MapperHookFunction[]; afterUpdate?: MapperHookFunction | MapperHookFunction[]; + afterValidate?: MapperValidateHookFunction | MapperValidateHookFunction[]; beforeInsert?: MapperHookFunction | MapperHookFunction[]; beforeUpdate?: MapperHookFunction | MapperHookFunction[]; + beforeValidate?: MapperValidateHookFunction | MapperValidateHookFunction[]; + mappers: { [key: string]: ContainerMapperOptions; }; @@ -130,10 +137,6 @@ export class Container extends EventEmitter { const mapperOptions: MapperOptions = { adapter: this.adapter, - afterInsert: options.afterInsert, - afterUpdate: options.afterUpdate, - beforeInsert: options.beforeInsert, - beforeUpdate: options.beforeUpdate, name: name, validate: validateRunner, virtuals: options.virtuals diff --git a/src/Mapper.ts b/src/Mapper.ts index fa87b5f..9d379cf 100644 --- a/src/Mapper.ts +++ b/src/Mapper.ts @@ -6,13 +6,19 @@ import Query from './Query'; import {Record, RecordData, RecordOptions, RecordValidateFunction, RecordVirtualProperties} from './Record'; export type MapperHookFunction = (record: Record) => void; +export type MapperBeforeGetHookFunction = (id: string) => void; +export type MapperValidateHookFunction = (record: Record, operation: 'insert' | 'replace' | 'update') => void; export interface MapperOptions { adapter: Adapter; + afterGet?: MapperHookFunction | MapperHookFunction[]; afterInsert?: MapperHookFunction | MapperHookFunction[]; afterUpdate?: MapperHookFunction | MapperHookFunction[]; + afterValidate?: MapperValidateHookFunction | MapperValidateHookFunction[]; + beforeGet?: MapperBeforeGetHookFunction | MapperBeforeGetHookFunction[]; beforeInsert?: MapperHookFunction | MapperHookFunction[]; beforeUpdate?: MapperHookFunction | MapperHookFunction[]; + beforeValidate?: MapperValidateHookFunction | MapperValidateHookFunction[]; name: string; validate?: RecordValidateFunction; virtuals?: RecordVirtualProperties; @@ -20,10 +26,14 @@ export interface MapperOptions { export class Mapper extends EventEmitter { static hooksList: string[] = [ + 'afterGet', 'afterInsert', 'afterUpdate', + 'afterValidate', + 'beforeGet', 'beforeInsert', - 'beforeUpdate' + 'beforeUpdate', + 'beforeValidate' ]; private _validate: RecordValidateFunction; @@ -33,6 +43,21 @@ export class Mapper extends EventEmitter { public name: string; public readonly collection: string; + /** + * Hooks ordering: + * Operation(s) Order + * -------------------------------------------------------------------------------------------------- + * find, findAll afterGet + * get, getAll beforeGet, afterGet + * insert,insertAll beforeValidate, afterValidate, beforeInsert, afterInsert, afterGet + * replace, update beforeValidate, afterValidate, beforeUpdate, afterUpdate, afterGet + * -------------------------------------------------------------------------------------------------- + * + * Hooks are called on each record, so getAll, findAll, and insertAll operations will call each hook for each + * record. + * + * @param {MapperOptions} options + */ constructor(options: MapperOptions) { super(); this.adapter = options.adapter; @@ -54,6 +79,14 @@ export class Mapper extends EventEmitter { } } + private validate(record: Record, operation: string) { + this.emit('beforeValidate', record, operation); + return record.validate().then(() => { + this.emit('afterValidate', record, operation); + return; + }); + } + public createRecord(data: RecordData = {}): Record { const options: RecordOptions = { validate: this._validate, @@ -65,8 +98,11 @@ export class Mapper extends EventEmitter { } public async get(id: string): Promise { + this.emit('beforeGet', id); const response = await this.adapter.get(this.collection, id); - return this.createRecord(response); + const record = this.createRecord(response); + this.emit('afterGet', record); + return record; } public async getAll(ids: string[]) { @@ -98,7 +134,9 @@ export class Mapper extends EventEmitter { const response = await this.adapter.findAll(this.collection, q); for (const i in response) { - results.push(this.createRecord(response[i])); + const record = this.createRecord(response[i]); + this.emit('afterGet', record); + results.push(record); } return results; @@ -117,7 +155,7 @@ export class Mapper extends EventEmitter { if (validate) { try { - await record.validate(); + await this.validate(record, 'update'); } catch (error) { throw error; } @@ -128,12 +166,13 @@ export class Mapper extends EventEmitter { const result = await this.adapter.update(this.collection, record.id, record.serialize('save')); record.commit(result); this.emit('afterUpdate', record); + this.emit('afterGet', record); return record; } - public async save(record: Record, validate: boolean = true): Promise { + public async save(record: Record, validate: boolean = true, force: boolean = false): Promise { if (record.id && record.rev) { - if (record.changes().length === 0) { + if (record.changes().length === 0 && !force) { return this.get(record.id).then((response) => { if (response.rev === record.rev) { return record; @@ -175,7 +214,7 @@ export class Mapper extends EventEmitter { if (validate) { try { - await record.validate(); + await this.validate(record, 'replace'); } catch (error) { throw error; } @@ -186,6 +225,7 @@ export class Mapper extends EventEmitter { return await this.adapter.replace(this.collection, id, record.serialize('save'), rev).then((data) => { record.commit(data); this.emit('afterUpdate', record); + this.emit('afterGet', record); return record; }); } @@ -201,7 +241,7 @@ export class Mapper extends EventEmitter { if (validate) { try { - await record.validate(); + await this.validate(record, 'insert'); } catch (error) { throw error; } @@ -212,6 +252,7 @@ export class Mapper extends EventEmitter { return await this.adapter.insert(this.collection, record.serialize('save')).then((result) => { record.init(result); this.emit('afterInsert', record); + this.emit('afterGet', record); return record; }); } diff --git a/src/Record.ts b/src/Record.ts index 1be0152..af88985 100644 --- a/src/Record.ts +++ b/src/Record.ts @@ -293,8 +293,6 @@ export class Record extends EventEmitter { if (typeof key !== 'string') key = key.toString(); if (this._virtuals.has(key)) return this._virtuals.get(key).get.apply(this.proxy()); - - return get(this._data, key); } @@ -303,8 +301,9 @@ export class Record extends EventEmitter { * * @param key * @param value + * @param silent */ - public set(key, value) { + public set(key, value, silent: boolean = false) { if (typeof key !== 'string') key = key.toString(); let isVirtual = false; @@ -373,7 +372,9 @@ export class Record extends EventEmitter { set(this._data, key, value); - this._changes.push(change); + if (!silent) { + this._changes.push(change); + } } /** @@ -381,10 +382,11 @@ export class Record extends EventEmitter { * `_changes` so long as a value exists. Virtual properties cannot be unset. * * @param key + * @param silent * @returns {boolean} If true, unset was successful. If false, there's nothing set at `key` under `_data` or it is * a virtual property. */ - public unset(key) { + public unset(key, silent: boolean = false) { if (!isNullOrUndefined(get(this._virtuals, key))) return false; const operation: RecordOperation = 'remove'; let old = this.get(key); @@ -403,7 +405,10 @@ export class Record extends EventEmitter { }; if (unset(this._data, key)) { - this._changes.push(change); + if (!silent) { + this._changes.push(change); + } + return true; } diff --git a/test/Container.test.ts b/test/Container.test.ts index e46d11e..6922201 100644 --- a/test/Container.test.ts +++ b/test/Container.test.ts @@ -9,9 +9,9 @@ import {inspect} from 'util'; describe('Container', () => { const config = require('./fixtures/config/' + process.env.NODE_ENV + '.json')['arangodb']; - const mock = require('./fixtures/mock.json'); let adapter; let instance; + let gets = 0; before((done) => { adapter = new ArangoDBAdapter(config); @@ -26,6 +26,9 @@ describe('Container', () => { instance = new Container({ adapter: adapter, + afterGet(record) { + gets++; + }, beforeInsert(record) { record.createdAt = new Date(); }, @@ -196,8 +199,10 @@ describe('Container', () => { describe('#findAll', () => { it('should return all results', (done) => { + const startGets = gets; instance.findAll('people', {}).then((results) => { expect(results.length).to.equal(people.length); + expect(gets).to.equal(startGets + results.length); }).then(done, done); }); @@ -234,19 +239,23 @@ describe('Container', () => { describe('#get()', () => { it('should return a saved Record instance', (done) => { + const startGets = gets; instance.get('people', people[0]['id']).then((result) => { expect(result.id).to.equal(people[0]['id']); expect(result.name).to.equal(`${people[0].firstName} ${people[0].lastName}`); + expect(gets).to.equal(startGets + 1); }).then(done, done); }); }); describe('#getAll()', () => { it('should return an array of saved Record instances', (done) => { + const startGets = gets; instance.getAll('people', [people[0]['id'], people[1]['id']]).then((results) => { expect(results.length).to.equal(2); expect(results[0].id).to.equal(people[0]['id']); expect(results[1].id).to.equal(people[1]['id']); + expect(gets).to.equal(startGets + 2); }).then(done, done); }); }); diff --git a/test/Mapper.test.ts b/test/Mapper.test.ts index 7d21842..3660288 100644 --- a/test/Mapper.test.ts +++ b/test/Mapper.test.ts @@ -10,6 +10,7 @@ process.on('unhandledRejection', (reason) => { }); describe('Mapper', () => { + let hooksFired = []; const records = []; const data = [ { @@ -34,6 +35,38 @@ describe('Mapper', () => { people = new Mapper({ adapter: new ArangoDBAdapter(config), name: 'person', + afterGet: (record) => { + expect(record).to.be.an.instanceOf(Record); + hooksFired.push('afterGet'); + }, + afterInsert: (record) => { + expect(record).to.be.an.instanceOf(Record); + hooksFired.push('afterInsert'); + }, + afterUpdate: (record) => { + expect(record).to.be.an.instanceOf(Record); + hooksFired.push('afterUpdate'); + }, + afterValidate: (record) => { + expect(record).to.be.an.instanceOf(Record); + hooksFired.push('afterValidate'); + }, + beforeGet: (id) => { + expect(id).to.be.a('string'); + hooksFired.push('beforeGet'); + }, + beforeInsert: (record) => { + expect(record).to.be.an.instanceOf(Record); + hooksFired.push('beforeInsert'); + }, + beforeUpdate: (record) => { + expect(record).to.be.an.instanceOf(Record); + hooksFired.push('beforeUpdate'); + }, + beforeValidate: (record) => { + expect(record).to.be.an.instanceOf(Record); + hooksFired.push('beforeValidate'); + } }); people.load().then(() => { return people.truncate().then(() => { @@ -59,10 +92,18 @@ describe('Mapper', () => { describe('#save()', () => { it('should save the record to the database collection when `id` is not specified', (done) => { + hooksFired = []; records[0].save().then(() => { expect(records[0].id).to.exist; expect(records[0].rev).to.exist; data[0] = records[0].toJSON(); + expect(hooksFired).to.deep.equal([ + 'beforeValidate', + 'afterValidate', + 'beforeInsert', + 'afterInsert', + 'afterGet' + ]); }).then(done, done); }); @@ -75,18 +116,45 @@ describe('Mapper', () => { }); it('should update the record to the database collection', (done) => { + hooksFired = []; records[0].birthDate = '2000-12-31'; records[0].save().then(() => { expect(data[0]['rev']).to.not.equal(records[0].rev); expect(records[0].birthDate).to.equal('2000-12-31'); data[0] = records[0].toJSON(); + expect(hooksFired).to.deep.equal([ + 'beforeValidate', + 'afterValidate', + 'beforeUpdate', + 'afterUpdate', + 'afterGet' + ]); }).then(done, done); }); it('should bypass database operations when no changes are made', (done) => { + hooksFired = []; expect(records[0].changes().length).to.equal(0); records[0].save().then(() => { expect(data[0]['rev']).to.equal(records[0].rev); + expect(hooksFired).to.deep.equal([ + 'beforeGet', + 'afterGet' + ]); + }).then(done, done); + }); + + it('should force database operations when no changes are made and `force` is true', (done) => { + hooksFired = []; + expect(records[0].changes().length).to.equal(0); + people.save(records[0], false, true).then(() => { + expect(data[0]['rev']).to.not.equal(records[0].rev); + data[0] = records[0].toJSON(); + expect(hooksFired).to.deep.equal([ + 'beforeUpdate', + 'afterUpdate', + 'afterGet' + ]); }).then(done, done); }); @@ -114,31 +182,44 @@ describe('Mapper', () => { describe('#findAll', () => { it('should find the saved Record', (done) => { + hooksFired = []; people.findAll({ $where: { id: records[0].id } }).then(results => { expect(results[0].serialize()).to.deep.equal(records[0].serialize()); + expect(hooksFired).to.deep.equal([ + 'afterGet' + ]); }).then(done, done); }); }); describe('#get()', () => { it('should get the saved Record', (done) => { + hooksFired = []; people.get(data[0]['id']).then((record) => { expect(record).to.exist; expect(record).to.be.instanceOf(Record); expect(record.id).to.equal(records[0].id); + expect(hooksFired).to.deep.equal([ + 'beforeGet', + 'afterGet' + ]); return done(); }).catch(done); }); it('should throw when specifying a non-existent Record', (done) => { + hooksFired = []; people.get('000000').then(() => { return done('Did not throw!'); }).catch((e) => { if (e.name === 'NotFoundError') { + expect(hooksFired).to.deep.equal([ + 'beforeGet' + ]); return done(); } @@ -161,13 +242,22 @@ describe('Mapper', () => { describe('#update()', () => { it('should update record given `data` is not a Record instance', (done) => { const data = records[0].serialize(); - + hooksFired = []; data.middleName = 'David'; people.update(data).then((result) => { expect(result.middleName).to.equal(data.middleName); expect(result.rev).to.not.equal(data.rev); expect(result).to.be.an.instanceOf(Record); records[0] = result; + expect(hooksFired).to.deep.equal([ + 'beforeGet', + 'afterGet', + 'beforeValidate', + 'afterValidate', + 'beforeUpdate', + 'afterUpdate', + 'afterGet' + ]); }).then(done, done); }); diff --git a/test/Record.test.ts b/test/Record.test.ts index 51f87cb..35835d2 100644 --- a/test/Record.test.ts +++ b/test/Record.test.ts @@ -140,6 +140,13 @@ describe('Record', () => { new: 'yes' }); }); + + it('should not list changes with `silent` set to true on #set() or #unset()', () => { + const start = instance.changes().length; + instance.set('testSilent', false, true); + expect(instance.changes().length).to.equal(start); + instance.unset('testSilent', true); + }); }); describe('#revert()', () => {