UNPKG

vitamin

Version:

Data Mapper library for Node.js applications

638 lines (538 loc) 15.5 kB
import InvalidRelationError from './errors/invalid-relation-object' import EventEmitter from 'vitamin-events' import Relation from './relations/base' import Collection from './collection' import registry from './registry' import Promise from 'bluebird' import Model from './model' import Query from './query' import { has, map, each, clone, reduce, extend, result, isArray, isObject, isString } from 'underscore' /** * @class Mapper */ export default class { /** * Mapper class constructor * * @param {Object} options * @constructor */ constructor(options = {}) { this.events = {} this.methods = {} this.statics = {} this.relations = {} this.attributes = {} this.defaults = null this.tableName = null this.primaryKey = 'id' this.timestamps = false this.connection = 'default' this.createdAtColumn = 'created_at' this.updatedAtColumn = 'updated_at' extend(this, options) // set up the model class for this mapper this._setupModel(options.modelClass || Model) // set up the event emitter this.emitter = new EventEmitter() this._registerEvents(this.events) } /** * Inheritance helper * * @param {Object} props * @param {Object} statics * @return constructor function * @deprecated */ static extend(props = {}, statics = {}) { var parent = this var child = function () { parent.apply(this, arguments) } // use custom constructor if ( has(props, 'constructor') ) child = props.constructor // set the prototype chain to inherit from `parent` child.prototype = Object.create(parent.prototype, { constructor: { value: child, writable: true, configurable: true } }) // add static and instance properties extend(child, statics) extend(child.prototype, props) // fix extending static properties Object.setPrototypeOf ? Object.setPrototypeOf(child, parent) : child.__proto__ = parent return child } /** * Get the model mapper from the registry * * @param {String} name * @return mapper */ mapper(name) { return registry.get(name) } /** * Get the model default attributes * * @return function or plain object */ getDefaults() { return this.defaults ? this.defaults : () => { return reduce(this.attributes, (memo, config, attr) => { if ( has(config, 'defaultValue') ) memo[attr] = result(config, 'defaultValue') return memo }, {}) } } /** * Load the given relationships of the given models * * @param {Model|Array} model * @param {Array} relations * @return promise */ load(model, ...relations) { // TODO deprecate the use of single model, always call it with an array var models = isArray(model) ? model : [model] return this.newQuery().withRelated(...relations).loadRelated(models).return(model) } /** * Create a new record into the database * * @param {Object} attrs * @param {Array} returning * @return promise */ create(attrs, returning = ['*']) { return this.save(this.newInstance(attrs), returning) } /** * Create many instances of the related model * * @param {Array} records * @parma {Array} returning * @return promise */ createMany(records, returning = ['*']) { return Promise.map(records, attrs => this.create(attrs, returning)) } /** * Save the model in the database * * @param {Model} model * @param {Array} returning * @return promise */ save(model, returning = ['*']) { return Promise.resolve(model) .tap(() => this.emitter.emit('saving', model)) .tap(() => model.exists ? this._update(...arguments) : this._insert(...arguments)) .tap(() => this.emitter.emit('saved', model)) } /** * Save the given models * * @param {Array} models * @parma {Array} returning * @return promise */ saveMany(models, returning = ['*']) { return Promise.map(models, model => this.save(model, returning)) } /** * Delete the model from the database * * @param {Model} model * @return promise */ destroy(model) { return Promise.resolve(model) .tap(() => this.emitter.emit('deleting', model)) .tap(() => this.newQuery().where(this.primaryKey, model.getId()).destroy()) .tap(() => this.emitter.emit('deleted', model)) } /** * Delete the given models from the database * * @param {Array} models * @return promise */ destroyMany(models) { return Promise.map(models, model => this.destroy(model)) } /** * Get a fresh timestamp for the model * * @return string ISO time */ freshTimestamp() { return new Date().toISOString() } /** * Update the model's update timestamp * * @param {Model} model * @return promise */ touch(model) { if ( this.timestamps && this.updatedAtColumn ) { return this.save(model.set(this.updatedAtColumn, this.freshTimestamp())) } return Promise.resolve(model) } /** * Touch the given models * * @param {Array} models * @return promise */ touchMany(models) { return Promise.map(models, model => this.touch(model)) } /** * Create a new model instance * * @param {Object} data * @param {Boolean} exists * @return model instance */ newInstance(data = {}, exists = false) { return this.modelClass.make(...arguments) } /** * Get the model query builder * * @return Query instance */ newQuery() { var client = registry.connection(this.connection) var query = new Query(client.queryBuilder()) return query.from(this.tableName).setModel(this) } /** * Create a new collection of models * * @param {Array} models * @return Collection instance */ newCollection(models = []) { return new Collection(models).setMapper(this) } /** * Helper to create a collection of models * * @param {Array} records * @return collection */ createModels(records) { return this.newCollection(map(records, data => this.newInstance(data, true))) } /** * Create the relationship mapper * * @param {String} name * return relation instance */ getRelation(name) { if ( this.relations[name] ) { let relation = this.relations[name].call(this) if ( relation instanceof Relation ) return relation.setName(name) } throw new InvalidRelationError(name) } /** * Define a has-one relationship * * @param {Model} related * @param {String} fk target model foreign key * @param {String} pk parent model primary key * @return relation */ hasOne(related, fk = null, pk = null) { var HasOne = require('./relations/has-one').default if (! pk ) pk = this.primaryKey if (! fk ) fk = this.name + '_id' if ( isString(related) ) related = this.mapper(related) return new HasOne(this, related, fk, pk) } /** * Define a morph-one relationship * * @param {Model} related * @param {String} name of the morph * @param {String} type target model type column * @param {String} fk target model foreign key * @param {String} pk parent model primary key * @return relation */ morphOne(related, name, type = null, fk = null, pk = null) { var MorphOne = require('./relations/morph-one').default if (! pk ) pk = this.primaryKey if (! type ) type = name + '_type' if (! fk ) fk = name + '_id' if ( isString(related) ) related = this.mapper(related) return new MorphOne(this, related, type, fk, pk) } /** * Define a has-many relationship * * @param {Model} related * @param {String} fk target model foreign key * @param {String} pk parent model primary key * @return relation */ hasMany(related, fk = null, pk = null) { var HasMany = require('./relations/has-many').default if (! pk ) pk = this.primaryKey if (! fk ) fk = this.name + '_id' if ( isString(related) ) related = this.mapper(related) return new HasMany(this, related, fk, pk) } /** * Define a morph-many relationship * * @param {Model} related * @param {String} name of the morph * @param {String} type target model type column * @param {String} fk target model foreign key * @param {String} pk parent model primary key * @return relation */ morphMany(related, name, type = null, fk = null, pk = null) { var MorphMany = require('./relations/morph-many').default if (! pk ) pk = this.primaryKey if (! type ) type = name + '_type' if (! fk ) fk = name + '_id' if ( isString(related) ) related = this.mapper(related) return new MorphMany(this, related, type, fk, pk) } /** * Define a belongs-to relationship * * @param {Model} related * @param {String} fk parent model foreign key * @param {String} pk target model primary key * @return relation */ belongsTo(related, fk = null, pk = null) { var BelongsTo = require('./relations/belongs-to').default if ( isString(related) ) related = this.mapper(related) if (! pk ) pk = related.primaryKey if (! fk ) fk = related.name + '_id' return new BelongsTo(this, related, fk, pk) } /** * Define a morph-to relationship * * @param {String} name of the morph * @param {String} type column name * @param {String} fk * @param {String} pk * @return relation */ morphTo(name, type = null, fk = null, pk = null) { var MorphTo = require('./relations/morph-to').default if (! fk ) fk = name + '_id' if (! pk ) pk = this.primaryKey if (! type ) type = name + '_type' return new MorphTo(this, type, fk, pk) } /** * Define a belongs-to-many relationship * * @param {Model} related * @param {String} pivot table name * @param {String} pfk parent model foreign key * @param {String} tfk target model foreign key * @return relation */ belongsToMany(related, pivot, pfk = null, tfk = null) { var BelongsToMany = require('./relations/belongs-to-many').default if ( isString(related) ) related = this.mapper(related) if (! pfk ) pfk = this.name + '_id' if (! tfk ) tfk = related.name + '_id' return new BelongsToMany(this, related, pivot, pfk, tfk) } /** * Define a morph-to-many relationship * * @param {Model} related * @param {String} pivot table name * @param {String} name of the morph * @param {String} type column name * @param {String} pfk parent model foreign key * @param {String} tfk target model foreign key * @return relation */ morphToMany(related, pivot, name, type = null, pfk = null, tfk = null) { var MorphToMany = require('./relations/morph-to-many').default if ( isString(related) ) related = this.mapper(related) if (! pfk ) pfk = name + '_id' if (! type ) type = name + '_type' if (! tfk ) tfk = related.name + '_id' return new MorphToMany(this, related, pivot, type, pfk, tfk) } /** * Define the inverse of morph-to-many relationship * * @param {Model} related * @param {String} pivot table name * @param {String} name of the morph * @param {String} type column name * @param {String} pfk parent model foreign key * @param {String} tfk target model foreign key * @return relation */ morphedByMany(related, pivot, name, type = null, pfk = null, tfk = null) { if (! tfk ) tfk = name + '_id' if (! pfk ) pfk = this.name + '_id' return this.morphToMany(related, pivot, name, type, pfk, tfk) } /** * Add a listener for the given event * * @param {String} event * @param {Function} fn * @return this model */ on(event, fn) { this.emitter.on(...arguments) return this } /** * Remove an event listener * * @param {String} event * @param {Function} fn * @return this model */ off(event, fn) { this.emitter.off(...arguments) return this } /** * Trigger an event with arguments * * @param {String} event * @param {Array} args * @return promise */ emit(event, ...args) { return this.emitter.emit(...arguments) } /** * Override it to register shared events between mappers * * @param {Object} events * @private */ _registerEvents(events = {}) { each(events, (listener, name) => { (isArray(listener) ? listener : [listener]).forEach(fn => this.emitter.on(name, fn)) }) } /** * Perform a model insert operation * * @param {Model} model * @param {String|Array} returning * @return promise * @private */ _insert(model, returning) { return Promise .resolve(model) .tap(() => this.emitter.emit('creating', model)) .tap(() => this.timestamps && this._updateTimestamps(model)) .tap(() => { return this .newQuery() .insert(model.getData(), returning) .then(res => this._emulateReturning(model, res, returning)) .then(res => model.setData(res, true)) }) .tap(() => this.emitter.emit('created', model)) } /** * Perform a model update operation * * @param {Model} model * @param {String|Array} returning * @return promise * @private */ _update(model, returning) { return Promise .resolve(model) .tap(() => this.emitter.emit('updating', model)) .tap(() => this.timestamps && this._updateTimestamps(model)) .tap(() => { return this .newQuery() .where(this.primaryKey, model.getId()) .update(model.getDirty(), returning) .then(res => this._emulateReturning(model, res, returning)) .then(res => model.setData(res, true)) }) .tap(() => this.emitter.emit('updated', model)) } /** * Emulate the `returning` SQL clause * * @param {Model} model * @param {Array} result * @param {Array} columns * @private */ _emulateReturning(model, result, columns = ['*']) { var id = result[0] if ( isObject(id) ) return id else { let qb = this.newQuery().toBase() if ( model.exists ) id = model.getId() // resolve with a plain object to populate the model data return qb.where(this.primaryKey, id).first(columns) } } /** * Update the creation and update timestamps * * @param {Model} model * @private */ _updateTimestamps(model) { var time = this.freshTimestamp() var useCreatedAt = !!this.createdAtColumn var useUpdatedAt = !!this.updatedAtColumn if ( useUpdatedAt && !model.isDirty(this.updatedAtColumn) ) { model.set(this.updatedAtColumn, time) } if ( useCreatedAt && !model.exists && !model.isDirty(this.createdAtColumn) ) { model.set(this.createdAtColumn, time) } } /** * Set up the model class * * @param {Model} model constructor * @private */ _setupModel(model) { var _this = this var proto = clone(this.methods) // add prototype properties proto.mapper = this proto.defaults = this.getDefaults() proto.idAttribute = this.primaryKey // add relationship accessors each(this.relations, (_, name) => { proto[name] = function () { return _this.getRelation(name).addConstraints(this) } }) this.modelClass = model.extend(proto, this.statics) } }