Skip to content

Commit

Permalink
Merge pull request #8 from baethon/method-proxies
Browse files Browse the repository at this point in the history
Method proxies (closes #8)
  • Loading branch information
radmen committed May 8, 2020
2 parents 91d6903 + 3d55d75 commit b3be456
Show file tree
Hide file tree
Showing 21 changed files with 419 additions and 269 deletions.
33 changes: 11 additions & 22 deletions src/kex.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
const modelUtils = require('./model')
const Model = require('./model')
const { omit } = require('./utils')
const builtinPlugins = require('./plugins')
const pluginUtils = require('./plugins')
const { KexError } = require('./errors')

/** @typedef { import('knex/lib/query/builder') } Knex */
/** @typedef { import('./query-builder').Scope } Scope */
/** @typedef { import('./plugins/soft-deletes').SoftDeleteOptions } SoftDeleteOptions */
/** @typedef { import('./model').Model } Model */
/** @typedef { import('./relations/relation') } Relation */
/** @typedef { import('./model') } Model */
/** @typedef { import('./model').ModelOptions } ModelOptions */

/**
* @type {Object} ModelDefaultOptions
Expand All @@ -18,18 +18,6 @@ const { KexError } = require('./errors')
* @property {Object.<String,Scope>} [globalScopes]
*/

/**
* @typedef {Object} ModelOptions
* @property {String} [tableName]
* @property {String} [primaryKey=id]
* @property {Boolean | SoftDeleteOptions} [softDeletes=false]
* @property {Object.<String, Object>} [relations]
* @property {PluginFactory[]} [plugins]
* @property {Object.<String,Scope>} [scopes]
* @property {Object.<String,Scope>} [globalScopes]
* @property {Object.<String,Relation>} [relations]
*/

/**
* @typedef {Object} KexOptions
* @property {ModelDefaultOptions} [modelDefaults]
Expand Down Expand Up @@ -85,12 +73,13 @@ class Kex {
...options
}

const modelPlugins = [
...builtinPlugins,
...plugins
]
const Model = modelUtils.createModel(this, name, useOptions)
this.models[name] = modelUtils.applyPlugins(modelPlugins, Model, useOptions)
this.models[name] = pluginUtils.applyPlugins(
[
...pluginUtils.builtinPlugins,
...plugins
],
new Model(this, name, useOptions)
)

return this.models[name]
}
Expand Down
187 changes: 114 additions & 73 deletions src/model.js
Original file line number Diff line number Diff line change
@@ -1,90 +1,131 @@
const pluralize = require('pluralize')
const snakeCase = require('lodash.snakecase')
const QueryBuilder = require('./query-builder')
const { toScope } = require('./utils')
const { KexError } = require('./errors')

const getTableName = (modelName, { tableName }) => {
return tableName || snakeCase(pluralize.plural(modelName))
}

/** @typedef { import('./kex') } Kex */
/** @typedef { import('./kex').ModelOptions } ModelOptions */
/** @typedef { import('./query-builder').QueryBuilder } QueryBuilder */
/** @typedef { import('./plugins/soft-deletes').SoftDeleteOptions } SoftDeleteOptions */
/** @typedef { import('./relations/relation') } Relation */
/** @typedef { import('./query-builder').Scope } Scope */

/**
* @typedef {Object} Model
* @property {QueryBuilder} QueryBuilder
* @property {Function} query create new query
* @property {String} name name of the model
* @property {Kex} kex
* @property {String} tableName
* @property {String} primaryKey
* @property {ModelOptions} options
* @typedef {Object} ModelOptions
* @property {String} [tableName]
* @property {String} [primaryKey=id]
* @property {Boolean | SoftDeleteOptions} [softDeletes=false]
* @property {Object.<String, Object>} [relations]
* @property {PluginFactory[]} [plugins]
* @property {Object.<String,Scope>} [scopes]
* @property {Object.<String,Scope>} [globalScopes]
* @property {Object.<String,Relation>} [relations]
*/

/**
* Create the model object
*
* @param {Kex} kex
* @param {String} name
* @param {ModelOptions} options
* @return {Model}
* @typedef {Object} ExtendOptions
* @property {String} methodName
* @property {Function} fn
* @property {Boolean} [queryProxy=false] should the function
* be proxied to the QueryBuilder?
*/
const createModel = (kex, name, options) => {
const tableName = getTableName(name, options)
const builder = QueryBuilder.createChildClass(tableName, options)

return {
get QueryBuilder () {
return builder
},

get name () {
return name
},

get kex () {
return kex
},

get tableName () {
return tableName
},

get primaryKey () {
return options.primaryKey || 'id'
},

get options () {
return options
},

query () {
const { knex } = this.kex
return this.QueryBuilder.create(knex.client)

const proxyQueryMethods = [
'where',
'insert',
'returning'
]

class Model {
/**
* @param {import('./kex')} kex
* @param {String} name
* @param {ModelOptions} options
*/
constructor (kex, name, options = {}) {
this.name = name
this.kex = kex
this.options = options
this.QueryBuilder = QueryBuilder.createChildClass(this)
this.booted = false
}

get tableName () {
const { tableName } = this.options
return tableName || snakeCase(pluralize.plural(this.name))
}

get primaryKey () {
const { primaryKey } = this.options
return primaryKey || 'id'
}

query () {
this.bootIfNotBooted()

const { knex } = this.kex
return this.QueryBuilder.create(knex.client)
}

/**
* @param {ExtendOptions} options
*/
extend (options) {
const { methodName, fn, queryProxy = false } = options

if (this[methodName]) {
throw new KexError(`Can't overwrite method [${methodName}] in ${this.name} model`)
}

if (queryProxy) {
this.QueryBuilder.extend({ methodName, fn })
this[methodName] = (...args) => {
const query = this.query()
return query[methodName](...args)
}
} else {
this[methodName] = (...args) => {
return fn.call(this, ...args)
}
}
}
}

/**
* @callback PluginFactory
* @param {Model} Model
* @param {Object} options
*/
addScope (name, fn) {
this.extend({
methodName: name,
fn: toScope(fn),
queryProxy: true
})

/**
* Apply the list of plugins to the Model
*
* @param {PluginFactory[]} plugins
* @param {Model} Model
* @param {Object} options
* @return {Model}
*/
const applyPlugins = function (plugins, Model, options) {
plugins.forEach(fn => {
fn(Model, options)
})
return this
}

return Model
/**
* @private
*/
bootIfNotBooted () {
if (this.booted) {
return
}

const {
scopes = {},
globalScopes = {}
} = this.options

Object.entries(scopes)
.forEach(([name, fn]) => this.addScope(name, fn))

Object.entries(globalScopes)
.forEach(([name, fn]) => this.QueryBuilder.addGlobalScope(name, fn))

this.booted = true
}
}

module.exports = { createModel, applyPlugins }
proxyQueryMethods.forEach(name => {
Model.prototype[name] = function (...args) {
const query = this.query()
return query[name](...args)
}
})

module.exports = Model
36 changes: 21 additions & 15 deletions src/plugins/find.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
const { ModelNotFound } = require('../errors')

/**
* @param {import('../model').Model} Model
* @param {import('../model')} Model
*/
module.exports = (Model) => {
const { primaryKey } = Model

Model.find = function (id) {
return this.query()
.where(primaryKey, id)
.first()
}
Model.extend({
methodName: 'find',
fn (id) {
return this.query()
.where(primaryKey, id)
.first()
}
})

Model.findOrFail = function (id) {
return this.find(id)
.then(model => {
if (!model) {
throw ModelNotFound.findOrFail(Model, { primaryKey, value: id })
}
Model.extend({
methodName: 'findOrFail',
fn (id) {
return this.find(id)
.then(model => {
if (!model) {
throw ModelNotFound.findOrFail(Model, { primaryKey, value: id })
}

return model
})
}
return model
})
}
})
}
25 changes: 13 additions & 12 deletions src/plugins/first-or-fail.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
const { ModelNotFound } = require('../errors')

/**
* @param {import('../model').Model} Model
* @param {import('../kex').ModelOptions} options
* @param {import('../model')} Model
*/
module.exports = (Model, options) => {
Model.QueryBuilder.prototype.firstOrFail = function (columns) {
return this.first(columns)
.then(model => {
if (!model) {
throw ModelNotFound.firstOrFail(Model)
}
module.exports = (Model) => {
Model.QueryBuilder.extend({
methodName: 'firstOrFail',
async fn (columns) {
const model = await this.first(columns)

return model
})
}
if (!model) {
throw ModelNotFound.firstOrFail(Model)
}

return model
}
})
}
36 changes: 22 additions & 14 deletions src/plugins/include/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,35 @@ const Related = require('./related')
const { parseIncludes } = require('./parser')

/**
* @param {import('../../model').Model} Model
* @param {import('../../model')} Model
*/
module.exports = (Model) => {
const { QueryBuilder } = Model
const related = new Related(Model)

QueryBuilder.prototype.include = function (...args) {
this.includes = {
...this.includes,
...parseIncludes(...args)
}
Model.extend({
methodName: 'include',
queryProxy: true,
fn (...args) {
this.includes = {
...this.includes,
...parseIncludes(...args)
}

return this
}
return this
}
})

const { then: thenMethod } = QueryBuilder.prototype

QueryBuilder.prototype.then = function (resolve, reject) {
return thenMethod.call(this)
.then(results => related.fetchRelated(results, this.includes))
.then(resolve)
.catch(reject)
}
QueryBuilder.extend({
methodName: 'then',
force: true,
fn (resolve, reject) {
return thenMethod.call(this)
.then(results => related.fetchRelated(results, this.includes))
.then(resolve)
.catch(reject)
}
})
}
2 changes: 1 addition & 1 deletion src/plugins/include/related.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { KexError } = require('../../errors')
const { groupIncludes } = require('./parser')

/** @typedef { import('../../model').Model } Model */
/** @typedef { import('../../model') } Model */
/** @typedef { import('../../query-builder').Scope } Scope */

class Related {
Expand Down
Loading

0 comments on commit b3be456

Please sign in to comment.