From 5b7e5641295edfb518e02b530b1921f6f6bd630d Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Thu, 14 May 2020 12:15:46 +0200 Subject: [PATCH 01/20] Add EventsPipeline class --- src/events/event.js | 17 +++++++ src/events/pipeline.js | 74 ++++++++++++++++++++++++++++ tests/events/pipeline.test.js | 92 +++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 src/events/event.js create mode 100644 src/events/pipeline.js create mode 100644 tests/events/pipeline.test.js diff --git a/src/events/event.js b/src/events/event.js new file mode 100644 index 0000000..70d2572 --- /dev/null +++ b/src/events/event.js @@ -0,0 +1,17 @@ +const { KexError } = require('../errors') + +class Event { + static get eventName () { + throw new KexError('Event name should be set in child class') + } + + constructor () { + this.cancelled = false + } + + cancel () { + this.cancelled = true + } +} + +module.exports = Event diff --git a/src/events/pipeline.js b/src/events/pipeline.js new file mode 100644 index 0000000..9bc0a11 --- /dev/null +++ b/src/events/pipeline.js @@ -0,0 +1,74 @@ +/** @typedef {Map} Listeners */ +/** @typedef { import('./event') } Event */ + +class EventsPipeline { + /** + * @param {Array} listeners entries for listeners mapTo + */ + constructor (listeners = []) { + /** @type {Listeners} */ + this.listeners = new Map(listeners) + } + + /** + * @param {String} eventName + * @param {Function} listener + * @return {Function} a callback which removes the listener + */ + on (eventName, listener) { + const list = this.listeners.get(eventName) || [] + this.listeners.set(eventName, list.concat(listener)) + + return () => { + const list = this.listeners.get(eventName) + const index = list.indexOf(listener) + + if (index >= 0) { + list.splice(index, 1) + this.listeners.set(eventName, list) + } + } + } + + /** + * Execute all listeners of given event. + * + * The listeners are called serially. + * + * @param {Event} event + * @return {Promise} the result of calling the listener; + * FALSE indicates that event was cancelled + */ + async emit (event) { + const { eventName } = event.constructor + const list = this.listeners.get(eventName) || [] + + for (let i = 0; i < list.length; i++) { + await list[i](event) + + if (event.cancelled) { + return false + } + } + + return true + } + + /** + * Create copy of current instance. + * + * This method makes sure that all lists are dereferenced. + * + * @return {EventsPipeline} + */ + clone () { + const entries = Array.from(this.listeners.entries()) + + return new this.constructor(entries.map(([name, listeners]) => ([ + name, + [...listeners] + ]))) + } +} + +module.exports = EventsPipeline diff --git a/tests/events/pipeline.test.js b/tests/events/pipeline.test.js new file mode 100644 index 0000000..fb272e9 --- /dev/null +++ b/tests/events/pipeline.test.js @@ -0,0 +1,92 @@ +const test = require('ava') +const sinon = require('sinon') +const EventsPipeline = require('../../src/events/pipeline') +const Event = require('../../src/events/event') + +class TestEvent extends Event { + static get eventName () { + return 'test' + } +} + +const defer = (ms, fn) => () => new Promise((resolve) => { + setTimeout(resolve(fn()), ms) +}) + +test('add & remove listener', t => { + const events = new EventsPipeline() + const remove = events.on('test', sinon.stub()) + + t.is(events.listeners.get('test').length, 1) + + remove() + t.is(events.listeners.get('test').length, 0) +}) + +test('call listeners', async t => { + const events = new EventsPipeline() + const firstListener = sinon.stub() + const secondListener = sinon.stub() + + events.on('test', firstListener) + events.on('test', secondListener) + + const event = new TestEvent() + + await events.emit(event) + + t.true(firstListener.calledWith(event)) + t.true(secondListener.calledWith(event)) +}) + +test('cancel event', async t => { + const secondListener = sinon.stub() + const events = new EventsPipeline([ + ['test', [ + (event) => { + event.cancel() + }, + secondListener + ]] + ]) + + const result = await events.emit(new TestEvent()) + + t.false(secondListener.calledOnce) + t.false(result) +}) + +test('call listeners in exact order', async t => { + const events = new EventsPipeline() + const callsList = [] + const firstListener = () => { + callsList.push('first') + } + const secondListener = () => { + callsList.push('second') + } + + events.on('test', defer(100, firstListener)) + events.on('test', defer(10, secondListener)) + + await events.emit(new TestEvent()) + + t.deepEqual(callsList, [ + 'first', + 'second' + ]) +}) + +test('cloning', t => { + const listener = sinon.stub() + + const events = new EventsPipeline() + events.on('test', listener) + + const cloned = events.clone() + + t.false(events === cloned) + t.false(events.listeners === cloned.listeners) + t.false(events.listeners.get('test') === cloned.listeners.get('test')) + t.deepEqual(events.listeners.get('test'), cloned.listeners.get('test')) +}) From 12b60e12167f1794ee9665d48abcaef5f02da769 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Thu, 14 May 2020 16:23:27 +0200 Subject: [PATCH 02/20] Allow to retrive queries builder corresponding model --- src/query-builder.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/query-builder.js b/src/query-builder.js index f336bcf..2c2f774 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -2,6 +2,7 @@ const BaseQueryBuilder = require('knex/lib/query/builder') const { KexError } = require('./errors') /** @typedef { import('knex/lib/client') } KnexClient */ +/** @typedef { import('./model') } Model */ /** * @callback Scope @@ -142,13 +143,20 @@ class QueryBuilder extends BaseQueryBuilder { /** * Create the a new child class of QueryBuilder * - * @param {import('./model')} Model + * @param {Model} Model * @returns {typeof QueryBuilder} */ const createChildClass = (Model) => { class ChildQueryBuilder extends QueryBuilder { static get tableName () { - return Model.tableName + return this.Model.tableName + } + + /** + * @return {Model} + */ + static get Model () { + return Model } } From d47dd97830592877acbb101489736fee48d8e3ce Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Thu, 14 May 2020 17:25:26 +0200 Subject: [PATCH 03/20] Add base events --- src/events/deleted.js | 18 ++++++++++++++++++ src/events/deleting.js | 28 ++++++++++++++++++++++++++++ src/events/event.js | 8 ++++++++ src/events/fetched.js | 18 ++++++++++++++++++ src/events/fetching.js | 27 +++++++++++++++++++++++++++ src/events/index.js | 9 +++++++++ src/events/updated.js | 20 ++++++++++++++++++++ src/events/updating.js | 30 ++++++++++++++++++++++++++++++ 8 files changed, 158 insertions(+) create mode 100644 src/events/deleted.js create mode 100644 src/events/deleting.js create mode 100644 src/events/fetched.js create mode 100644 src/events/fetching.js create mode 100644 src/events/index.js create mode 100644 src/events/updated.js create mode 100644 src/events/updating.js diff --git a/src/events/deleted.js b/src/events/deleted.js new file mode 100644 index 0000000..2bd671f --- /dev/null +++ b/src/events/deleted.js @@ -0,0 +1,18 @@ +const Event = require('./event') + +class Deleted extends Event { + static get eventName () { + return 'deleted' + } + + /** + * @param {*} results + */ + constructor (results) { + super() + + this.result = results + } +} + +module.exports = Deleted diff --git a/src/events/deleting.js b/src/events/deleting.js new file mode 100644 index 0000000..2366a86 --- /dev/null +++ b/src/events/deleting.js @@ -0,0 +1,28 @@ +const Event = require('./event') +const Deleted = require('./deleted') + +class Deleting extends Event { + static get eventName () { + return 'deleting' + } + + /** + * @param {import('knex/lib/query/builder')} queryBuilder + * @param {String|String[]} returning + */ + constructor (queryBuilder, returning) { + super() + + this.queryBuilder = queryBuilder + this.returning = returning + } + + /** + * @inheritdoc + */ + toAfterEvent (results) { + return new Deleted(results) + } +} + +module.exports = Deleting diff --git a/src/events/event.js b/src/events/event.js index 70d2572..326bb58 100644 --- a/src/events/event.js +++ b/src/events/event.js @@ -12,6 +12,14 @@ class Event { cancel () { this.cancelled = true } + + /** + * @param {*} results + * @return {Event} + */ + toAfterEvent (results) { + throw new KexError('toAfterEvent() should be implemented in the child class') + } } module.exports = Event diff --git a/src/events/fetched.js b/src/events/fetched.js new file mode 100644 index 0000000..4a6208e --- /dev/null +++ b/src/events/fetched.js @@ -0,0 +1,18 @@ +const Event = require('./event') + +class Fetched extends Event { + static get eventName () { + return 'fetched' + } + + /** + * @param {Object|Object[]} results + */ + constructor (results) { + super() + + this.results = results + } +} + +module.exports = Fetched diff --git a/src/events/fetching.js b/src/events/fetching.js new file mode 100644 index 0000000..ed57046 --- /dev/null +++ b/src/events/fetching.js @@ -0,0 +1,27 @@ +const Event = require('./event') +const Fetched = require('./fetched') + +class Fetching extends Event { + static get eventName () { + return 'fetching' + } + + /** + * @param {import('knex/lib/query/builder')} queryBuilder + */ + constructor (queryBuilder) { + super() + + this.queryBuilder = queryBuilder + } + + /** + * @param {*} results + * @return {import('./fetched')} + */ + toAfterEvent (results) { + return new Fetched(results) + } +} + +module.exports = Fetching diff --git a/src/events/index.js b/src/events/index.js new file mode 100644 index 0000000..4d1af49 --- /dev/null +++ b/src/events/index.js @@ -0,0 +1,9 @@ +module.exports = { + Pipeline: require('./pipeline'), + DeletingEvent: require('./deleting'), + DeletedEvent: require('./deleted'), + FetchingEvent: require('./fetching'), + FetchedEvent: require('./fetched'), + UpdatingEvent: require('./updating'), + UpdatedEvent: require('./updated') +} diff --git a/src/events/updated.js b/src/events/updated.js new file mode 100644 index 0000000..57cf17c --- /dev/null +++ b/src/events/updated.js @@ -0,0 +1,20 @@ +const Event = require('./event') + +class Updated extends Event { + static get eventName () { + return 'updated' + } + + /** + * @param {*} results + * @param {Object|Object[]} values + */ + constructor (results, values) { + super() + + this.results = results + this.values = values + } +} + +module.exports = Updated diff --git a/src/events/updating.js b/src/events/updating.js new file mode 100644 index 0000000..cce87fd --- /dev/null +++ b/src/events/updating.js @@ -0,0 +1,30 @@ +const Event = require('./event') +const Updated = require('./updated') + +class Updating extends Event { + static get eventName () { + return 'updating' + } + + /** + * @param {import('knex/lib/query/builder')} queryBuilder + * @param {Object|Object[]} values + * @param {String|String[]} returning + */ + constructor (queryBuilder, values, returning) { + super() + + this.queryBuilder = queryBuilder + this.values = values + this.returning = returning + } + + /** + * @inheritdoc + */ + toAfterEvent (results) { + return new Updated(results, this.values) + } +} + +module.exports = Updating From 994b0eec51c4621bf174302f396c0403929901e5 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Thu, 14 May 2020 19:42:49 +0200 Subject: [PATCH 04/20] Setup events instance in Model and QueryBuilder --- src/events/index.js | 2 +- src/model.js | 3 +++ src/query-builder.js | 30 +++++++++++++++++++++--------- tests/model-events.test.js | 24 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 tests/model-events.test.js diff --git a/src/events/index.js b/src/events/index.js index 4d1af49..98a6117 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -1,5 +1,5 @@ module.exports = { - Pipeline: require('./pipeline'), + EventsPipeline: require('./pipeline'), DeletingEvent: require('./deleting'), DeletedEvent: require('./deleted'), FetchingEvent: require('./fetching'), diff --git a/src/model.js b/src/model.js index db8eee4..e04328f 100644 --- a/src/model.js +++ b/src/model.js @@ -2,6 +2,7 @@ const pluralize = require('pluralize') const snakeCase = require('lodash.snakecase') const QueryBuilder = require('./query-builder') const { KexError } = require('./errors') +const { EventsPipeline } = require('./events') /** @typedef { import('./plugins/soft-deletes').SoftDeleteOptions } SoftDeleteOptions */ /** @typedef { import('./relations/relation') } Relation */ @@ -39,6 +40,7 @@ class Model { this.options = options this.QueryBuilder = QueryBuilder.createChildClass(this) this.booted = false + this.events = new EventsPipeline() } get tableName () { @@ -53,6 +55,7 @@ class Model { query () { this.bootIfNotBooted() + return this.QueryBuilder.create(this.kex.getKnexClient()) } diff --git a/src/query-builder.js b/src/query-builder.js index 2c2f774..9975344 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -3,6 +3,7 @@ const { KexError } = require('./errors') /** @typedef { import('knex/lib/client') } KnexClient */ /** @typedef { import('./model') } Model */ +/** @typedef { import('./events/pipeline') } EventsPipeline */ /** * @callback Scope @@ -28,15 +29,6 @@ class QueryBuilder extends BaseQueryBuilder { return BaseQueryBuilder.prototype.table.call(qb, this.tableName) } - table () { - throw new KexError('Can\'t use table() in models query builder') - } - - newInstance () { - const Builder = this.constructor - return new Builder(this.client) - } - /** * Add a global scope to the model * @@ -74,6 +66,14 @@ class QueryBuilder extends BaseQueryBuilder { this.prototype[methodName] = fn } + /** + * @return {Model} + * @abstract + */ + static get Model () { + throw new KexError('The Model getter is not implemented') + } + /** * @inheritdoc */ @@ -82,6 +82,18 @@ class QueryBuilder extends BaseQueryBuilder { /** @type {Set} */ this.ignoredScopes = new Set() + + /** @type {EventsPipeline} */ + this.events = this.constructor.Model.events.clone() + } + + table () { + throw new KexError('Can\'t use table() in models query builder') + } + + newInstance () { + const Builder = this.constructor + return new Builder(this.client) } /** diff --git a/tests/model-events.test.js b/tests/model-events.test.js new file mode 100644 index 0000000..a7cf473 --- /dev/null +++ b/tests/model-events.test.js @@ -0,0 +1,24 @@ +const test = require('ava') +const sinon = require('sinon') +const setupDb = require('./setup-db') +const { createKex } = require('./utils') + +setupDb() + +test.beforeEach(t => { + const kex = createKex(t) + t.context.User = kex.createModel('User') +}) + +test('events passing to query builder', t => { + const { User } = t.context + const spy = sinon.spy(User.events, 'clone') + + User.events.on('fetched', sinon.stub()) + + const queryEvents = User.query().events + + t.deepEqual(User.events, queryEvents) + t.false(User.events === queryEvents) + t.true(spy.calledOnce) +}) From 7e7a8dd202880f7fdb29c0d4e7a76a75fcf67bb0 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Thu, 14 May 2020 20:05:51 +0200 Subject: [PATCH 05/20] Emit fetching / fetched events --- src/query-builder.js | 18 ++++++++++++++ tests/model-events.test.js | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/query-builder.js b/src/query-builder.js index 9975344..09bf520 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -1,5 +1,6 @@ const BaseQueryBuilder = require('knex/lib/query/builder') const { KexError } = require('./errors') +const { FetchingEvent } = require('./events') /** @typedef { import('knex/lib/client') } KnexClient */ /** @typedef { import('./model') } Model */ @@ -150,6 +151,23 @@ class QueryBuilder extends BaseQueryBuilder { Object.assign(qb, builder) }) } + + async then () { + const fetching = new FetchingEvent(this) + + await this.events.emit(fetching) + + if (fetching.cancelled) { + return undefined + } + + const results = await super.then() + const fetched = fetching.toAfterEvent(results) + + await this.events.emit(fetched) + + return fetched.results + } } /** diff --git a/tests/model-events.test.js b/tests/model-events.test.js index a7cf473..6c32cb5 100644 --- a/tests/model-events.test.js +++ b/tests/model-events.test.js @@ -2,6 +2,8 @@ const test = require('ava') const sinon = require('sinon') const setupDb = require('./setup-db') const { createKex } = require('./utils') +const { compareDbResults } = require('./assertions') +const { FetchingEvent, FetchedEvent } = require('../src/events') setupDb() @@ -22,3 +24,49 @@ test('events passing to query builder', t => { t.false(User.events === queryEvents) t.true(spy.calledOnce) }) + +test('fetched/fetching', async t => { + const { User, knex } = t.context + + const fetching = sinon.stub() + const fetched = sinon.stub() + + User.events.on('fetching', fetching) + User.events.on('fetched', fetched) + + const expected = await knex.table('users') + const query = User.query() + const actual = await query + + t.true(fetching.calledWith(new FetchingEvent(query))) + t.true(fetched.calledWith(new FetchedEvent(expected))) + compareDbResults(t, expected, actual) +}) + +test('fetched/fetching | cancel fetching event', async t => { + const { User } = t.context + + const fetched = sinon.stub() + + User.events.on('fetching', event => { + event.cancel() + }) + User.events.on('fetched', fetched) + + const actual = await User.query() + + t.false(fetched.called) + t.is(actual, undefined) +}) + +test('fetched/fetching | modify end results', async t => { + const { User } = t.context + + User.events.on('fetched', event => { + event.results = 'foo' + }) + + const actual = await User.query() + + t.is(actual, 'foo') +}) From 57511849bc7183152f675dc1cc6593d6176e6a6e Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Fri, 15 May 2020 11:31:10 +0200 Subject: [PATCH 06/20] Emit updating/updated events --- src/query-builder.js | 24 ++++++++---- tests/model-events.test.js | 79 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/query-builder.js b/src/query-builder.js index 09bf520..53870d6 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -1,6 +1,6 @@ const BaseQueryBuilder = require('knex/lib/query/builder') const { KexError } = require('./errors') -const { FetchingEvent } = require('./events') +const { FetchingEvent, UpdatingEvent } = require('./events') /** @typedef { import('knex/lib/client') } KnexClient */ /** @typedef { import('./model') } Model */ @@ -86,6 +86,10 @@ class QueryBuilder extends BaseQueryBuilder { /** @type {EventsPipeline} */ this.events = this.constructor.Model.events.clone() + + this.toEmit = { + event: new FetchingEvent(this) + } } table () { @@ -152,21 +156,27 @@ class QueryBuilder extends BaseQueryBuilder { }) } + update (values, returning) { + this.toEmit.event = new UpdatingEvent(this, values, returning) + + return super.update(values, returning) + } + async then () { - const fetching = new FetchingEvent(this) + const { event } = this.toEmit - await this.events.emit(fetching) + await this.events.emit(event) - if (fetching.cancelled) { + if (event.cancelled) { return undefined } const results = await super.then() - const fetched = fetching.toAfterEvent(results) + const afterEvent = event.toAfterEvent(results) - await this.events.emit(fetched) + await this.events.emit(afterEvent) - return fetched.results + return afterEvent.results } } diff --git a/tests/model-events.test.js b/tests/model-events.test.js index 6c32cb5..5ce7f71 100644 --- a/tests/model-events.test.js +++ b/tests/model-events.test.js @@ -3,7 +3,7 @@ const sinon = require('sinon') const setupDb = require('./setup-db') const { createKex } = require('./utils') const { compareDbResults } = require('./assertions') -const { FetchingEvent, FetchedEvent } = require('../src/events') +const events = require('../src/events') setupDb() @@ -38,8 +38,8 @@ test('fetched/fetching', async t => { const query = User.query() const actual = await query - t.true(fetching.calledWith(new FetchingEvent(query))) - t.true(fetched.calledWith(new FetchedEvent(expected))) + t.true(fetching.calledWith(new events.FetchingEvent(query))) + t.true(fetched.calledWith(new events.FetchedEvent(expected))) compareDbResults(t, expected, actual) }) @@ -70,3 +70,76 @@ test('fetched/fetching | modify end results', async t => { t.is(actual, 'foo') }) + +test.serial('updating/updated', async t => { + const { User, knex } = t.context + + const updating = sinon.stub() + const updated = sinon.stub() + const [userId] = await knex.table('users') + .returning('id') + .insert({ + username: 'arya', + first_name: 'Arya', + last_name: 'Stark', + active: true + }) + + User.events.on('updating', updating) + User.events.on('updated', updated) + + const data = { active: false } + const query = User.query() + .where('id', userId) + .update(data) + + await query + + const check = await User.find(userId) + + await User.find(userId) + .delete() + + t.true(updating.calledWith(new events.UpdatingEvent(query, data))) + t.true(updated.calledWith(new events.UpdatedEvent( + sinon.match.any, + data + ))) + + t.falsy(check.active) +}) + +test.serial('updating/updated | cancel update', async t => { + const { User, knex } = t.context + + const updated = sinon.stub() + const [userId] = await knex.table('users') + .returning('id') + .insert({ + username: 'arya', + first_name: 'Arya', + last_name: 'Stark', + active: true + }) + + User.events.on('updating', (event) => { + event.cancel() + }) + User.events.on('updated', updated) + + const data = { active: false } + const query = User.query() + .where('id', userId) + .update(data) + + await query + + const check = await User.find(userId) + + await User.find(userId) + .delete() + + t.false(updated.called) + + t.truthy(check.active) +}) From 70d0c9e00b5577936a06504a9a8bcf9290a55ad6 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Fri, 15 May 2020 11:56:54 +0200 Subject: [PATCH 07/20] Emit deleting/deleted events --- src/query-builder.js | 7 +++- tests/model-events.test.js | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/query-builder.js b/src/query-builder.js index 53870d6..d97fa68 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -1,6 +1,6 @@ const BaseQueryBuilder = require('knex/lib/query/builder') const { KexError } = require('./errors') -const { FetchingEvent, UpdatingEvent } = require('./events') +const { FetchingEvent, UpdatingEvent, DeletingEvent } = require('./events') /** @typedef { import('knex/lib/client') } KnexClient */ /** @typedef { import('./model') } Model */ @@ -162,6 +162,11 @@ class QueryBuilder extends BaseQueryBuilder { return super.update(values, returning) } + delete (returning) { + this.toEmit.event = new DeletingEvent(this, returning) + return super.delete(returning) + } + async then () { const { event } = this.toEmit diff --git a/tests/model-events.test.js b/tests/model-events.test.js index 5ce7f71..566c632 100644 --- a/tests/model-events.test.js +++ b/tests/model-events.test.js @@ -143,3 +143,69 @@ test.serial('updating/updated | cancel update', async t => { t.truthy(check.active) }) + +test.serial('deleting/deleted', async t => { + const { User, knex } = t.context + + const deleting = sinon.stub() + const deleted = sinon.stub() + const [userId] = await knex.table('users') + .returning('id') + .insert({ + username: 'arya', + first_name: 'Arya', + last_name: 'Stark', + active: true + }) + + User.events.on('deleting', deleting) + User.events.on('deleted', deleted) + + const query = User.query() + .where('id', userId) + .delete() + + await query + + const check = await User.find(userId) + + await knex.table('users') + .where('id', userId) + .delete() + + t.true(deleting.calledWith(new events.DeletingEvent(query))) + t.true(deleted.calledWith(new events.DeletedEvent(sinon.match.any))) + t.falsy(check) +}) + +test.serial('deleting/deleted | cancel event', async t => { + const { User, knex } = t.context + + const deleted = sinon.stub() + const [userId] = await knex.table('users') + .returning('id') + .insert({ + username: 'arya', + first_name: 'Arya', + last_name: 'Stark', + active: true + }) + + User.events.on('deleting', (event) => { + event.cancel() + }) + User.events.on('deleted', deleted) + + await User.query() + .where('id', userId) + .delete() + + const check = await User.find(userId) + + await knex.table('users') + .where('id', userId) + .delete() + + t.false(deleted.called) + t.truthy(check) +}) From a8cc8c09d364aec178c4c0ca9c80a376e9933858 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Fri, 15 May 2020 15:23:00 +0200 Subject: [PATCH 08/20] Emit event only once --- src/events/event.js | 5 +++++ src/events/pipeline.js | 7 +++++++ tests/events/pipeline.test.js | 15 +++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/src/events/event.js b/src/events/event.js index 326bb58..3f68a22 100644 --- a/src/events/event.js +++ b/src/events/event.js @@ -7,12 +7,17 @@ class Event { constructor () { this.cancelled = false + this.emitted = false } cancel () { this.cancelled = true } + markEmitted () { + this.emitted = true + } + /** * @param {*} results * @return {Event} diff --git a/src/events/pipeline.js b/src/events/pipeline.js index 9bc0a11..757325e 100644 --- a/src/events/pipeline.js +++ b/src/events/pipeline.js @@ -34,15 +34,22 @@ class EventsPipeline { * Execute all listeners of given event. * * The listeners are called serially. + * Event instance can be emitted only once. To repeat its emission, create new event. * * @param {Event} event * @return {Promise} the result of calling the listener; * FALSE indicates that event was cancelled */ async emit (event) { + if (event.emitted) { + return false + } + const { eventName } = event.constructor const list = this.listeners.get(eventName) || [] + event.markEmitted() + for (let i = 0; i < list.length; i++) { await list[i](event) diff --git a/tests/events/pipeline.test.js b/tests/events/pipeline.test.js index fb272e9..1e6834d 100644 --- a/tests/events/pipeline.test.js +++ b/tests/events/pipeline.test.js @@ -35,10 +35,25 @@ test('call listeners', async t => { await events.emit(event) + t.true(event.emitted) t.true(firstListener.calledWith(event)) t.true(secondListener.calledWith(event)) }) +test('prevent second emission of the same event', async t => { + const events = new EventsPipeline() + const firstListener = sinon.stub() + + events.on('test', firstListener) + + const event = new TestEvent() + event.markEmitted() + + await events.emit(event) + + t.false(firstListener.called) +}) + test('cancel event', async t => { const secondListener = sinon.stub() const events = new EventsPipeline([ From 9f370e6a60e9a2d427052222b7d9f0f19e3e2112 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Fri, 15 May 2020 16:48:58 +0200 Subject: [PATCH 09/20] Create event based on query method --- src/events/deleting.js | 9 +++--- src/events/event.js | 4 +++ src/events/fetching.js | 9 ------ src/events/pipeline.js | 2 +- src/events/updating.js | 9 ++++-- src/query-builder.js | 38 ++++++++++++++----------- tests/model-events.test.js | 57 ++++++++++++++++++++++++++++++++------ 7 files changed, 86 insertions(+), 42 deletions(-) diff --git a/src/events/deleting.js b/src/events/deleting.js index 2366a86..48bfdd8 100644 --- a/src/events/deleting.js +++ b/src/events/deleting.js @@ -7,16 +7,17 @@ class Deleting extends Event { } /** - * @param {import('knex/lib/query/builder')} queryBuilder * @param {String|String[]} returning */ - constructor (queryBuilder, returning) { + constructor (returning) { super() - - this.queryBuilder = queryBuilder this.returning = returning } + mutateQueryBuilder (qb) { + qb._single.returning = this.returning + } + /** * @inheritdoc */ diff --git a/src/events/event.js b/src/events/event.js index 3f68a22..6d51590 100644 --- a/src/events/event.js +++ b/src/events/event.js @@ -18,6 +18,10 @@ class Event { this.emitted = true } + mutateQueryBuilder (qb) { + // extend on when required + } + /** * @param {*} results * @return {Event} diff --git a/src/events/fetching.js b/src/events/fetching.js index ed57046..e14f5e0 100644 --- a/src/events/fetching.js +++ b/src/events/fetching.js @@ -6,15 +6,6 @@ class Fetching extends Event { return 'fetching' } - /** - * @param {import('knex/lib/query/builder')} queryBuilder - */ - constructor (queryBuilder) { - super() - - this.queryBuilder = queryBuilder - } - /** * @param {*} results * @return {import('./fetched')} diff --git a/src/events/pipeline.js b/src/events/pipeline.js index 757325e..84da5eb 100644 --- a/src/events/pipeline.js +++ b/src/events/pipeline.js @@ -34,7 +34,7 @@ class EventsPipeline { * Execute all listeners of given event. * * The listeners are called serially. - * Event instance can be emitted only once. To repeat its emission, create new event. + * Event instance can be emitted only once. To repeat it emission, create new event. * * @param {Event} event * @return {Promise} the result of calling the listener; diff --git a/src/events/updating.js b/src/events/updating.js index cce87fd..be03091 100644 --- a/src/events/updating.js +++ b/src/events/updating.js @@ -7,18 +7,21 @@ class Updating extends Event { } /** - * @param {import('knex/lib/query/builder')} queryBuilder * @param {Object|Object[]} values * @param {String|String[]} returning */ - constructor (queryBuilder, values, returning) { + constructor (values, returning) { super() - this.queryBuilder = queryBuilder this.values = values this.returning = returning } + mutateQueryBuilder (qb) { + qb._single.update = this.values + qb._single.returning = this.returning + } + /** * @inheritdoc */ diff --git a/src/query-builder.js b/src/query-builder.js index d97fa68..0dac2b9 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -5,6 +5,7 @@ const { FetchingEvent, UpdatingEvent, DeletingEvent } = require('./events') /** @typedef { import('knex/lib/client') } KnexClient */ /** @typedef { import('./model') } Model */ /** @typedef { import('./events/pipeline') } EventsPipeline */ +/** @typedef { import('./events/event') } Event */ /** * @callback Scope @@ -86,10 +87,6 @@ class QueryBuilder extends BaseQueryBuilder { /** @type {EventsPipeline} */ this.events = this.constructor.Model.events.clone() - - this.toEmit = { - event: new FetchingEvent(this) - } } table () { @@ -156,19 +153,8 @@ class QueryBuilder extends BaseQueryBuilder { }) } - update (values, returning) { - this.toEmit.event = new UpdatingEvent(this, values, returning) - - return super.update(values, returning) - } - - delete (returning) { - this.toEmit.event = new DeletingEvent(this, returning) - return super.delete(returning) - } - async then () { - const { event } = this.toEmit + const event = this.createEventToEmit() await this.events.emit(event) @@ -176,6 +162,8 @@ class QueryBuilder extends BaseQueryBuilder { return undefined } + event.mutateQueryBuilder(this) + const results = await super.then() const afterEvent = event.toAfterEvent(results) @@ -183,6 +171,24 @@ class QueryBuilder extends BaseQueryBuilder { return afterEvent.results } + + /** + * Create the event which should be emitted before the query + * + * @return {Event} + */ + createEventToEmit () { + switch (this._method) { + case 'update': + return new UpdatingEvent(this._single.update, this._single.returning) + + case 'del': + return new DeletingEvent(this._single.returning) + + default: + return new FetchingEvent() + } + } } /** diff --git a/tests/model-events.test.js b/tests/model-events.test.js index 566c632..4be1106 100644 --- a/tests/model-events.test.js +++ b/tests/model-events.test.js @@ -7,6 +7,11 @@ const events = require('../src/events') setupDb() +const emitted = event => { + event.markEmitted() + return event +} + test.beforeEach(t => { const kex = createKex(t) t.context.User = kex.createModel('User') @@ -35,11 +40,10 @@ test('fetched/fetching', async t => { User.events.on('fetched', fetched) const expected = await knex.table('users') - const query = User.query() - const actual = await query + const actual = await User.query() - t.true(fetching.calledWith(new events.FetchingEvent(query))) - t.true(fetched.calledWith(new events.FetchedEvent(expected))) + t.true(fetching.calledWith(emitted(new events.FetchingEvent()))) + t.true(fetched.calledWith(emitted(new events.FetchedEvent(expected)))) compareDbResults(t, expected, actual) }) @@ -100,15 +104,48 @@ test.serial('updating/updated', async t => { await User.find(userId) .delete() - t.true(updating.calledWith(new events.UpdatingEvent(query, data))) - t.true(updated.calledWith(new events.UpdatedEvent( + t.true(updating.calledWith(emitted(new events.UpdatingEvent(data)))) + t.true(updated.calledWith(emitted(new events.UpdatedEvent( sinon.match.any, data - ))) + )))) t.falsy(check.active) }) +test.serial('updating/updated | alter update data', async t => { + const { User, knex } = t.context + + const [userId] = await knex.table('users') + .returning('id') + .insert({ + username: 'arya', + first_name: 'Arya', + last_name: 'Stark', + active: true + }) + + User.events.on('updating', (event) => { + event.values = { + ...event.values, + last_name: 'No name' + } + }) + + const data = { active: false } + await User.query() + .where('id', userId) + .update(data) + + const check = await User.find(userId) + + await User.find(userId) + .delete() + + t.falsy(check.active) + t.is(check.last_name, 'No name') +}) + test.serial('updating/updated | cancel update', async t => { const { User, knex } = t.context @@ -173,8 +210,8 @@ test.serial('deleting/deleted', async t => { .where('id', userId) .delete() - t.true(deleting.calledWith(new events.DeletingEvent(query))) - t.true(deleted.calledWith(new events.DeletedEvent(sinon.match.any))) + t.true(deleting.calledWith(emitted(new events.DeletingEvent()))) + t.true(deleted.calledWith(emitted(new events.DeletedEvent(sinon.match.any)))) t.falsy(check) }) @@ -209,3 +246,5 @@ test.serial('deleting/deleted | cancel event', async t => { t.false(deleted.called) t.truthy(check) }) + +test.todo('inserting/inserted') From aff7a002f99357da1b97398293bb0071dcf14019 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Fri, 15 May 2020 17:05:47 +0200 Subject: [PATCH 10/20] Emit inserting/inserted event --- src/events/index.js | 4 +- src/events/inserted.js | 19 +++++++++ src/events/inserting.js | 33 +++++++++++++++ src/query-builder.js | 10 ++++- tests/model-events.test.js | 85 +++++++++++++++++++++++++++++++++++++- 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/events/inserted.js create mode 100644 src/events/inserting.js diff --git a/src/events/index.js b/src/events/index.js index 98a6117..632a467 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -5,5 +5,7 @@ module.exports = { FetchingEvent: require('./fetching'), FetchedEvent: require('./fetched'), UpdatingEvent: require('./updating'), - UpdatedEvent: require('./updated') + UpdatedEvent: require('./updated'), + InsertingEvent: require('./inserting'), + InsertedEvent: require('./inserted') } diff --git a/src/events/inserted.js b/src/events/inserted.js new file mode 100644 index 0000000..b5d6830 --- /dev/null +++ b/src/events/inserted.js @@ -0,0 +1,19 @@ +const Event = require('./event') + +class Inserted extends Event { + static get eventName () { + return 'inserted' + } + + /** + * @param {*} results + * @param {Object|Object[]} values + */ + constructor (results, values) { + super() + this.results = results + this.values = values + } +} + +module.exports = Inserted diff --git a/src/events/inserting.js b/src/events/inserting.js new file mode 100644 index 0000000..6dbd6cf --- /dev/null +++ b/src/events/inserting.js @@ -0,0 +1,33 @@ +const Event = require('./event') +const Inserted = require('./inserted') + +class Inserting extends Event { + static get eventName () { + return 'inserting' + } + + /** + * @param {Object|Object[]} values + * @param {String|String[]} returning + */ + constructor (values, returning) { + super() + + this.values = values + this.returning = returning + } + + mutateQueryBuilder (qb) { + qb._single.insert = this.values + qb._single.returning = this.returning + } + + /** + * @inheritdoc + */ + toAfterEvent (results) { + return new Inserted(results, this.values) + } +} + +module.exports = Inserting diff --git a/src/query-builder.js b/src/query-builder.js index 0dac2b9..79c7078 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -1,6 +1,11 @@ const BaseQueryBuilder = require('knex/lib/query/builder') const { KexError } = require('./errors') -const { FetchingEvent, UpdatingEvent, DeletingEvent } = require('./events') +const { + FetchingEvent, + UpdatingEvent, + DeletingEvent, + InsertingEvent +} = require('./events') /** @typedef { import('knex/lib/client') } KnexClient */ /** @typedef { import('./model') } Model */ @@ -185,6 +190,9 @@ class QueryBuilder extends BaseQueryBuilder { case 'del': return new DeletingEvent(this._single.returning) + case 'insert': + return new InsertingEvent(this._single.insert, this._single.returning) + default: return new FetchingEvent() } diff --git a/tests/model-events.test.js b/tests/model-events.test.js index 4be1106..6f2bafb 100644 --- a/tests/model-events.test.js +++ b/tests/model-events.test.js @@ -247,4 +247,87 @@ test.serial('deleting/deleted | cancel event', async t => { t.truthy(check) }) -test.todo('inserting/inserted') +test.serial('inserting/inserted', async t => { + const { User } = t.context + + const inserting = sinon.stub() + const inserted = sinon.stub() + const data = { + username: 'arya', + first_name: 'Arya', + last_name: 'Stark', + active: true + } + + User.events.on('inserting', inserting) + User.events.on('inserted', inserted) + + const [userId] = await User.returning('id') + .insert(data) + + await User.findOrFail(userId) + await User.find(userId) + .delete() + + t.true(inserting.calledWith(emitted(new events.InsertingEvent(data, 'id')))) + t.true(inserted.calledWith(emitted(new events.InsertedEvent( + sinon.match.any, + data + )))) +}) + +test.serial('inserting/inserted | cancel inserting', async t => { + const { User } = t.context + + const inserted = sinon.stub() + const data = { + username: 'arya', + first_name: 'Arya', + last_name: 'Stark', + active: true + } + + User.events.on('inserting', (event) => { + event.cancel() + }) + User.events.on('inserted', inserted) + + const [{ count: usersCount }] = await User.query().count() + + const result = await User.returning('id') + .insert(data) + + const [{ count: checkCount }] = await User.query().count() + + t.false(inserted.called) + t.is(checkCount, usersCount) + t.falsy(result) +}) + +test.serial('inserting/inserted | modify data', async t => { + const { User } = t.context + + const data = { + username: 'arya', + first_name: 'Arya', + last_name: 'Stark', + active: true + } + + User.events.on('inserting', (event) => { + event.values = { + ...event.values, + active: false + } + }) + + const [userId] = await User.returning('id') + .insert(data) + + const check = await User.findOrFail(userId) + + await User.find(userId) + .delete() + + t.falsy(check.active) +}) From b954725770f1284f685a213d2debc65dd6ffd752 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Sun, 17 May 2020 17:09:55 +0200 Subject: [PATCH 11/20] Install faker --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index 92464af..5b7c39e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "ava": "^3.6.0", + "faker": "^4.1.0", "husky": "^4.2.3", "knex": "^0.20.13", "lint-staged": "^10.1.1", diff --git a/yarn.lock b/yarn.lock index a663e36..de1198e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1512,6 +1512,11 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +faker@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/faker/-/faker-4.1.0.tgz#1e45bbbecc6774b3c195fad2835109c6d748cc3f" + integrity sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8= + fast-deep-equal@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" From bbd8471d89cac2beff8b3f0b4c7dddf4326e56f9 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Sun, 17 May 2020 17:10:06 +0200 Subject: [PATCH 12/20] Add userFactory test util --- tests/utils.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/utils.js b/tests/utils.js index 2efd620..42d5350 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -1,4 +1,5 @@ const test = require('ava') +const faker = require('faker') const { Kex } = require('../') const createKex = (t, options = {}) => { @@ -17,4 +18,11 @@ const onlyForClient = (dbClient, name, testFn) => { test(name, testFn) } -module.exports = { createKex, onlyForClient } +const userFactory = () => ({ + username: faker.internet.userName(), + first_name: faker.name.firstName(), + last_name: faker.name.lastName(), + active: true +}) + +module.exports = { createKex, onlyForClient, userFactory } From 51401146958c0e08149c8828d410135ab18a3098 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Sun, 17 May 2020 17:30:19 +0200 Subject: [PATCH 13/20] Add Model.on() proxy --- src/events/pipeline.js | 10 ++++++++-- src/model.js | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/events/pipeline.js b/src/events/pipeline.js index 84da5eb..013ecfc 100644 --- a/src/events/pipeline.js +++ b/src/events/pipeline.js @@ -1,6 +1,12 @@ -/** @typedef {Map} Listeners */ /** @typedef { import('./event') } Event */ +/** + * @callback EventListener + * @param {Event} event + */ + +/** @typedef {Map} Listeners */ + class EventsPipeline { /** * @param {Array} listeners entries for listeners mapTo @@ -12,7 +18,7 @@ class EventsPipeline { /** * @param {String} eventName - * @param {Function} listener + * @param {EventListener} listener * @return {Function} a callback which removes the listener */ on (eventName, listener) { diff --git a/src/model.js b/src/model.js index e04328f..0cf61e2 100644 --- a/src/model.js +++ b/src/model.js @@ -8,6 +8,7 @@ const { EventsPipeline } = require('./events') /** @typedef { import('./relations/relation') } Relation */ /** @typedef { import('./query-builder').Scope } Scope */ /** @typedef { import('./plugins/timestamps').TimestampsOptions } TimestampsOptions */ +/** @typedef { import('./events/pipeline').EventListener } EventListener */ /** * @typedef {Object} ModelOptions @@ -77,6 +78,16 @@ class Model { } } + /** + * @param {String} eventName + * @param {EventListener} listener + * @return {Model} + */ + on (eventName, listener) { + this.events.on(eventName, listener) + return this + } + /** * @private */ From c3399a01cf4c7e783f5f20da83f83bf88e02c721 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Sun, 17 May 2020 17:31:36 +0200 Subject: [PATCH 14/20] Use model events to update timestamps --- src/plugins/timestamps.js | 52 +++------ tests/migrations/1-users.js | 4 + tests/plugins/timestamps.test.js | 195 ++++++++++++++++++++++--------- 3 files changed, 158 insertions(+), 93 deletions(-) diff --git a/src/plugins/timestamps.js b/src/plugins/timestamps.js index b182f6d..8f980f8 100644 --- a/src/plugins/timestamps.js +++ b/src/plugins/timestamps.js @@ -7,13 +7,13 @@ const setDateField = (name) => item => ({ /** * @typedef {Object} TimestampsOptions - * @property {String} [deletedAtColumn=deleted_at] + * @property {String} [createdAtColumn=created_at] * @property {String} [updatedAtColumn=updated_at] */ /** @type {TimestampsOptions} */ const defaults = { - deletedAtColumn: 'deleted_at', + createdAtColumn: 'created_at', updatedAtColumn: 'updated_at' } @@ -27,47 +27,27 @@ module.exports = (Model) => { return } - const { QueryBuilder } = Model - const timestampsOptions = { + const options = { ...defaults, ...timestamps } - const { - createdAtColumn = 'created_at', - updatedAtColumn = 'updated_at' - } = timestampsOptions + const setUpdatedAt = setDateField(options.updatedAtColumn) + const setCreatedAt = setDateField(options.createdAtColumn) + const setBothFields = flow([ + setUpdatedAt, + setCreatedAt + ]) - const { - insert: insertMethod, - update: updateMethod - } = QueryBuilder.prototype - - const setUpdatedAt = setDateField(updatedAtColumn) - const setCreatedAt = setDateField(createdAtColumn) - - QueryBuilder.extend({ - methodName: 'update', - force: true, - fn (values, returning) { - return updateMethod.call(this, setUpdatedAt(values), returning) - } + Model.on('updating', event => { + event.values = setUpdatedAt(event.values) }) - QueryBuilder.extend({ - methodName: 'insert', - force: true, - fn (values, returning) { - const update = flow([ - setUpdatedAt, - setCreatedAt - ]) - - const newValues = Array.isArray(values) - ? values.map(update) - : update(values) + Model.on('inserting', event => { + const { values } = event - return insertMethod.call(this, newValues, returning) - } + event.values = Array.isArray(values) + ? values.map(setBothFields) + : setBothFields(values) }) } diff --git a/tests/migrations/1-users.js b/tests/migrations/1-users.js index afaedeb..77abc07 100644 --- a/tests/migrations/1-users.js +++ b/tests/migrations/1-users.js @@ -7,6 +7,10 @@ module.exports = { table.string('first_name') table.string('last_name') table.boolean('active').default(false) + table.datetime('created').nullable().default(null) + table.datetime('updated').nullable().default(null) + table.datetime('created_at').nullable().default(null) + table.datetime('updated_at').nullable().default(null) }) }, diff --git a/tests/plugins/timestamps.test.js b/tests/plugins/timestamps.test.js index d2700be..2ae8b25 100644 --- a/tests/plugins/timestamps.test.js +++ b/tests/plugins/timestamps.test.js @@ -1,97 +1,178 @@ const test = require('ava') const sinon = require('sinon') const setupDb = require('../setup-db') -const { equalQueries } = require('../assertions') -const { createKex } = require('../utils') +const { createKex, userFactory } = require('../utils') setupDb() -test.before(() => { - sinon.useFakeTimers({ now: new Date() }) +const timestampFields = { + created: null, + created_at: null, + updated: null, + updated_at: null +} +const userData = userFactory() + +test.before(t => { + const clock = sinon.useFakeTimers({ now: new Date() }) + t.context.clock = clock }) -test('disabled timestamps', t => { +test.beforeEach(async t => { const { knex } = t.context - const User = createKex(t).createModel('User') - equalQueries(t, knex.from('users').insert({ foo: 1 }), User.insert({ foo: 1 })) - equalQueries(t, knex.from('users').update({ foo: 1 }), User.query().update({ foo: 1 })) + const trx = await knex.transaction() + const kex = createKex(t, { + knexClientResolver: () => trx.client + }) + + Object.assign(t.context, { kex, trx }) }) -test('insert | default columns', async t => { - const { knex } = t.context - const User = createKex(t).createModel('User', { - timestamps: true - }) +test.afterEach.always(async t => { + const { trx } = t.context + + await trx.rollback() +}) + +test.serial('disabled timestamps', async t => { + const { kex } = t.context - const expected = knex.table('users').insert({ - foo: 1, - updated_at: new Date(), - created_at: new Date() + const User = kex.createModel('User') + const [id] = await User.returning('id') + .insert(userData) + + await User.query() + .where({ + ...userData, + ...timestampFields + }) + .firstOrFail() + + await User.find(id) + .update({ active: false }) + + await User.query() + .where({ + ...userData, + ...timestampFields, + active: false + }) + .firstOrFail() + + t.pass() +}) + +test.serial('insert | default columns', async t => { + const { kex } = t.context + const User = kex.createModel('User', { + timestamps: true }) - equalQueries(t, expected, User.insert({ foo: 1 })) + await User.insert(userData) + await User.query() + .where({ + ...userData, + ...timestampFields, + updated_at: new Date(), + created_at: new Date() + }) + .firstOrFail() + + t.pass() }) -test('insert | list of items', async t => { - const { knex } = t.context - const User = createKex(t).createModel('User', { +test.serial('insert | list of items', async t => { + const { kex } = t.context + const User = kex.createModel('User', { timestamps: true }) const data = [ - { foo: 1 }, - { foo: 2 } + userFactory(), + userFactory() ] - const expected = knex.table('users').insert(data.map(item => ({ - ...item, - updated_at: new Date(), - created_at: new Date() - }))) + await User.insert(data) - equalQueries(t, expected, User.insert(data)) -}) + await Promise.all(data.map(item => User.query() + .where({ + ...item, + ...timestampFields, + updated_at: new Date(), + created_at: new Date() + }) + .firstOrFail() + )) -test('insert | custom column name', async t => { - const { knex } = t.context - const User = createKex(t).createModel('User', { - timestamps: { createdAtColumn: 'createdAt', updatedAtColumn: 'updatedAt' } - }) + t.pass() +}) - const expected = knex.table('users').insert({ - foo: 1, - updatedAt: new Date(), - createdAt: new Date() +test.serial('insert | custom column name', async t => { + const { kex } = t.context + const User = kex.createModel('User', { + timestamps: { createdAtColumn: 'created', updatedAtColumn: 'updated' } }) - equalQueries(t, expected, User.insert({ foo: 1 })) + await User.insert(userData) + await User.query() + .where({ + ...userData, + ...timestampFields, + created: new Date(), + updated: new Date() + }) + .firstOrFail() + + t.pass() }) -test('update | default columns', async t => { - const { knex } = t.context - const User = createKex(t).createModel('User', { +test.serial('update | default columns', async t => { + const { kex, clock } = t.context + const User = kex.createModel('User', { timestamps: true }) - const expected = knex.table('users').update({ - foo: 1, - updated_at: new Date() - }) + const createdAt = new Date() + const [id] = await User.returning('id') + .insert(userData) - equalQueries(t, expected, User.query().update({ foo: 1 })) + clock.tick(1000) + + await User.find(id) + .update({ active: false }) + + await User.query() + .where({ + ...userData, + active: false, + created_at: createdAt, + updated_at: new Date() + }) + .firstOrFail() + + t.pass() }) -test('update | custom column name', async t => { - const { knex } = t.context - const User = createKex(t).createModel('User', { - timestamps: { createdAtColumn: 'createdAt', updatedAtColumn: 'updatedAt' } +test.serial('update | custom column name', async t => { + const { kex } = t.context + const User = kex.createModel('User', { + timestamps: { updatedAtColumn: 'updated' } }) - const expected = knex.table('users').update({ - foo: 1, - updatedAt: new Date() - }) + const jon = await User.where('username', 'jon') + .firstOrFail() + + await User.find(jon.id) + .update({ active: false }) + + await User.query() + .where({ + ...jon, + active: false, + updated: new Date() + }) + .firstOrFail() - equalQueries(t, expected, User.query().update({ foo: 1 })) + t.pass() }) From d94afe66ca88f4ee9f9582d53a4bf5eeec18dc9f Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Sun, 17 May 2020 18:21:54 +0200 Subject: [PATCH 15/20] Bind query builder instance to event listeners Because: - some plugins will need the qb instance Although it's not possible to manipulate the query when the event is emitted, some plugins will require the instance of query builder to retrieve some values. With binding it's possible to fetch them. --- src/events/pipeline.js | 7 +++++-- src/query-builder.js | 4 ++-- tests/events/pipeline.test.js | 13 +++++++++++++ tests/model-events.test.js | 14 ++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/events/pipeline.js b/src/events/pipeline.js index 013ecfc..90e5be8 100644 --- a/src/events/pipeline.js +++ b/src/events/pipeline.js @@ -43,10 +43,11 @@ class EventsPipeline { * Event instance can be emitted only once. To repeat it emission, create new event. * * @param {Event} event + * @param {*} [bind] value to bind with the listener * @return {Promise} the result of calling the listener; * FALSE indicates that event was cancelled */ - async emit (event) { + async emit (event, bind = null) { if (event.emitted) { return false } @@ -57,7 +58,9 @@ class EventsPipeline { event.markEmitted() for (let i = 0; i < list.length; i++) { - await list[i](event) + const fn = list[i] + + await fn.call(bind, event) if (event.cancelled) { return false diff --git a/src/query-builder.js b/src/query-builder.js index 79c7078..3edac0b 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -161,7 +161,7 @@ class QueryBuilder extends BaseQueryBuilder { async then () { const event = this.createEventToEmit() - await this.events.emit(event) + await this.events.emit(event, this) if (event.cancelled) { return undefined @@ -172,7 +172,7 @@ class QueryBuilder extends BaseQueryBuilder { const results = await super.then() const afterEvent = event.toAfterEvent(results) - await this.events.emit(afterEvent) + await this.events.emit(afterEvent, this) return afterEvent.results } diff --git a/tests/events/pipeline.test.js b/tests/events/pipeline.test.js index 1e6834d..f34990e 100644 --- a/tests/events/pipeline.test.js +++ b/tests/events/pipeline.test.js @@ -105,3 +105,16 @@ test('cloning', t => { t.false(events.listeners.get('test') === cloned.listeners.get('test')) t.deepEqual(events.listeners.get('test'), cloned.listeners.get('test')) }) + +test('binding', async t => { + const fakeContext = {} + const events = new EventsPipeline() + t.plan(1) + + events.on('test', function () { + t.is(fakeContext, this) + }) + + const event = new TestEvent() + await events.emit(event, fakeContext) +}) diff --git a/tests/model-events.test.js b/tests/model-events.test.js index 6f2bafb..d3b6081 100644 --- a/tests/model-events.test.js +++ b/tests/model-events.test.js @@ -331,3 +331,17 @@ test.serial('inserting/inserted | modify data', async t => { t.falsy(check.active) }) + +test('query builder binding', async t => { + const { User } = t.context + const assert = function () { + t.true(this instanceof User.QueryBuilder) + } + + t.plan(2) + + User.on('fetching', assert) + User.on('fetched', assert) + + await User.query() +}) From 4c469d7f6386fa83d4326e4c811e3794fb2af0a0 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Sun, 17 May 2020 18:52:57 +0200 Subject: [PATCH 16/20] Install @baethon/promise-duck Because: - it helps with overwriting of the `then()` method --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index 5b7c39e..6f20e1e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint:fix": "standard --fix" }, "dependencies": { + "@baethon/promise-duck": "^1.0.1", "dataloader": "^2.0.0", "lodash.flow": "^3.5.0", "lodash.frompairs": "^4.0.1", diff --git a/yarn.lock b/yarn.lock index de1198e..3ea310e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,6 +30,11 @@ dependencies: regenerator-runtime "^0.13.4" +"@baethon/promise-duck@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@baethon/promise-duck/-/promise-duck-1.0.1.tgz#6c41a2451d3a8140cb8eaecaa928a1fe4c5fcabe" + integrity sha512-TPVq2hMtBTe9PDGzr2bcNmwAxw5rSWWxNIAkeOpZglS3KHOdJKRWSEjY6a6BSEy9XBKBLw+Z/cmqop22Jw+xQg== + "@concordance/react@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@concordance/react/-/react-2.0.0.tgz#aef913f27474c53731f4fd79cc2f54897de90fde" From e768251e5bbe7920aeb6c6f32452b3ccf1cf2dff Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Sun, 17 May 2020 18:53:53 +0200 Subject: [PATCH 17/20] Use event to get related data --- src/plugins/include/index.js | 14 ++------------ src/query-builder.js | 13 +++++++++++-- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/plugins/include/index.js b/src/plugins/include/index.js index e55d367..898630a 100644 --- a/src/plugins/include/index.js +++ b/src/plugins/include/index.js @@ -5,7 +5,6 @@ const { parseIncludes } = require('./parser') * @param {import('../../model')} Model */ module.exports = (Model) => { - const { QueryBuilder } = Model const related = new Related(Model) Model.QueryBuilder.extend({ @@ -20,16 +19,7 @@ module.exports = (Model) => { } }) - const { then: thenMethod } = QueryBuilder.prototype - - QueryBuilder.extend({ - methodName: 'then', - force: true, - fn (resolve, reject) { - return thenMethod.call(this) - .then(results => related.fetchRelated(results, this.includes)) - .then(resolve) - .catch(reject) - } + Model.on('fetched', async function (event) { + event.results = await related.fetchRelated(event.results, this.includes) }) } diff --git a/src/query-builder.js b/src/query-builder.js index 3edac0b..7b52ca5 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -1,3 +1,4 @@ +const promiseDuck = require('@baethon/promise-duck') const BaseQueryBuilder = require('knex/lib/query/builder') const { KexError } = require('./errors') const { @@ -92,6 +93,9 @@ class QueryBuilder extends BaseQueryBuilder { /** @type {EventsPipeline} */ this.events = this.constructor.Model.events.clone() + + // overwrite native then() method + Object.assign(this, promiseDuck.thenable(this.fetch.bind(this))) } table () { @@ -158,7 +162,12 @@ class QueryBuilder extends BaseQueryBuilder { }) } - async then () { + /** + * Execute the query and fetch the results + * + * @return {Promise<*>} + */ + async fetch () { const event = this.createEventToEmit() await this.events.emit(event, this) @@ -169,7 +178,7 @@ class QueryBuilder extends BaseQueryBuilder { event.mutateQueryBuilder(this) - const results = await super.then() + const results = await BaseQueryBuilder.prototype.then.call(this) const afterEvent = event.toAfterEvent(results) await this.events.emit(afterEvent, this) From 4ddec0d123d442c3e37ec03c610ae5076e51943a Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Mon, 18 May 2020 15:14:00 +0200 Subject: [PATCH 18/20] Fix failing test case for mysql --- tests/plugins/timestamps.test.js | 25 ++++++++++++++++++------- tests/utils.js | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/plugins/timestamps.test.js b/tests/plugins/timestamps.test.js index 2ae8b25..be2c7bc 100644 --- a/tests/plugins/timestamps.test.js +++ b/tests/plugins/timestamps.test.js @@ -1,5 +1,6 @@ const test = require('ava') const sinon = require('sinon') +const faker = require('faker') const setupDb = require('../setup-db') const { createKex, userFactory } = require('../utils') @@ -14,7 +15,11 @@ const timestampFields = { const userData = userFactory() test.before(t => { - const clock = sinon.useFakeTimers({ now: new Date() }) + // eliminate the milliseconds precision from current time + // without it, MySQL might return weird results + const now = Math.floor(new Date().getTime() / 1000) + const clock = sinon.useFakeTimers({ now: new Date(now * 1000) }) + t.context.clock = clock }) @@ -49,14 +54,16 @@ test.serial('disabled timestamps', async t => { }) .firstOrFail() + const firstName = faker.name.firstName() + await User.find(id) - .update({ active: false }) + .update({ first_name: firstName }) await User.query() .where({ ...userData, ...timestampFields, - active: false + first_name: firstName }) .firstOrFail() @@ -139,13 +146,15 @@ test.serial('update | default columns', async t => { clock.tick(1000) + const firstName = faker.name.firstName() + await User.find(id) - .update({ active: false }) + .update({ first_name: firstName }) await User.query() .where({ ...userData, - active: false, + first_name: firstName, created_at: createdAt, updated_at: new Date() }) @@ -163,13 +172,15 @@ test.serial('update | custom column name', async t => { const jon = await User.where('username', 'jon') .firstOrFail() + const firstName = faker.name.firstName() + await User.find(jon.id) - .update({ active: false }) + .update({ first_name: firstName }) await User.query() .where({ ...jon, - active: false, + first_name: firstName, updated: new Date() }) .firstOrFail() diff --git a/tests/utils.js b/tests/utils.js index 42d5350..b2fa4fb 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -22,7 +22,7 @@ const userFactory = () => ({ username: faker.internet.userName(), first_name: faker.name.firstName(), last_name: faker.name.lastName(), - active: true + active: (process.env.DB_CLIENT === 'mysql') ? 1 : true }) module.exports = { createKex, onlyForClient, userFactory } From e4a2dee4985fe11fd2442e18e72bfc4fac369c34 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Mon, 18 May 2020 16:34:36 +0200 Subject: [PATCH 19/20] Rename fetch() -> executeQuery() in QueryBuilder --- src/query-builder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/query-builder.js b/src/query-builder.js index 7b52ca5..1bb02b1 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -95,7 +95,7 @@ class QueryBuilder extends BaseQueryBuilder { this.events = this.constructor.Model.events.clone() // overwrite native then() method - Object.assign(this, promiseDuck.thenable(this.fetch.bind(this))) + Object.assign(this, promiseDuck.thenable(this.executeQuery.bind(this))) } table () { @@ -167,7 +167,7 @@ class QueryBuilder extends BaseQueryBuilder { * * @return {Promise<*>} */ - async fetch () { + async executeQuery () { const event = this.createEventToEmit() await this.events.emit(event, this) From 33097f9cad51d6972d47b7d455bb92952fcdd9f3 Mon Sep 17 00:00:00 2001 From: Radoslaw Mejer Date: Mon, 18 May 2020 16:53:50 +0200 Subject: [PATCH 20/20] Add event listener using query object --- src/query-builder.js | 20 ++++++++++++++++++++ tests/queries.test.js | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/query-builder.js b/src/query-builder.js index 1bb02b1..f92c8b1 100644 --- a/src/query-builder.js +++ b/src/query-builder.js @@ -12,6 +12,7 @@ const { /** @typedef { import('./model') } Model */ /** @typedef { import('./events/pipeline') } EventsPipeline */ /** @typedef { import('./events/event') } Event */ +/** @typedef { import('./events/pipeline').EventListener } EventListener */ /** * @callback Scope @@ -206,6 +207,25 @@ class QueryBuilder extends BaseQueryBuilder { return new FetchingEvent() } } + + /** + * @param {String} eventName + * @param {EventListener|Function} listener + * @param {Object} [options] + * @param {Boolean} [options.native=false] + * @return {QueryBuilder} + */ + on (eventName, listener, options = {}) { + const { native = false } = options + + if (native) { + return BaseQueryBuilder.prototype.on.call(this, eventName, listener) + } + + this.events.on(eventName, listener) + + return this + } } /** diff --git a/tests/queries.test.js b/tests/queries.test.js index 3e0b015..ec27bd5 100644 --- a/tests/queries.test.js +++ b/tests/queries.test.js @@ -1,4 +1,5 @@ const test = require('ava') +const sinon = require('sinon') const setupDb = require('./setup-db') const { equalQueries } = require('./assertions') const { createKex } = require('./utils') @@ -36,3 +37,25 @@ test('forbid using table()', t => { .table('foo') }) }) + +test('events | add listener', async t => { + const User = createKex(t).createModel('User') + + const listener = sinon.stub() + + await User.query() + .on('fetching', listener) + + t.true(listener.calledOnce) +}) + +test('events | add knex native listener', async t => { + const User = createKex(t).createModel('User') + + const listener = sinon.stub() + + await User.query() + .on('query', listener, { native: true }) + + t.true(listener.calledOnce) +})