memserver
Version:
in-memory database/ORM and http mock server you can run in-browser and node environments. Built for large frontend teams, fast tests and rapid prototyping
410 lines (339 loc) • 12.9 kB
text/typescript
import kleur from "kleur";
import inspect from "object-inspect";
import { underscore } from "@emberx/string";
import { pluralize, singularize } from "inflected";
import { insertFixturesWithTypechecks, primaryKeyTypeSafetyCheck, generateUUID } from "./utils";
type RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>;
}[Keys];
export interface InternalModelShape {
id?: number;
uuid?: string;
[propName: string]: any;
}
export type InternalModel = RequireOnlyOne<InternalModelShape, "id" | "uuid">;
// TODO: remove static setters and maybe getters
export default class MemServerModel {
static _DB = {};
static _modelDefinitions = {};
static _attributes = {};
static _defaultAttributes = {}; // NOTE: probably a decorator here in future
static _embedReferences = {}; // NOTE: serializer concern
static primaryKey: string | null = null;
static get DB(): Array<InternalModel> {
if (!this._DB[this.name]) {
this._DB[this.name] = [];
return this._DB[this.name];
}
return this._DB[this.name];
}
static get attributes(): Set<string> {
if (!this._attributes[this.name]) {
this._attributes[this.name] = new Set();
this._modelDefinitions[this.name] = this;
return this._attributes[this.name];
}
return this._attributes[this.name];
}
static set defaultAttributes(value: object) {
Object.keys(value).forEach((key) => {
if (!this.attributes.has(key)) {
this.attributes.add(key);
}
});
this._defaultAttributes = value;
}
static get defaultAttributes(): object {
return this._defaultAttributes;
}
static set embedReferences(references: Object) {
this._embedReferences[this.name] = references;
}
static get embedReferences() {
// NOTE: serializer concern
if (!this._embedReferences[this.name]) {
this._embedReferences[this.name] = {};
return this._embedReferences[this.name];
}
return this._embedReferences[this.name];
}
static resetDatabase(fixtures?: Array<InternalModel>): Array<InternalModel> {
this.DB.length = 0;
this.attributes.clear();
Object.keys(this.defaultAttributes).forEach((key) => this.attributes.add(key));
if (fixtures) {
insertFixturesWithTypechecks(this, fixtures);
}
return this.DB;
}
static count(): number {
return this.DB.length;
}
static find(param: Array<number> | number): Array<InternalModel> | InternalModel | undefined {
// NOTE: turn param into an interface with id or uuid
if (!param) {
throw new Error(
kleur.red(`[Memserver] ${this.name}.find(id) cannot be called without a valid id`)
);
} else if (Array.isArray(param)) {
return Array.from(this.DB).reduce((result: InternalModel[], model) => {
const foundModel = param.includes(model.id) ? model : null;
return foundModel ? result.concat([foundModel]) : result;
}, []) as Array<InternalModel>;
} else if (typeof param !== "number") {
throw new Error(
kleur.red(`[Memserver] ${this.name}.find(id) cannot be called without a valid id`)
);
}
return Array.from(this.DB).find((model) => model.id === param) as InternalModel | undefined;
}
static findBy(options: object): InternalModel | undefined {
if (!options) {
throw new Error(
kleur.red(`[Memserver] ${this.name}.findBy(id) cannot be called without a parameter`)
);
}
const keys = Object.keys(options);
return this.DB.find((model) => comparison(model, options, keys, 0));
}
static findAll(options = {}): Array<InternalModel> {
const keys = Object.keys(options);
if (keys.length === 0) {
return Array.from(this.DB);
}
return Array.from(this.DB).filter((model) => comparison(model, options, keys, 0));
}
static insert(options?: InternalModelShape): InternalModel {
options = options || {};
if (this.DB.length === 0) {
this.primaryKey = this.primaryKey || (options.uuid ? "uuid" : "id");
this.attributes.add(this.primaryKey);
Object.keys(this.defaultAttributes).forEach((attribute) => this.attributes.add(attribute));
}
if (!options.hasOwnProperty(this.primaryKey)) {
options[this.primaryKey] =
this.primaryKey === "id" ? incrementId(this.DB, this) : generateUUID();
}
primaryKeyTypeSafetyCheck(this.primaryKey, options[this.primaryKey], this.name);
const target = Array.from(this.attributes).reduce((result, attribute) => {
if (typeof result[attribute] === "function") {
result[attribute] = result[attribute].apply(result);
} else if (!result.hasOwnProperty(attribute)) {
result[attribute] = undefined;
}
return result;
}, Object.assign({}, this.defaultAttributes, options));
const existingRecord = target.id ? this.find(target.id) : this.findBy({ uuid: target.uuid });
if (existingRecord) {
throw new Error(
kleur.red(
`[Memserver] ${this.name} ${this.primaryKey} ${
target[this.primaryKey]
} already exists in the database! ${this.name}.insert(${inspect(options)}) fails`
)
);
}
Object.keys(target)
.filter((attribute) => !this.attributes.has(attribute))
.forEach((attribute) => this.attributes.add(attribute));
this.DB.push(target as InternalModel);
return target as InternalModel;
}
static update(record: InternalModel): InternalModel {
if (!record || (!record.id && !record.uuid)) {
throw new Error(
kleur.red(
`[Memserver] ${this.name}.update(record) requires id or uuid primary key to update a record`
)
);
}
const targetRecord = record.id ? this.find(record.id) : this.findBy({ uuid: record.uuid });
if (!targetRecord) {
throw new Error(
kleur.red(
`[Memserver] ${this.name}.update(record) failed because ${this.name} with ${
this.primaryKey
}: ${record[this.primaryKey]} does not exist`
)
);
}
const recordsUnknownAttribute = Object.keys(record).find(
(attribute) => !this.attributes.has(attribute)
);
if (recordsUnknownAttribute) {
throw new Error(
kleur.red(
`[Memserver] ${this.name}.update ${this.primaryKey}: ${record[this.primaryKey]} fails, ${
this.name
} model does not have ${recordsUnknownAttribute} attribute to update`
)
);
}
return Object.assign(targetRecord, record);
}
static delete(record?: InternalModel) {
if (this.DB.length === 0) {
throw new Error(
kleur.red(
`[Memserver] ${this.name} has no records in the database to delete. ${
this.name
}.delete(${inspect(record)}) failed`
)
);
} else if (!record) {
throw new Error(
kleur.red(
`[Memserver] ${this.name}.delete(model) model object parameter required to delete a model`
)
);
}
const targetRecord = record.id ? this.find(record.id) : this.findBy({ uuid: record.uuid });
if (!targetRecord) {
throw new Error(
kleur.red(
`[Memserver] Could not find ${this.name} with ${this.primaryKey} ${
record[this.primaryKey]
} to delete. ${this.name}.delete(${inspect(record)}) failed`
)
);
}
if (Array.isArray(targetRecord)) {
targetRecord.forEach((record) => {
const targetIndex = this.DB.indexOf(record);
this.DB.splice(targetIndex, 1);
});
return targetRecord;
}
const targetIndex = this.DB.indexOf(targetRecord);
this.DB.splice(targetIndex, 1);
return targetRecord;
}
static embed(relationship): object {
// EXAMPLE: { comments: Comment }
if (typeof relationship !== "object" || relationship.name) {
throw new Error(
kleur.red(
`[Memserver] ${this.name}.embed(relationshipObject) requires an object as a parameter: { relationshipKey: $RelationshipModel }`
)
);
}
const key = Object.keys(relationship)[0];
if (!relationship[key]) {
throw new Error(
kleur.red(
`[Memserver] ${this.name}.embed() fails: ${key} Model reference is not a valid. Please put a valid $ModelName to ${this.name}.embed()`
)
);
}
return Object.assign(this.embedReferences, relationship);
}
static serializer(objectOrArray: InternalModel | Array<InternalModel>) {
if (!objectOrArray) {
return;
} else if (Array.isArray(objectOrArray)) {
return (objectOrArray as Array<InternalModel>).map((object) => this.serialize(object));
}
return this.serialize(objectOrArray as InternalModel);
}
static serialize(object: InternalModel) {
// NOTE: add links object ?
if (Array.isArray(object)) {
throw new Error(
kleur.red(
`[Memserver] ${this.name}.serialize(object) expects an object not an array. Use ${this.name}.serializer(data) for serializing array of records`
)
);
}
const objectWithAllAttributes = Array.from(this.attributes).reduce((result, attribute) => {
if (result[attribute] === undefined) {
result[attribute] = null;
}
return result;
}, Object.assign({}, object));
return Object.keys(this.embedReferences).reduce((result, embedKey) => {
const embedModel = this.embedReferences[embedKey];
const embeddedRecords = this.getRelationship(object, embedKey, embedModel);
return Object.assign({}, result, { [embedKey]: embedModel.serializer(embeddedRecords) });
}, objectWithAllAttributes);
}
static getRelationship(
parentObject,
relationshipName: string,
relationshipModel?: InternalModel
) {
if (Array.isArray(parentObject)) {
throw new Error(
kleur.red(
`[Memserver] ${this.name}.getRelationship expects model input to be an object not an array`
)
);
}
const targetRelationshipModel = relationshipModel || this.embedReferences[relationshipName];
const hasManyRelationship = pluralize(relationshipName) === relationshipName;
if (!targetRelationshipModel) {
throw new Error(
kleur.red(
`[Memserver] ${relationshipName} relationship could not be found on ${this.name} model. Please put the ${relationshipName} Model object as the third parameter to ${this.name}.getRelationship function`
)
);
} else if (hasManyRelationship) {
if (parentObject.id) {
const hasManyIDRecords = targetRelationshipModel.findAll({
[`${underscore(this.name)}_id`]: parentObject.id,
});
return hasManyIDRecords.length > 0 ? hasManyIDRecords : [];
} else if (parentObject.uuid) {
const hasManyUUIDRecords = targetRelationshipModel.findAll({
[`${underscore(this.name)}_uuid`]: parentObject.uuid,
});
return hasManyUUIDRecords.length > 0 ? hasManyUUIDRecords : [];
}
}
const objectRef =
parentObject[`${underscore(relationshipName)}_id`] ||
parentObject[`${underscore(relationshipName)}_uuid`] ||
parentObject[`${underscore(targetRelationshipModel.name)}_id`] ||
parentObject[`${underscore(targetRelationshipModel.name)}_uuid`];
if (objectRef && typeof objectRef === "number") {
return targetRelationshipModel.find(objectRef);
} else if (objectRef) {
return targetRelationshipModel.findBy({ uuid: objectRef });
}
if (parentObject.id) {
return targetRelationshipModel.findBy({
[`${underscore(this.name)}_id`]: parentObject.id,
});
} else if (parentObject.uuid) {
return targetRelationshipModel.findBy({
[`${underscore(this.name)}_uuid`]: parentObject.uuid,
});
}
}
}
export function resetMemory(DefaultBaseModel) {
DefaultBaseModel._DB = {};
DefaultBaseModel._modelDefinitions = {};
DefaultBaseModel._attributes = {};
DefaultBaseModel._defaultAttributes = {};
DefaultBaseModel._embedReferences = {};
}
function incrementId(DB, Model) {
if (!DB || DB.length === 0) {
return 1;
}
const lastIdInSequence = DB.map((model) => model.id)
.sort((a, b) => a - b)
.find((id, index, array) => (index === array.length - 1 ? true : id + 1 !== array[index + 1]));
return lastIdInSequence + 1;
}
// NOTE: if records were ordered by ID, then there could be performance benefit
function comparison(model, options, keys, index = 0) {
const key = keys[index];
if (keys.length === index) {
return model[key] === options[key];
} else if (model[key] === options[key]) {
return comparison(model, options, keys, index + 1);
}
return false;
}