UNPKG

miragejs

Version:

A client-side server to help you build, test and demo your JavaScript app

607 lines (497 loc) 16.1 kB
import { camelize, dasherize } from "../utils/inflector"; import Association from "./associations/association"; import Collection from "./collection"; import assert from "../assert"; import forIn from "lodash/forIn"; const collectionNameCache = {}; const internalCollectionNameCache = {}; const modelNameCache = {}; /** The primary use of the `Schema` class is to use it to find Models and Collections via the `Model` class methods. The `Schema` is most often accessed via the first parameter to a route handler: ```js this.get('posts', schema => { return schema.posts.where({ isAdmin: false }); }); ``` It is also available from the `.schema` property of a `server` instance: ```js server.schema.users.create({ name: 'Yehuda' }); ``` To work with the Model or Collection returned from one of the methods below, refer to the instance methods in the API docs for the `Model` and `Collection` classes. @class Schema @constructor @public */ export default class Schema { constructor(db, modelsMap = {}) { assert(db, "A schema requires a db"); /** Returns Mirage's database. See the `Db` docs for the db's API. @property db @type {Object} @public */ this.db = db; this._registry = {}; this._dependentAssociations = { polymorphic: [] }; this.registerModels(modelsMap); this.isSaving = {}; // a hash of models that are being saved, used to avoid cycles } /** @method registerModels @param hash @public @hide */ registerModels(hash = {}) { forIn(hash, (model, key) => { this.registerModel(key, hash[key]); }); } /** @method registerModel @param type @param ModelClass @public @hide */ registerModel(type, ModelClass) { let camelizedModelName = camelize(type); let modelName = dasherize(camelizedModelName); // Avoid mutating original class, because we may want to reuse it across many tests ModelClass = ModelClass.extend(); // Store model & fks in registry // TODO: don't think this is needed anymore this._registry[camelizedModelName] = this._registry[camelizedModelName] || { class: null, foreignKeys: [], }; // we may have created this key before, if another model added fks to it this._registry[camelizedModelName].class = ModelClass; // TODO: set here, remove from model#constructor ModelClass.prototype._schema = this; ModelClass.prototype.modelName = modelName; // Set up associations ModelClass.prototype.hasManyAssociations = {}; // a registry of the model's hasMany associations. Key is key from model definition, value is association instance itself ModelClass.prototype.hasManyAssociationFks = {}; // a lookup table to get the hasMany association by foreignKey ModelClass.prototype.belongsToAssociations = {}; // a registry of the model's belongsTo associations. Key is key from model definition, value is association instance itself ModelClass.prototype.belongsToAssociationFks = {}; // a lookup table to get the belongsTo association by foreignKey ModelClass.prototype.associationKeys = new Set(); // ex: address.user, user.addresses ModelClass.prototype.associationIdKeys = new Set(); // ex: address.user_id, user.address_ids ModelClass.prototype.dependentAssociations = []; // a registry of associations that depend on this model, needed for deletion cleanup. let fksAddedFromThisModel = {}; for (let associationProperty in ModelClass.prototype) { if (ModelClass.prototype[associationProperty] instanceof Association) { let association = ModelClass.prototype[associationProperty]; association.name = associationProperty; association.modelName = association.modelName || this.toModelName(associationProperty); association.ownerModelName = modelName; association.setSchema(this); // Update the registry with this association's foreign keys. This is // essentially our "db migration", since we must know about the fks. let [fkHolder, fk] = association.getForeignKeyArray(); fksAddedFromThisModel[fkHolder] = fksAddedFromThisModel[fkHolder] || []; assert( !fksAddedFromThisModel[fkHolder].includes(fk), `Your '${type}' model definition has multiple possible inverse relationships of type '${fkHolder}'. Please use explicit inverses.` ); fksAddedFromThisModel[fkHolder].push(fk); this._addForeignKeyToRegistry(fkHolder, fk); // Augment the Model's class with any methods added by this association association.addMethodsToModelClass(ModelClass, associationProperty); } } // Create a db collection for this model, if doesn't exist let collection = this.toCollectionName(modelName); if (!this.db[collection]) { this.db.createCollection(collection); } // Create the entity methods this[collection] = { camelizedModelName, new: (attrs) => this.new(camelizedModelName, attrs), create: (attrs) => this.create(camelizedModelName, attrs), all: (attrs) => this.all(camelizedModelName, attrs), find: (attrs) => this.find(camelizedModelName, attrs), findBy: (attrs) => this.findBy(camelizedModelName, attrs), findOrCreateBy: (attrs) => this.findOrCreateBy(camelizedModelName, attrs), where: (attrs) => this.where(camelizedModelName, attrs), none: (attrs) => this.none(camelizedModelName, attrs), first: (attrs) => this.first(camelizedModelName, attrs), }; return this; } /** @method modelFor @param type @public @hide */ modelFor(type) { return this._registry[type]; } /** Create a new unsaved model instance with attributes *attrs*. ```js let post = blogPosts.new({ title: 'Lorem ipsum' }); post.title; // Lorem ipsum post.id; // null post.isNew(); // true ``` @method new @param type @param attrs @public */ new(type, attrs) { return this._instantiateModel(dasherize(type), attrs); } /** Create a new model instance with attributes *attrs*, and insert it into the database. ```js let post = blogPosts.create({title: 'Lorem ipsum'}); post.title; // Lorem ipsum post.id; // 1 post.isNew(); // false ``` @method create @param type @param attrs @public */ create(type, attrs) { return this.new(type, attrs).save(); } /** Return all models in the database. ```js let posts = blogPosts.all(); // [post:1, post:2, ...] ``` @method all @param type @public */ all(type) { let collection = this.collectionForType(type); return this._hydrate(collection, dasherize(type)); } /** Return an empty collection of type `type`. @method none @param type @public */ none(type) { return this._hydrate([], dasherize(type)); } /** Return one or many models in the database by id. ```js let post = blogPosts.find(1); let posts = blogPosts.find([1, 3, 4]); ``` @method find @param type @param ids @public */ find(type, ids) { let collection = this.collectionForType(type); let records = collection.find(ids); if (Array.isArray(ids)) { assert( records.length === ids.length, `Couldn't find all ${this._container.inflector.pluralize( type )} with ids: (${ids.join(",")}) (found ${ records.length } results, but was looking for ${ids.length})` ); } return this._hydrate(records, dasherize(type)); } /** Returns the first model in the database that matches the key-value pairs in `attrs`. Note that a string comparison is used. ```js let post = blogPosts.findBy({ published: true }); let post = blogPosts.findBy({ authorId: 1, published: false }); let post = blogPosts.findBy({ author: janeSmith, featured: true }); ``` This will return `null` if the schema doesn't have any matching record. A predicate function can also be used to find a match. ```js let longPost = blogPosts.findBy((post) => post.body.length > 1000); ``` @method findBy @param type @param attributesOrPredicate @public */ findBy(type, query) { let collection = this.collectionForType(type); let record = collection.findBy(query); return this._hydrate(record, dasherize(type)); } /** Returns the first model in the database that matches the key-value pairs in `attrs`, or creates a record with the attributes if one is not found. ```js // Find the first published blog post, or create a new one. let post = blogPosts.findOrCreateBy({ published: true }); ``` @method findOrCreateBy @param type @param attributeName @public */ findOrCreateBy(type, attrs) { let collection = this.collectionForType(type); let record = collection.findBy(attrs); let model; if (!record) { model = this.create(type, attrs); } else { model = this._hydrate(record, dasherize(type)); } return model; } /** Return an ORM/Collection, which represents an array of models from the database matching `query`. If `query` is an object, its key-value pairs will be compared against records using string comparison. `query` can also be a compare function. ```js let posts = blogPosts.where({ published: true }); let posts = blogPosts.where(post => post.published === true); ``` @method where @param type @param query @public */ where(type, query) { let collection = this.collectionForType(type); let records = collection.where(query); return this._hydrate(records, dasherize(type)); } /** Returns the first model in the database. ```js let post = blogPosts.first(); ``` N.B. This will return `null` if the schema doesn't contain any records. @method first @param type @public */ first(type) { let collection = this.collectionForType(type); let record = collection[0]; return this._hydrate(record, dasherize(type)); } /** @method modelClassFor @param modelName @public @hide */ modelClassFor(modelName) { let model = this._registry[camelize(modelName)]; assert(model, `Model not registered: ${modelName}`); return model.class.prototype; } /* This method updates the dependentAssociations registry, which is used to keep track of which models depend on a given association. It's used when deleting models - their dependents need to be looked up and foreign keys updated. For example, schema = { post: Model.extend(), comment: Model.extend({ post: belongsTo() }) }; comment1.post = post1; ... post1.destroy() Deleting this post should clear out comment1's foreign key. Polymorphic associations can have _any_ other model as a dependent, so we handle them separately. */ addDependentAssociation(association, modelName) { if (association.isPolymorphic) { this._dependentAssociations.polymorphic.push(association); } else { this._dependentAssociations[modelName] = this._dependentAssociations[modelName] || []; this._dependentAssociations[modelName].push(association); } } dependentAssociationsFor(modelName) { let directDependents = this._dependentAssociations[modelName] || []; let polymorphicAssociations = this._dependentAssociations.polymorphic || []; return directDependents.concat(polymorphicAssociations); } /** Returns an object containing the associations registered for the model of the given _modelName_. For example, given this configuration ```js import { createServer, Model, hasMany, belongsTo } from 'miragejs' let server = createServer({ models: { user: Model, article: Model.extend({ fineAuthor: belongsTo("user"), comments: hasMany() }), comment: Model } }) ``` each of the following would return empty objects ```js server.schema.associationsFor('user') // {} server.schema.associationsFor('comment') // {} ``` but the associations for the `article` would return ```js server.schema.associationsFor('article') // { // fineAuthor: BelongsToAssociation, // comments: HasManyAssociation // } ``` Check out the docs on the Association class to see what fields are available for each association. @method associationsFor @param {String} modelName @return {Object} @public */ associationsFor(modelName) { let modelClass = this.modelClassFor(modelName); return Object.assign( {}, modelClass.belongsToAssociations, modelClass.hasManyAssociations ); } hasModelForModelName(modelName) { return this.modelFor(camelize(modelName)); } /* Private methods */ /** @method collectionForType @param type @private @hide */ collectionForType(type) { let collection = this.toCollectionName(type); assert( this.db[collection], `You're trying to find model(s) of type ${type} but this collection doesn't exist in the database.` ); return this.db[collection]; } toCollectionName(type) { if (typeof collectionNameCache[type] !== "string") { let modelName = dasherize(type); const collectionName = camelize( this._container.inflector.pluralize(modelName) ); collectionNameCache[type] = collectionName; } return collectionNameCache[type]; } // This is to get at the underlying Db collection. Poorly named... need to // refactor to DbTable or something. toInternalCollectionName(type) { if (typeof internalCollectionNameCache[type] !== "string") { const internalCollectionName = `_${this.toCollectionName(type)}`; internalCollectionNameCache[type] = internalCollectionName; } return internalCollectionNameCache[type]; } toModelName(type) { if (typeof modelNameCache[type] !== "string") { let dasherized = dasherize(type); const modelName = this._container.inflector.singularize(dasherized); modelNameCache[type] = modelName; } return modelNameCache[type]; } /** @method _addForeignKeyToRegistry @param type @param fk @private @hide */ _addForeignKeyToRegistry(type, fk) { this._registry[type] = this._registry[type] || { class: null, foreignKeys: [], }; let fks = this._registry[type].foreignKeys; if (!fks.includes(fk)) { fks.push(fk); } } /** @method _instantiateModel @param modelName @param attrs @private @hide */ _instantiateModel(modelName, attrs) { let ModelClass = this._modelFor(modelName); let fks = this._foreignKeysFor(modelName); return new ModelClass(this, modelName, attrs, fks); } /** @method _modelFor @param modelName @private @hide */ _modelFor(modelName) { return this._registry[camelize(modelName)].class; } /** @method _foreignKeysFor @param modelName @private @hide */ _foreignKeysFor(modelName) { return this._registry[camelize(modelName)].foreignKeys; } /** Takes a record and returns a model, or an array of records and returns a collection. * @method _hydrate @param records @param modelName @private @hide */ _hydrate(records, modelName) { if (Array.isArray(records)) { let models = records.map(function (record) { return this._instantiateModel(modelName, record); }, this); return new Collection(modelName, models); } else if (records) { return this._instantiateModel(modelName, records); } else { return null; } } }