UNPKG

rosie-as-promised

Version:

factory for building JavaScript objects with async support, mostly useful for setting up test data. Inspired by rosiejs

637 lines (574 loc) 17.9 kB
/** * Creates a new factory with attributes, options, etc. to be used to build * objects. * * @param {Function=} constructor * @class */ class Factory { constructor (constructor) { this.construct = constructor this._attrs = {} this.opts = {} this.sequences = {} this.beforeBuildHooks = [] this.afterBuildHooks = [] this.beforeCreateHooks = [] this.createHandler = null this.afterCreateHooks = [] Factory._allFactories.push(this) } /** * Define an attribute on this factory. Attributes can optionally define a * default value, either as a value (e.g. a string or number) or as a builder * function. For example: * * // no default value for age * Factory.define('Person').attr('age') * * // static default value for age * Factory.define('Person').attr('age', 18) * * // dynamic default value for age * Factory.define('Person').attr('age', function() { * return Math.random() * 100 * }) * * Attributes with dynamic default values can depend on options or other * attributes: * * Factory.define('Person').attr('age', ['name'], function(name) { * return name === 'Brian' ? 30 : 18 * }) * * By default if the consumer of your factory provides a value for an * attribute your builder function will not be called. You can override this * behavior by declaring that your attribute depends on itself: * * Factory.define('Person').attr('spouse', ['spouse'], function(spouse) { * return Factory.build('Person', spouse) * }) * * As in the example above, this can be a useful way to fill in * partially-specified child objects. * * @param {string} attr * @param {Array.<string>=} dependencies * @param {*=} value * @return {Factory} */ attr (attr, dependencies, value) { let builder if (arguments.length === 2) { value = dependencies dependencies = null } builder = Factory.util.isFunction(value) ? value : () => value this._attrs[attr] = { dependencies: dependencies || [], builder } return this } /** * Convenience function for defining a set of attributes on this object as * builder functions or static values. If you need to specify dependencies, * use #attr instead. * * For example: * * Factory.define('Person').attrs({ * name: 'Michael', * age: function() { return Math.random() * 100 } * }) * * @param {object} attributes * @return {Factory} */ attrs (attributes) { for (let attr in attributes) { if (Object.prototype.hasOwnProperty.call(attributes, attr)) { this.attr(attr, attributes[attr]) } } return this } /** * Define an option for this factory. Options are values that may inform * dynamic attribute behavior but are not included in objects built by the * factory. Like attributes, options may have dependencies. Unlike * attributes, options may only depend on other options. * * Factory.define('Person') * .option('includeRelationships', false) * .attr( * 'spouse', * ['spouse', 'includeRelationships'], * function(spouse, includeRelationships) { * return includeRelationships ? * Factory.build('Person', spouse) : * null * }) * * Factory.build('Person', null, { includeRelationships: true }) * * Options may have either static or dynamic default values, just like * attributes. Options without default values must have a value specified * when building. * * @param {string} opt * @param {Array.<string>=} dependencies * @param {*=} value * @return {Factory} */ option (opt, dependencies, value) { let builder if (arguments.length === 2) { value = dependencies dependencies = null } if (arguments.length > 1) { builder = Factory.util.isFunction(value) ? value : () => value } this.opts[opt] = { dependencies: dependencies || [], builder } return this } /** * Defines an attribute that, by default, simply has an auto-incrementing * numeric value starting at 1. You can provide your own builder function * that accepts the number of the sequence and returns whatever value you'd * like it to be. * * Sequence values are inherited such that a factory derived from another * with a sequence will share the state of that sequence and they will never * conflict. * * Factory.define('Person').sequence('id') * * @param {string} attr * @param {Array.<string>=} dependencies * @param {function(number): *=} builder * @return {Factory} */ sequence (attr, dependencies, builder) { if (arguments.length === 2) { builder = dependencies dependencies = null } builder = builder || ((i) => i) return this.attr(attr, dependencies, (...args) => { this.sequences[attr] = this.sequences[attr] || 0 args.unshift(++this.sequences[attr]) return builder(...args) }) } /** * Sets a pre-processor hook that will receive provided attributes * and the options for the build just before they are sent to * the #attributes function * * @param {function(object, object=)} callback * @return {Factory} */ beforeBuild (hook) { this.beforeBuildHooks.push(hook) return this } /** * Sets a post-processor callback that will receive built objects and the * options for the build just before they are returned from the #build * function. * * @param {function(object, object=)} callback * @return {Factory} */ afterBuild (hook) { this.afterBuildHooks.push(hook) return this } /** * Backwards compatibility alias for afterBuild */ after (hook) { this.afterBuild(hook) return this } /** * Sets a pre-processor async callback that will receive the built object and the * options for the build just before the createHandler is called * * @param {function(object, object=)} callback * @return {Factory} */ beforeCreate (callback) { this.beforeCreateHooks.push(callback) return this } /** * Sets a processor async callback that will receive built object and the * options for the build just after the beforeCreate processors are called * * @param {function(object, object=)} callback * @return {Factory} */ onCreate (onCreateHandler) { this.createHandler = onCreateHandler return this } /** * Sets a post-processor async callback that will receive the built object and the * options for the build just after the createHandler is called * * @param {function(object, object=)} callback * @return {Factory} */ afterCreate (callback) { this.afterCreateHooks.push(callback) return this } /** * Builds a plain object containing values for each of the declared * attributes. The result of this is the same as the result when using #build * when there is no constructor registered. * * @param {object=} attributes * @param {object=} options * @return {object} */ attributes (attributes, options) { attributes = { ...attributes } options = this.options(options) for (let attr in this._attrs) { this._attrValue(attr, attributes, options, [attr]) } return attributes } /** * Generates a value for the given named attribute and adds the result to the * given attributes list. * * @private * @param {string} attr * @param {object} attributes * @param {object} options * @param {Array.<string>} stack * @return {*} */ _attrValue (attr, attributes, options, stack) { if (!this._alwaysCallBuilder(attr) && Object.prototype.hasOwnProperty.call(attributes, attr)) { return attributes[attr] } const value = this._buildWithDependencies(this._attrs[attr], (dep) => { if (Object.prototype.hasOwnProperty.call(options, dep)) { return options[dep] } else if (dep === attr) { return attributes[dep] } else if (stack.indexOf(dep) >= 0) { throw new Error('detected a dependency cycle: ' + stack.concat([dep]).join(' -> ')) } else { return this._attrValue(dep, attributes, options, stack.concat([dep])) } }) attributes[attr] = value return value } /** * Determines whether the given named attribute has listed itself as a * dependency. * * @private * @param {string} attr * @return {boolean} */ _alwaysCallBuilder (attr) { const attrMeta = this._attrs[attr] return attrMeta.dependencies.indexOf(attr) >= 0 } /** * Generates values for all the registered options using the values given. * * @private * @param {?object} options * @return {object} */ options (options) { options = { ...options } for (let opt in this.opts) { options[opt] = this._optionValue(opt, options) } return options } /** * Generates a value for the given named option and adds the result to the * given options list. * * @private * @param {string} opt * @param {object} options * @return {*} */ _optionValue (opt, options) { if (Object.prototype.hasOwnProperty.call(options, opt)) { return options[opt] } const optMeta = this.opts[opt] if (!optMeta.builder) { throw new Error('option `' + opt + '` has no default value and none was provided') } return this._buildWithDependencies(optMeta, (dep) => this._optionValue(dep, options)) } /** * Calls the builder function with its dependencies as determined by the * given dependency resolver. * * @private * @param {{builder: function(...[*]): *, dependencies: Array.<string>}} meta * @param {function(string): *} getDep * @return {*} */ _buildWithDependencies (meta, getDep) { const deps = meta.dependencies const args = deps.map((...args) => getDep.apply(this, args)) return meta.builder.apply(this, args) } /** * Builds objects by getting values for all attributes and optionally passing * the result to a constructor function. * * @param {object=} attributes * @param {object=} options * @return {*} */ build (attributes, options) { attributes = { ...attributes } // Precalculate options. // Because options cannot depend on themselves or on attributes, subsequent calls to // `this.options` will be idempotent and we can avoid re-running builders options = this.options(options) return Factory.util.nextHook(0, this.beforeBuildHooks, attributes, options, (attributes) => { let result = this.attributes(attributes, options) if (this.construct) result = new this.construct(result) return Factory.util.nextHook(0, this.afterBuildHooks, result, options, (maybeResult) => { return maybeResult || result }) }) } buildList (size, attributes, options) { let containsPromise = false const objs = [] for (let i = 0; i < size; i++) { const obj = this.build(attributes, options) if (Factory.util.isPromise(obj)) containsPromise = true objs.push(obj) } return containsPromise ? Promise.all(objs) : objs } /** * Passes built objects through async beforeCreate functions, * a create function, and afterCreate functions * and returns the result * * @param {object=} attributes * @param {object=} options * @return {*} */ create (attributes, options) { const maybePromise = this.build(attributes, options) return Factory.util.after(maybePromise, (object) => { return Factory.util.nextHook(0, this.beforeCreateHooks, object, options, (maybeResult) => { object = maybeResult || object const maybePromise = this.createHandler ? this.createHandler(object, options) : null return Factory.util.after(maybePromise, (maybeResult) => { object = maybeResult || object return Factory.util.nextHook(0, this.afterCreateHooks, object, options, (maybeResult) => { return maybeResult || object }) }) }) }) } createList (size, attributes, options) { let containsPromise = false const objs = [] for (let i = 0; i < size; i++) { const obj = this.create(attributes, options) if (Factory.util.isPromise(obj)) containsPromise = true objs.push(obj) } return containsPromise ? Promise.all(objs) : objs } /** * Extends a given factory by copying over its attributes, options, * callbacks, and constructor. This can be useful when you want to make * different types which all share certain attributes. * * @param {string|Factory} name The factory to extend. * @return {Factory} */ extend (name) { const factory = typeof name === 'string' ? Factory.get(name) : name this.construct = this.construct || factory.construct // Copy the parent's constructor Object.assign(this._attrs, factory._attrs) Object.assign(this.opts, factory.opts) // Copy the parent's hooks this.beforeBuildHooks = this.beforeBuildHooks.concat(factory.beforeBuildHooks) this.afterBuildHooks = this.afterBuildHooks.concat(factory.afterBuildHooks) this.beforeCreateHooks = this.beforeCreateHooks.concat(factory.beforeCreateHooks) this.createHandler = this.createHandler || factory.createHandler // needs test for not overritting this.afterCreateHooks = this.afterCreateHooks.concat(factory.afterCreateHooks) return this } /** * Resets any state changed by building objects back to the original values. * Preserves attributes and options as-is. */ reset() { this.sequences = {} } } /** * @private */ Factory.util = (function () { return { isObject: function isObject(value) { return value !== null && typeof value === 'object' }, isFunction: function isFunction(value) { return typeof value === 'function' }, isPromise: function isPromise(value) { return Factory.util.isObject(value) && Factory.util.isFunction(value.then) }, after: function after (maybePromise, next) { return Factory.util.isPromise(maybePromise) ? maybePromise.then(next) : next(maybePromise) }, nextHook: function nextHook(index, hooks, object, options, next) { const hook = hooks[index] if (!hook) return next(object) const maybePromise = hook(object, options) return Factory.util.after(maybePromise, (maybeResult) => { object = maybeResult || object return Factory.util.nextHook(++index, hooks, object, options, next) }) } } })() Factory.factories = {} Object.defineProperty(Factory, '_allFactories', { value: [], enumerable: false }) /** * Retrieve a factory from the registrar * * @param {!string} name * @return {Factory} */ Factory.get = function (name) { const factory = this.factories[name] if (!factory) throw new Error(`The "${name}" factory is not defined.`) return factory } /** * Defines a factory by name and constructor function. Call #attr and #option * on the result to define the properties of this factory. * * @param {!string} name * @param {function(object): *=} constructor * @return {Factory} */ Factory.define = function (name, constructor) { const factory = new Factory(constructor) this.factories[name] = factory return factory } /** * Locates a factory by name and calls #build on it. * * @param {string} name * @param {object=} attributes * @param {object=} options * @return {*} */ Factory.build = function (name, attributes, options) { return this.get(name).build(attributes, options) } /** * Builds a collection of objects using the named factory. * * @param {string} name * @param {number} size * @param {object=} attributes * @param {object=} options * @return {Array.<*>} */ Factory.buildList = function (name, size, attributes, options) { return this.get(name).buildList(size, attributes, options) } /** * Locates a factory by name and calls #create on it. * * @param {string} name * @param {object=} attributes * @param {object=} options * @return {*} */ Factory.create = function (name, attributes, options) { return this.get(name).create(attributes, options) } /** * Creates a collection of objects using the named factory. * * @param {string} name * @param {number} size * @param {object=} attributes * @param {object=} options * @return {Array.<*>} */ Factory.createList = function (name, size, attributes, options) { return this.get(name).createList(size, attributes, options) } /** * Locates a factory by name and calls #attributes on it. * * @param {string} name * @param {object} attributes * @param {object} options * @return {object} */ Factory.attributes = function (name, attributes, options) { return this.get(name).attributes(attributes, options) } /** * Resets a factory by name. Preserves attributes and options as-is. * * @param {string} name */ Factory.reset = function (name) { this.get(name).reset() } /** * Resets all factory build state. Preserves attributes and options as-is. */ Factory.resetAll = function () { Factory._allFactories.forEach((factory) => factory.reset()) } /** * Unregister and forget all existing factories. */ Factory.implode = function () { Factory.factories = {} Factory._allFactories.length = 0 } /* istanbul ignore next */ if (typeof exports === 'object' && typeof module !== 'undefined') { /* eslint-env commonjs */ exports.Factory = Factory /* eslint-env commonjs:false */ } else if (typeof define === 'function' && define.amd) { /* eslint-env amd */ define([], () => ({ Factory })) /* eslint-env amd:false */ } else if (this) { this.Factory = Factory }