lib/data/model.ts
import * as assert from 'assert';
import * as util from 'util';
import * as createDebug from 'debug';
import { pluralize, singularize } from 'inflection';
import { forEach, startCase, kebabCase, upperFirst, pickBy } from 'lodash';
import DenaliObject from '../metal/object';
import { lookup } from '../metal/container';
import ORMAdapter from './orm-adapter';
import { AttributeDescriptor, RelationshipDescriptor, SchemaDescriptor } from './descriptors';
import { Dict, Constructor } from '../utils/types';
const debug = createDebug('denali:model');
export const augmentedWithAccessors = Symbol();
/**
* The Model class is the core of Denali's unique approach to data and ORMs. It
* acts as a wrapper and translation layer that provides a unified interface to
* access and manipulate data, but translates those interactions into ORM
* specific operations via ORM adapters.
*
* The primary purpose of having Models in Denali is to allow Denali addons to
* access a common interface for manipulating data. Importantly, the goal is
* **not** to let you swap different ORMs / databases with little effort. Don't
* be surprised if you find your app littered with ORM specific code - that is
* expected and even encouraged. For more on this concept, check out the Denali
* blog.
*
* TODO: link to blog post on ^
*
* @package data
* @since 0.1.0
*/
export default class Model extends DenaliObject {
/**
* Marks the Model as an abstract base model, so ORM adapters can know not to
* create tables or other supporting infrastructure.
*
* @since 0.1.0
*/
static abstract = false;
/**
* The schema definition for this model. Keys are the field names, and values
* should be either `attr(...)', `hasOne(...)`, or `hasMany(...)`
*/
static schema: Dict<SchemaDescriptor> = {};
/**
* Returns the schema filtered down to just the attribute fields
*/
static get attributes(): Dict<AttributeDescriptor> {
// <any> is needed here because pickBy doesn't properly act as a type guard
// see: https://github.com/Microsoft/TypeScript/issues/7657
return <any>pickBy(this.schema, (descriptor: AttributeDescriptor) => descriptor.isAttribute);
}
/**
* Returns the schema filtered down to just the relationship fields
*/
static get relationships(): Dict<RelationshipDescriptor> {
// <any> is needed here because pickBy doesn't properly act as a type guard
// see: https://github.com/Microsoft/TypeScript/issues/7657
return <any>pickBy(this.schema, (descriptor: RelationshipDescriptor) => descriptor.isRelationship);
}
private static _augmentWithSchemaAccessors() {
if ((<any>this.prototype)[augmentedWithAccessors]) {
return;
}
(<any>this.prototype)[augmentedWithAccessors] = true;
forEach(this.schema, (descriptor, name) => {
if ((<any>descriptor).isAttribute) {
Object.defineProperty(this.prototype, name, {
configurable: true,
get() {
return (<typeof Model>this.constructor).adapter.getAttribute(this, name);
},
set(newValue) {
return (<typeof Model>this.constructor).adapter.setAttribute(this, name, newValue);
}
});
} else {
// author => "Author"
let methodRoot = upperFirst(name);
// getAuthor(options?)
Object.defineProperty(this.prototype, `get${methodRoot}`, {
configurable: true,
value(options?: any) { return (<Model>this).getRelated(name, options); }
});
// setAuthor(comments, options?)
Object.defineProperty(this.prototype, `set${methodRoot}`, {
configurable: true,
value(relatedModels: Model | Model[], options?: any) {
return (<Model>this).setRelated(name, relatedModels, options);
}
});
if ((<any>descriptor).mode === 'hasMany') {
let singularRoot = singularize(methodRoot);
// addComment(comment, options?)
Object.defineProperty(this.prototype, `add${singularRoot}`, {
configurable: true,
value(relatedModel: Model, options?: any) {
return (<Model>this).addRelated(name, relatedModel, options);
}
});
// removeComment(comment, options?)
Object.defineProperty(this.prototype, `remove${singularRoot}`, {
configurable: true,
value(relatedModel: Model, options?: any) {
return (<Model>this).removeRelated(name, relatedModel, options);
}
});
}
}
});
}
/**
* Builds a new Model instance from an already existing ORM record reference
*
* @param record The ORM adapter record object
*/
static build<T extends Model>(this: Constructor<T>, record: any): T {
let model = new this();
model.record = record;
return model;
}
/**
* Retrieve a single record by it's id
*
* @param id The id of the record you want to lookup
* @param options Options passed through to the ORM adapter
* @since 0.1.0
*/
static async find<T extends Model>(this: typeof Model & Constructor<T>, id: any, options?: any): Promise<T|null> {
assert(id != null, `You must pass an id to Model.find(id)`);
debug(`${ this.modelName } find: ${ id }`);
let record = await this.adapter.find(this.modelName, id, options);
if (!record) {
return null;
}
return this.build(record);
}
/**
* Retrieve the first record matching the given query
*
* @param query The query to pass through to the ORM adapter
* @param options An options object passed through to the ORM adapter
* @since 0.1.0
*/
static async queryOne<T extends Model>(this: typeof Model & Constructor<T>, query: any, options?: any): Promise<T|null> {
assert(query != null, `You must pass a query to Model.queryOne(conditions)`);
debug(`${ this.modelName } queryOne: ${ util.inspect(query) }`);
let record = await this.adapter.queryOne(this.modelName, query, options);
if (!record) {
return null;
}
return this.build(record);
}
/**
* Fetch all records matching the given query
*
* @param query The query to pass through to the ORM adapter
* @param options An options object passed through to the ORM adapter
* @since 0.1.0
*/
static async query<T extends Model>(this: typeof Model & Constructor<T>, query: any, options?: any): Promise<T[]> {
assert(query != null, `You must pass a query to Model.query(conditions)`);
debug(`${ this.modelName } query: ${ util.inspect(query) }`);
let records = await this.adapter.query(this.modelName, query, options);
return records.map((record) => this.build(record));
}
/**
* Fetch all records of this type
*
* @param options An options object passed through to the ORM adapter
* @since 0.1.0
*/
static async all<T extends Model>(this: typeof Model & Constructor<T>, options?: any): Promise<T[]> {
debug(`${ this.modelName } all`);
let result = await this.adapter.all(this.modelName, options);
return result.map((record) => this.build(record));
}
/**
* Create a new Model instance with the supplied data, and immediately
* persist it
*
* @param data Data to populate the new Model instance with
* @param options An options object passed through to the ORM adapter
* @since 0.1.0
*/
static async create<T extends Model>(this: typeof Model & Constructor<T>, data: any = {}, options?: any): Promise<T> {
let model = new this(data, options);
return model.save();
}
/**
* The ORM adapter specific to this model type. Defaults to the application's
* ORM adapter if none for this specific model type is found.
*
* @since 0.1.0
*/
static get adapter(): ORMAdapter {
return lookup<ORMAdapter>(`orm-adapter:${ this.modelName }`);
}
/**
* The name of this Model type. Used in a variety of use cases, including
* serialization.
*
* @since 0.1.0
*/
static get modelName(): string {
let name = this.name;
if (name.endsWith('Model')) {
name = name.substring(0, name.length - 'Model'.length);
}
name = kebabCase(name);
return name;
}
/**
* The underlying ORM adapter record. An opaque value to Denali, handled
* entirely by the ORM adapter.
*
* @since 0.1.0
*/
record: any = null;
/**
* The name of this Model type. Used in a variety of use cases, including
* serialization.
*
* @since 0.1.0
*/
get modelName(): string {
return (<typeof Model>this.constructor).modelName;
}
/**
* The id of the record
*
* @since 0.1.0
*/
get id(): any {
return (<typeof Model>this.constructor).adapter.idFor(this);
}
set id(value: any) {
(<typeof Model>this.constructor).adapter.setId(this, value);
}
/**
* Tell the underlying ORM to build this record
*/
constructor(data?: any, options?: any) {
super();
(<typeof Model>this.constructor)._augmentWithSchemaAccessors();
this.record = (<typeof Model>this.constructor).adapter.buildRecord(this.modelName, data, options);
}
/**
* Persist this model.
*
* @since 0.1.0
*/
async save(options?: any): Promise<this> {
debug(`saving ${ this.toString()}`);
await (<typeof Model>this.constructor).adapter.saveRecord(this, options);
return this;
}
/**
* Delete this model.
*
* @since 0.1.0
*/
async delete(options?: any): Promise<void> {
debug(`deleting ${ this.toString() }`);
await (<typeof Model>this.constructor).adapter.deleteRecord(this, options);
}
/**
* Returns the related record(s) for the given relationship.
*
* @since 0.1.0
*/
async getRelated<T extends Model>(relationshipName: string, options?: any): Promise<T|T[]> {
let descriptor: RelationshipDescriptor = <RelationshipDescriptor>(<typeof Model>this.constructor).schema[relationshipName];
assert(descriptor && descriptor.isRelationship, `You tried to fetch related ${ relationshipName }, but no such relationship exists on ${ this.modelName }`);
let RelatedModel = lookup<Constructor<T> & typeof Model>(`model:${ descriptor.relatedModelName }`);
let results = await (<typeof Model>this.constructor).adapter.getRelated(this, relationshipName, descriptor, options);
if (descriptor.mode === 'hasOne') {
assert(!Array.isArray(results), `The ${ this.modelName } ORM adapter returned an array for the hasOne '${ relationshipName }' relationship - it should return either an ORM record or null.`);
return results ? RelatedModel.create(results) : null;
}
assert(Array.isArray(results), `The ${ this.modelName } ORM adapter did not return an array for the hasMany '${ relationshipName }' relationship - it should return an array (empty if no related records exist).`);
return results.map((record: any) => RelatedModel.build(record));
}
/**
* Replaces the related records for the given relationship with the supplied
* related records.
*
* @since 0.1.0
*/
async setRelated<T extends Model>(relationshipName: string, relatedModels: T|T[], options?: any): Promise<void> {
let descriptor: RelationshipDescriptor = <RelationshipDescriptor>(<typeof Model>this.constructor).schema[relationshipName];
await (<typeof Model>this.constructor).adapter.setRelated(this, relationshipName, descriptor, relatedModels, options);
}
/**
* Add a related record to a hasMany relationship.
*
* @since 0.1.0
*/
async addRelated<T extends Model>(relationshipName: string, relatedModel: T, options?: any): Promise<void> {
let descriptor: RelationshipDescriptor = <RelationshipDescriptor>(<typeof Model>this.constructor).schema[pluralize(relationshipName)];
await (<typeof Model>this.constructor).adapter.addRelated(this, relationshipName, descriptor, relatedModel, options);
}
/**
* Remove the given record from the hasMany relationship
*
* @since 0.1.0
*/
async removeRelated<T extends Model>(relationshipName: string, relatedModel: T, options?: any): Promise<void> {
let descriptor: RelationshipDescriptor = <RelationshipDescriptor>(<typeof Model>this.constructor).schema[pluralize(relationshipName)];
await (<typeof Model>this.constructor).adapter.removeRelated(this, relationshipName, descriptor, relatedModel, options);
}
/**
* Return an human-friendly string representing this Model instance, with a
* summary of it's attributes
*
* @since 0.1.0
*/
inspect(): string {
let attributesSummary: string[] = [];
forEach((<typeof Model>this.constructor).schema, (descriptor, name) => {
attributesSummary.push(`${ name }=${ util.inspect((<any>this)[name]) }`);
});
return `<${ startCase(this.modelName) }:${ this.id == null ? '-new-' : this.id } ${ attributesSummary.join(', ') }>`;
}
/**
* Return an human-friendly string representing this Model instance
*
* @since 0.1.0
*/
toString(): string {
return `<${ startCase(this.modelName) }:${ this.id == null ? '-new-' : this.id }>`;
}
}