src/model.ts
import { mapValues, defaults, castArray, map, assign, omit, keys, pick, difference, forEach, chain, isString, Dictionary, isArrayLike, zipObject, times, constant, isObject, values, isNil, first, compact } from 'lodash';
import { Adapter } from './adapters';
import AAdapterEntity = Adapter.Base.AAdapterEntity;
import AAdapter = Adapter.Base.AAdapter;
import DataAccessLayer = Adapter.DataAccessLayer;
import TDataSource = Adapter.TDataSource;
import { Entity, Set, EntityFactory } from './entities';
import { deepFreeze } from './utils';
import { EntityTransformers } from './entityTransformers';
import { EFieldType, ModelDescription, _ModelDescription, FieldDescriptorTypeChecks } from './types/modelDescription';
import { QueryLanguage } from './types/queryLanguage';
import { IDataSourceRegistry, dataSourceRegistry } from './staticStores';
import { IEntityAttributes } from './types/entity';
/**
* The model class is used to interact with the population of all data of the same type.
*
* @typeParam TEntity - Test
* @param TEntity - Test
*/
export class Model<TEntity extends IEntityAttributes> {
public attributes: { [key: string]: _ModelDescription.FieldDescriptor };
private readonly _dataSources: IDataSourceRegistry;
public modelDesc: _ModelDescription.IModelDescription;
public get dataSources() {
return this._dataSources;
}
private readonly defaultDataSource: string;
private readonly _entityFactory: Entity.IEntitySpawner<TEntity>;
public get entityFactory() {
return this._entityFactory;
}
private readonly _entityTransformers: {
default: EntityTransformers.DefaultTransformer;
check: EntityTransformers.CheckTransformer;
[key: string]: EntityTransformers.AEntityTransformer | undefined;
};
public get entityTransformers() {
return this._entityTransformers;
}
public get ctor() {
return this.constructor as typeof Model;
}
/**
* Modifies the raw attributes description to standardize it
*
* @param desc - Attributes description map to transform
* @returns Attributes description map normalized, with properties defaulted
* @author Gerkin
*/
private static normalizeAttributesDescription( desc: ModelDescription.AttributesDescription ): _ModelDescription.AttributesDescription {
return mapValues(
desc,
val => Model.normalizeAttributeDescription( val )
);
}
/**
* Normalize fields to convert a {@link Raw.FieldDescriptor} to a {@link FieldDescriptor}
*
* @param attributeDesc - Attributes description to transform
* @returns Attribute description normalized, with properties defaulted
* @author Gerkin
*/
private static normalizeAttributeDescription( attributeDesc: ModelDescription.FieldDescriptor ): _ModelDescription.FieldDescriptor{
const fieldDescriptorToDefault = FieldDescriptorTypeChecks.isObjectFieldDescriptor( attributeDesc ) ? attributeDesc : { type: attributeDesc } as ModelDescription.ObjectFieldDescriptor;
const fieldDescriptorWithRequired = defaults( fieldDescriptorToDefault , {required: false } );
// Perform deep defaulting
if ( fieldDescriptorWithRequired.type === EFieldType.OBJECT ){
// TODO: Find the source of this cast issue
const attrsDescs: ModelDescription.AttributesDescription[] = castArray( fieldDescriptorWithRequired.attributes as any );
const attrsDescsDefaulted = map( attrsDescs, attrsDesc => Model.normalizeAttributesDescription( attrsDesc ) );
return assign( omit( fieldDescriptorWithRequired, 'attributes' ), {attributes: attrsDescsDefaulted} );
} else if ( fieldDescriptorWithRequired.type === EFieldType.ARRAY ){
const attrsDescs = castArray( fieldDescriptorWithRequired.of );
const ofDefaulted = map( attrsDescs, attrsDesc => Model.normalizeAttributeDescription( attrsDesc ) );
return assign( omit( fieldDescriptorWithRequired, 'of' ), {of: ofDefaulted} );
} else {
return fieldDescriptorWithRequired;
}
}
/**
* Create a new Model that is allowed to interact with all entities of data sources tables selected.
*
* @author gerkin
* @param name - Name of the model.
* @param modelDesc - Hash representing the configuration of the model.
*/
public constructor( public name: string, modelDesc: ModelDescription.IModelDescription ) {
// Normalize our sources: normalized form is an object with keys corresponding to source name, and key corresponding to remaps
const sourcesNormalized = Model.normalizeRemaps( modelDesc );
// List sources required by this model
const sourceNames = keys( sourcesNormalized );
const modelSources = pick( dataSourceRegistry, sourceNames );
const missingSources = difference( sourceNames, keys( modelSources ) );
if ( 0 !== missingSources.length ) {
throw new Error( `Missing data sources ${missingSources.map( v => `"${v}"` ).join( ', ' )}` );
}
// Now, we are sure that config is valid. We can configure our _dataSources with model options, and set `this` properties.
// Configure attributes-related elements
const attributes = Model.normalizeAttributesDescription( modelDesc.attributes );
this._entityTransformers = {
default: new EntityTransformers.DefaultTransformer( attributes ),
check: new EntityTransformers.CheckTransformer( attributes ),
};
this.attributes = deepFreeze( attributes );
// Configure source-related elements
forEach( sourcesNormalized, ( remap, sourceName ) => {
modelSources[sourceName].configureCollection( name, remap );
} );
this._dataSources = modelSources;
const defaultDataSource = first( keys( modelSources ) );
if ( isNil( defaultDataSource ) ){
throw new TypeError( 'Missing default data source' );
}
this.defaultDataSource = defaultDataSource;
// Store & expose the model description
this.modelDesc = deepFreeze( assign( modelDesc, { attributes, sources: sourcesNormalized } ) );
// Prepare our entity factory
this._entityFactory = EntityFactory( name, this.modelDesc, this );
}
/**
* Transform the `sources` string, list or object to a dictionary of remaps (as dictionaries).
*
* @author Gerkin
* @param modelDesc - Description of the model to normalize remaps for
*/
protected static normalizeRemaps( modelDesc: ModelDescription.IModelDescription ) {
const sourcesRaw = modelDesc.sources;
if ( isString( sourcesRaw ) ) {
// Single source: create an empty remap hash
return { [sourcesRaw]: {} } as Dictionary<Dictionary<string>>;
} else if ( isArrayLike( sourcesRaw ) ) {
return zipObject(
sourcesRaw,
// Create empty remaps dictionaries to zip with source names
times( sourcesRaw.length, constant( {} as Dictionary<string> ) )
);
} else {
// Normalize each sources separately
return mapValues( sourcesRaw, ( remap, dataSourceName ) => {
if ( true === remap ) {
return {} as Dictionary<string>;
} else if ( isObject( remap ) ) {
return remap as Dictionary<string>;
} else {
throw new TypeError(
`Datasource "${dataSourceName}" value is invalid: expect \`true\` or a remap hash, but have ${JSON.stringify( remap )}`
);
}
} );
}
}
/**
* Create a new Model that is allowed to interact with all entities of data sources tables selected.
*
* @author gerkin
* @throws {ReferenceError} Thrown if requested source name does not exists.
* @param dataSource - Name of the source to get. It corresponds to one of the sources you set in {@link Model#modelDesc}.Sources.
* @returns Source adapter with requested name.
*/
public getDataSource(
dataSource: TDataSource = this.defaultDataSource
): DataAccessLayer {
// If argument is a data access layer, check that it is in our {@link _dataSources} hash.
if ( dataSource instanceof DataAccessLayer ) {
if ( values( this._dataSources ).indexOf( dataSource ) === -1 ) {
throw new ReferenceError(
`Model does not contain data source "${dataSource.adapter.name}"`
);
}
return dataSource;
} else {
const dataSourceName =
dataSource instanceof AAdapter ? dataSource.name : dataSource;
if ( !this._dataSources.hasOwnProperty( dataSourceName ) ) {
throw new ReferenceError(
`Model does not contain data source "${dataSourceName}"`
);
}
return this._dataSources[dataSourceName];
}
}
/**
* Create a new *orphan* {@link Entity entity}.
*
* @author gerkin
* @param source - Object to copy attributes from.
* @returns New *orphan* entity.
*/
public spawn( source: Partial<TEntity> ): Entity<TEntity> {
const newEntity = new this.entityFactory( source );
return newEntity;
}
/**
* Create multiple new *orphan* {@link Entity entities}.
*
* @author gerkin
* @param sources - Array of objects to copy attributes from.
* @returns Set with new *orphan* entities.
*/
public spawnMany( sources: Array<Partial<TEntity>> ): Set<TEntity> {
return Set.create( this, map( sources, source => this.spawn( source ) ) );
}
/**
* Insert a raw source object in the data store.
*
* @author gerkin
* @param source - Object to copy attributes from.
* @param dataSource - Name of the data source, [DataAccessLayer] or [Adapter] to insert the entity in.
* @returns Promise resolved with new *sync* {@link Entity entity}.
* @tag CRUD
*/
public async insert(
source: TEntity,
dataSource: Adapter.TDataSource = this.defaultDataSource
): Promise<Entity<TEntity> | null> {
return this.makeEntity(
this.getDataSource( dataSource ).insertOne( this.name, source )
);
}
/**
* Insert multiple raw source objects in the data store.
*
* @author gerkin
* @param sources - Array of object to copy attributes from.
* @param dataSource - Name of the data source, [DataAccessLayer] or [Adapter] to insert the entities in.
* @returns Promise resolved with a {@link Set set} containing new *sync* entities.
* @tag CRUD
*/
public async insertMany(
sources: TEntity[],
dataSource: Adapter.TDataSource = this.defaultDataSource
): Promise<Set<TEntity>> {
return this.makeSet(
this.getDataSource( dataSource ).insertMany( this.name, sources )
);
}
/**
* Retrieve a single entity from specified data source that matches provided `queryFind` and `options`.
*
* @author gerkin
* @param queryFind - Query to get desired entity.
* @param options - Options for this query.
* @param dataSource - Name of the data source, [DataAccessLayer] or [Adapter] to get the entity from.
* @returns Promise resolved with the found {@link Entity entity} in *sync* state.
* @tag CRUD
*/
public async find(
queryFind?: QueryLanguage.SearchQuery,
options: QueryLanguage.IQueryOptions = {},
dataSource: Adapter.TDataSource = this.defaultDataSource
): Promise<Entity<TEntity> | null> {
return this.makeEntity(
this.getDataSource( dataSource ).findOne( this.name, queryFind, options )
);
}
/**
* Retrieve multiple entities from specified data source that matches provided `queryFind` and `options`.
*
* @author gerkin
* @param queryFind - Query to get desired entities.
* @param options - Options for this query.
* @param dataSource - Name of the data source, [DataAccessLayer] or [Adapter] to get the entities from.
* @returns Promise resolved with a {@link Set set} of found entities in *sync* state.
* @tag CRUD
*/
public async findMany(
queryFind?: QueryLanguage.SearchQuery,
options: QueryLanguage.IQueryOptions = {},
dataSource: Adapter.TDataSource = this.defaultDataSource
): Promise<Set<TEntity>> {
return this.makeSet(
this.getDataSource( dataSource ).findMany( this.name, queryFind, options )
);
}
/**
* Update a single entity from specified data source that matches provided `queryFind` and `options`.
*
* @author gerkin
* @param queryFind - Query to get desired entity.
* @param update - Attributes to update on matched set.
* @param options - Options for this query.
* @param dataSource - Name of the data source, [DataAccessLayer] or [Adapter] to update the entity to.
* @returns Promise resolved with the updated {@link Entity entity} in *sync* state.
* @tag CRUD
*/
public async update(
queryFind: QueryLanguage.SearchQuery | undefined,
update: object,
options: QueryLanguage.IQueryOptions = {},
dataSource: Adapter.TDataSource = this.defaultDataSource
): Promise<Entity<TEntity> | null> {
return this.makeEntity(
this.getDataSource( dataSource ).updateOne(
this.name,
queryFind,
update,
options
)
);
}
/**
* Update multiple entities from specified data source that matches provided `queryFind` and `options`.
*
* @author gerkin
* @param queryFind - Query to get desired entities.
* @param update - Attributes to update on matched set.
* @param options - Options for this query.
* @param dataSource - Name of the data source, [DataAccessLayer] or [Adapter] to update the entities to.
* @returns Promise resolved with the {@link Set set} of found entities in *sync* state.
* @tag CRUD
*/
public async updateMany(
queryFind: QueryLanguage.SearchQuery | undefined,
update: object,
options: QueryLanguage.IQueryOptions = {},
dataSource: Adapter.TDataSource = this.defaultDataSource
): Promise<Set<TEntity>> {
return this.makeSet(
this.getDataSource( dataSource ).updateMany(
this.name,
queryFind,
update,
options
)
);
}
/**
* Delete a single entity from specified data source that matches provided `queryFind` and `options`.
*
* @author gerkin
* @param queryFind - Query to get desired entity.
* @param options - Options for this query.
* @param dataSource - Name of the data source, [DataAccessLayer] or [Adapter] to delete the entity from.
* @returns Promise resolved with `undefined`.
* @tag CRUD
*/
public async delete(
queryFind?: QueryLanguage.SearchQuery,
options: QueryLanguage.IQueryOptions = {},
dataSource: Adapter.TDataSource = this.defaultDataSource
): Promise<void> {
return this.getDataSource( dataSource ).deleteOne(
this.name,
queryFind,
options
);
}
/**
* Delete multiple entities from specified data source that matches provided `queryFind` and `options`.
*
* @author gerkin
* @param queryFind - Query to get desired entities.
* @param options - Options for this query.
* @param dataSource - Name of the data source, [DataAccessLayer] or [Adapter] to delete the entities from.
* @returns Promise resolved with `undefined`.
* @tag CRUD
*/
public async deleteMany(
queryFind?: QueryLanguage.SearchQuery,
options: QueryLanguage.IQueryOptions = {},
dataSource: Adapter.TDataSource = this.defaultDataSource
): Promise<void> {
return this.getDataSource( dataSource ).deleteMany(
this.name,
queryFind,
options
);
}
/**
* Generate a new Set containing provided `AdapterEntities` wrapped in `Entities`
*
* @param dataSourceEntitiesPromise - Promise that may return adapter entities to wrap in a newly created `Set`
*/
protected async makeSet(
dataSourceEntitiesPromise: Promise<Array<AAdapterEntity | undefined>>
) {
const dataSourceEntities = await dataSourceEntitiesPromise;
const newEntities = compact( map( dataSourceEntities, dataSourceEntity => new this.entityFactory( dataSourceEntity ) ) );
const set = Set.create<TEntity>( this, newEntities );
return set;
}
/**
* Generate a new entity from the promise's result
*
* @author Gerkin
* @param dataSourceEntityPromise - Promise that may return an adapter entity to wrap in a newly created `Entity`
*/
protected async makeEntity(
dataSourceEntityPromise: Promise<AAdapterEntity | undefined>
) {
const dataSourceEntity = await dataSourceEntityPromise;
if ( isNil( dataSourceEntity ) ) {
return null;
}
return new this.entityFactory( dataSourceEntity );
}
}