Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Method proxies #8

Merged
merged 7 commits into from
May 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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