model-layer
Version:
590 lines (455 loc) • 16.4 kB
text/typescript
import {EventEmitter} from "events";
import EqualStack from "./EqualStack";
import {Type, InputType, OutputType, JsonType, IType} from "./type/Type";
import { invalidValuesAsString, isObject } from "./utils";
import Walker from "./Walker";
import {
UnknownPropertyError,
ModelWithoutStructureError,
InvalidKeyError,
InvalidValueError,
RequiredError,
ConstValueError,
DataShouldBeObjectError
} from "./errors";
export interface ISimpleObject extends Object {
[propName: string]: any;
}
export type MutableObject<T extends ISimpleObject> = {
-readonly [key in keyof T]: T[key];
}
export type StructureType = {
[key: string]: IType | (new (...args: any) => IType)
}
export type EventHandlerType<TModel extends Model<any>> = (
event: IChangeEvent<TModel>,
options: ISimpleObject
) => void;
type ReadOnlyPartial<TData> = {
readonly [key in keyof TData]?: TData[key];
};
interface IChangeEvent<TModel extends Model<any>> {
prev: TModel["row"];
changes: ReadOnlyPartial<TModel["row"]>;
}
export abstract class Model<ChildModel extends {
structure: () => StructureType;
}> extends EventEmitter {
static Type = Type;
TInputData!: InputType< ReturnType< ChildModel["structure"] > >;
TInput!: InputType< ReturnType< ChildModel["structure"] > > | this;
TOutput!: this;
TJson!: JsonType< ReturnType< ChildModel["structure"] > >;
row: OutputType< ReturnType< ChildModel["structure"] > >;
// "id"
primaryKey?: string;
// value of id
primaryValue?: number | string;
parent?: Model<any>;
// row properties
private properties: any;
constructor(inputData?: InputType< ReturnType< ChildModel["structure"] > >) {
super();
this.prepareStructure();
const defaultRow: any = {};
for (const propKey in this.properties) {
const key = propKey as keyof this["row"];
if ( key === "*" ) {
continue;
}
const description = this.properties[ key ];
// default value is null, or something from description
let defaultValue = description.default();
// default can be invalid
defaultValue = description.prepare(defaultValue, key, this);
defaultRow[ key ] = defaultValue;
}
this.row = Object.freeze(defaultRow);
(this as any).isInit = true; // do not check const
this.set(inputData || {} as any);
delete (this as any).isInit;
}
abstract structure(): {[key: string]: IType | (new (...args: any) => IType)};
get<TKey extends keyof this["row"]>(key: TKey): this["row"][TKey] {
return this.row[ key ];
}
set(row: this["TInputData"], options?: ISimpleObject) {
options = options || {
onlyValidate: false
};
const newData: any = {};
const oldData = this.row;
// clone old values in oldData
for (const key in oldData) {
newData[ key ] = oldData[ key ];
}
const anyKeyDescription = this.properties["*"];
for (const key in row) {
let description = this.properties[ key ];
if ( !description ) {
if ( anyKeyDescription ) {
description = anyKeyDescription;
const isValidKey = description.validateKey( key );
if ( !isValidKey ) {
throw new InvalidKeyError({
key
});
}
} else {
throw new UnknownPropertyError({
propertyName: key
});
}
}
let value = row[ key ];
// cast input value to expected format
value = description.prepare(value, key, this);
// validate by params
const isValid = description.validate( value, key );
if ( !isValid ) {
const valueAsString = invalidValuesAsString( value );
throw new InvalidValueError({
key,
value: valueAsString
});
}
newData[ key ] = value;
}
// modify by reference
// because it conveniently
this.prepare( newData );
const changes: any = {};
for (const key in newData) {
const anyKey: any = key;
let description = this.properties[ anyKey ];
if ( !description ) {
description = anyKeyDescription;
}
let newValue = newData[ key ];
const oldValue = oldData[ key ];
// if field has type string,
// then he must be string or null in anyway!
if ( this.prepare !== Model.prototype.prepare ) {
newValue = description.prepare(newValue, key, this);
}
if ( oldValue !== newValue ) {
if ( description.const ) {
if ( !(this as any).isInit ) {
throw new ConstValueError({
key
});
}
}
}
if ( description.required ) {
if ( newValue == null ) {
throw new RequiredError({
key
});
}
}
if ( newValue !== oldValue ) {
changes[ key ] = newValue;
newData[ key ] = newValue;
}
}
const hasChanges = Object.keys( changes ).length > 0;
if ( !hasChanges ) {
return;
}
// juniors love use model.row for set
// stick on his hands
Object.freeze(newData);
this.validate(newData);
// do not call emit and set newData
if ( options.onlyValidate ) {
return;
}
this.row = newData;
if ( this.primaryKey ) {
const primaryValue = this.row[ this.primaryKey ];
(this as any)[ this.primaryKey ] = primaryValue;
this.primaryValue = primaryValue;
}
for (const key in changes) {
this.emit("change:" + key, {
prev: oldData,
changes
}, options);
}
this.emit("change", {
prev: oldData,
changes
}, options);
}
isValid(row: this["TInputData"]): boolean {
if ( !isObject(row) ) {
throw new DataShouldBeObjectError({});
}
try {
this.set(row, {
onlyValidate: true
});
return true;
} catch (err) {
return false;
}
}
hasProperty<Key extends keyof this["row"]>(key: Key): boolean {
return this.row.hasOwnProperty( key );
}
getDescription<Key extends keyof this["row"]>(key: Key) {
const iKey = key as any;
return this.properties[ iKey ] || this.properties["*"];
}
hasValue<Key extends keyof this["row"]>(key: Key): boolean {
const value = this.row[ key ];
if ( value == null ) {
return false;
} else {
return true;
}
}
walk(
iteration: (model: Model<any>, walker: Walker) => void,
stack?: any
) {
stack = stack || [];
for (const key in this.row) {
const value = this.row[ key ];
let elements = [value];
const isModelsArray = (
Array.isArray(value) &&
value[0] instanceof Model
);
const isCollection = (
value &&
Array.isArray(value.models) &&
value.models[0] instanceof Model
);
if ( isModelsArray ) {
elements = value;
}
else if ( isCollection ) {
elements = value.models;
}
for (let i = 0, n = elements.length; i < n; i++) {
const element = elements[ i ] as any;
if ( element instanceof Model ) {
const model = element;
// stop circular recursion
if ( stack.includes(model) ) {
continue;
}
stack.push( model );
// api for stop and skip elements
const walker = new Walker();
// callback
iteration(model, walker);
// inside iteration we call walker.exit();
if ( walker.isExited() ) {
return;
}
// inside iteration we call walker.continue();
if ( walker.isContinued() ) {
continue;
}
// recursion
model.walk(iteration, stack);
}
}
}
}
findChild(
iteration: (model: Model<any>) => boolean
): Model<any> | undefined {
let child: Model<any> | undefined;
this.walk((model, walker) => {
const result = iteration( model );
if ( result ) {
child = model;
walker.exit();
}
});
return child;
}
filterChildren(
iteration: (model: Model<any>) => boolean
): Array<Model<any>> {
const children: Array<Model<any>> = [];
this.walk((model) => {
const result = iteration( model );
if ( result ) {
children.push( model );
}
});
return children;
}
filterChildrenByInstance<TModel extends Model<any>>(
SomeModel: new (...args: any) => TModel
): TModel[] {
return this.filterChildren((model) =>
model instanceof SomeModel
) as TModel[];
}
findParent(
iteration: (model: Model<any>) => boolean,
stack?: any
): Model<any> | undefined {
stack = stack || [];
let parent = this.parent;
while ( parent ) {
// stop circular recursion
if ( stack.includes(parent) ) {
return;
}
stack.push( parent );
const result = iteration( parent );
if ( result ) {
return parent;
}
parent = parent.parent;
}
}
filterParents(
iteration: (model: Model<any>) => boolean
): Array<Model<any>> {
const parents: Array<Model<any>> = [];
let parent = this.parent;
while ( parent ) {
const result = iteration( parent );
if ( result ) {
parents.push( parent );
}
parent = parent.parent;
}
return parents;
}
findParentInstance<TModel extends Model<any>>(
SomeModel: new (...args: any) => TModel
): TModel {
return this.findParent((model) =>
model instanceof SomeModel
) as TModel;
}
toJSON(stack = []): this["TJson"] {
const json: any = {};
for (const key in this.row) {
const description = this.getDescription( key );
let value = this.row[ key ];
if ( value != null ) {
value = description.toJSON( value, [...stack] );
}
json[ key ] = value;
}
this.prepareJSON( json );
return json;
}
clone(stack?: EqualStack): this {
stack = stack || new EqualStack();
// circular reference
const existsClone = stack.get(this);
if ( existsClone ) {
return existsClone;
}
const clone: this = Object.create( this.constructor.prototype );
stack.add(this, clone);
const cloneData: Partial<this["row"]> = {};
for (const key in this.row) {
const description = this.getDescription( key );
let value = this.row[ key ];
if ( value != null ) {
value = description.clone( value, stack, clone );
}
cloneData[ key ] = value;
}
(clone as any).row = Object.freeze(cloneData);
return clone;
}
equal(otherModel: this | this["row"], stack?: any): boolean {
stack = stack || new EqualStack();
for (const key in this.row) {
const anyKey = key as any;
const description = this.getDescription( key );
const selfValue = this.row[ anyKey ];
const otherValue = (
otherModel instanceof Model ?
otherModel.row[ anyKey ] :
otherModel[ anyKey ]
);
const isEqual = description.equal( selfValue, otherValue, stack );
if ( !isEqual ) {
return false;
}
}
// check additional keys from other model
const otherData = (
otherModel instanceof Model ?
otherModel.row :
otherModel
);
for (const key in otherData) {
if ( key in this.row ) {
continue;
}
// exists unknown property for self model
return false;
}
return true;
}
validate(row: this["TInputData"]): void {
// for invalid row throw error here
}
prepare(row: this["TInputData"]): void {
// any calculations with row by reference
}
prepareJSON(json: this["TJson"]): void {
// any calculations with json by reference
}
on(
eventName: "change",
keyOrListener: string | EventHandlerType<this>,
listener?: EventHandlerType<this>
): this {
if ( typeof keyOrListener === "string" ) {
const key = keyOrListener;
const description = this.getDescription(key);
if ( !description ) {
throw new UnknownPropertyError({
propertyName: key
});
}
super.on(eventName + ":" + key, listener as any);
}
else {
listener = keyOrListener as any;
super.on(eventName, listener as any);
}
return this;
}
private prepareStructure(): void {
if ( this.constructor.prototype.hasOwnProperty( "properties" ) ) {
return;
}
const constructor = this.constructor as any;
const properties = constructor.prototype.structure();
// for speedup constructor, saving structure to prototype
this.constructor.prototype.properties = properties;
for (const key in this.properties) {
const description = this.properties[ key ];
this.properties[ key ] = Type.create( description, key );
if ( description.primary ) {
this.constructor.prototype.primaryKey = key;
}
}
// structure must be static... really static
Object.freeze( properties );
}
}
// for js
(Model as any).prototype.structure = function() {
throw new ModelWithoutStructureError({
className: this.constructor.name
});
};
Type.Model = Model;