diaspora-orm/diaspora

View on GitHub
src/entities/entity.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import { keys, reduce, isNil, cloneDeep, difference, forEach, castArray, isUndefined, isNull, defaults, chain, isEqual, mapValues, isObject, isString, isInteger, slice, concat, uniq, reject, transform, isEmpty, without, values } from 'lodash';
import { SequentialEvent } from 'sequential-event';

import { Adapter } from '../adapters';
import AAdapterEntity = Adapter.Base.AAdapterEntity;
import AAdapter = Adapter.Base.AAdapter;
import DataAccessLayer = Adapter.DataAccessLayer;
import TDataSource = Adapter.TDataSource;

import { Errors } from '../errors';
import { Model } from '../model';
import { QueryLanguage } from '../types/queryLanguage';
import { _ModelDescription } from '../types/modelDescription';
import { IEntityAttributes, EEntityState, IIdHash, IEntityProperties, EntityUid } from '../types/entity';
import { logger } from '../logger';

const DEFAULT_OPTIONS = { skipEvents: false };

/**
 * The entity is the class you use to manage a single document in all data sources managed by your model.
 * > Note that this class is proxied: you may try to access to undocumented class properties to get entity's data attributes
 *
 * @extends SequentialEvent
 */
export abstract class Entity<TEntity extends IEntityAttributes> extends SequentialEvent {
    public get attributes() {
        return this.getAttributes();
    }
    
    public set attributes( newAttributes: TEntity | null ) {
        this._attributes = newAttributes;
    }
    
    public get state() {
        return this._state;
    }
    
    public get lastDataSource() {
        return this._lastDataSource;
    }
    
    public get dataSources() {
        return this._dataSources;
    }
    
    public get ctor() {
        return this.constructor as typeof Entity & Entity.IEntitySpawner<TEntity>;
    }
    
    private _attributes: TEntity | null = null;
    
    private _state: EEntityState = EEntityState.ORPHAN;
    
    private _lastDataSource: DataAccessLayer | null;
    
    private readonly _dataSources: Entity.IDataSourceMap<AAdapterEntity>;
    
    private idHash: IIdHash;
    
    /**
     * Create a new entity.
     *
     * @author gerkin
     * @param modelDesc   - Model configuration that generated the associated `model`.
     * @param source - Hash with properties to copy on the new object.
     *        If provided object inherits AdapterEntity, the constructed entity is built in `sync` state.
     */
    public constructor(
        public readonly model: Model<TEntity>,
        source?: AAdapterEntity | TEntity | null
    ) {
        super();
        const modelAttrsKeys = keys( this.model.modelDesc.attributes );
        
        // ### Init defaults
        const sources = reduce(
            model.dataSources,
            ( acc: Entity.IDataSourceMap<AAdapterEntity>, adapter ) => acc.set( adapter, null ),
            new WeakMap()
        );
        this._dataSources = Object.seal( sources );
        this._lastDataSource = null;
        this.idHash = {};
        
        // ### Load datas from source
        // If we construct our Entity from a datastore entity (that can happen internally in Diaspora), set it to `sync` state
        if ( source instanceof AAdapterEntity ) {
            // ### Load datas from source
            // If we construct our Entity from a datastore entity (that can happen internally in Diaspora), set it to `sync` state
            this.setLastDataSourceEntity( DataAccessLayer.retrieveAccessLayer( source.dataSource ), source );
        } else {
            // ### Generate attributes
            // Now we know that the source is valid. Deep clone to detach object values from entity
            this._attributes = isNil( source ) ? null : this.applyDefaults( cloneDeep( source ) );
        }
        

        // ### Final validation
        // Check keys provided in source
        const sourceDModel = difference( keys( this._attributes ), modelAttrsKeys );
        if ( 0 !== sourceDModel.length ) {
            // Later, add a criteria for schemaless models
            throw new Error( `Source has unknown keys: ${JSON.stringify( sourceDModel )} in ${JSON.stringify( source )}` );
        }
        
        // ### Load events
        forEach(
            this.model.modelDesc.lifecycleEvents,
            ( eventFunctions, eventName ) => {
                // Iterate on each event functions. `castArray` will ensure we iterate on an array if a single function is provided.
                forEach( castArray( eventFunctions ), eventFunction => this.on( eventName, eventFunction ) );
            }
        );
    }
    
    /**
     * Apply the default values using the {@link DefaultTransformer}.
     *
     * @author Gerkin
     */
    public applyDefaults(): this;
    public applyDefaults( attributes: TEntity | null ): TEntity | null;
    public applyDefaults( attributes?: TEntity | null ) {
        const attrs = isUndefined( attributes ) ? this._attributes : attributes;
        const defaultApplied = isNull( attrs ) ? null : this.ctor.model.entityTransformers.default.apply( attrs ) as TEntity;
        return isUndefined( attributes ) ? this : defaultApplied;
    }
    
    /**
     * Check if the entity matches model description.
     *
     * @author gerkin
     * @throws EntityValidationError Thrown if validation failed. This breaks event chain and prevent persistance.
     * @returns This function does not return anything.
     * @see Validator.Validator#validate
     */
    public validate(): this;
    public validate( attributes: TEntity | null ): TEntity | null;
    public validate( attributes?: TEntity | null ) {
        const attrs = isUndefined( attributes ) ? this._attributes : attributes;
        const validateApplied = isNull( attrs ) ? null : this.ctor.model.entityTransformers.check.apply( attrs ) as TEntity;
        return isUndefined( attributes ) ? this : validateApplied;
    }
    
    /**
     * Applied before persisting the entity, this function is in charge to convert entity convinient attributes to a raw entity.
     *
     * @author gerkin
     * @param   data - Data to convert to primitive types.
     * @returns Object with Primitives-only types.
     */
    public static serialize(
        data: IEntityAttributes | null
    ): IEntityAttributes | undefined {
        return data ? cloneDeep( data ) : undefined;
    }
    
    /**
     * Applied after retrieving the entity, this function is in charge to convert entity raw attributes to convinient types.
     *
     * @author gerkin
     * @param   data - Data to convert from primitive types.
     * @returns Object with Primitives & non primitives types.
     */
    public static deserialize(
        data: IEntityAttributes | null
    ): IEntityAttributes | undefined {
        return data ? cloneDeep( data ) : undefined;
    }
    
    /**
     * Generate the query to get this unique entity in the desired data source.
     *
     * @author gerkin
     * @param   dataSource - Data source to get query for.
     * @returns Query to find this entity.
     */
    public uidQuery(
        dataSource?: TDataSource
    ): QueryLanguage.SelectQueryOrCondition {
        const dataSourceFixed = this.getDataSource( dataSource );
        // Todo: precise return type
        return {
            id: this.idHash[dataSourceFixed.name],
        };
    }
    
    /**
     * Return the collectionName of this entity in the specified data source.
     *
     * @author gerkin
     * @returns Name of the collectionName.
     */
    public collectionName( dataSource?: TDataSource ) {
        // Will be used later
        return this.model.name;
    }
    
    /**
     * Returns a copy of this entity attributes.
     *
     * @author gerkin
     * @returns Attributes of this entity.
     */
    public getAttributes( dataSource?: undefined ): TEntity | null;
    public getAttributes( dataSource: TDataSource ): TEntity;
    public getAttributes(
        dataSource?: TDataSource | undefined
    ): TEntity | null {
        if ( dataSource ) {
            // Get the target data source
            const dataSourceFixed = this.getDataSource( dataSource );
            const adapterEntity = this._dataSources.get( dataSourceFixed );
            return adapterEntity ? adapterEntity.attributes as any : null;
        }
        return this._attributes;
    }
    
    /**
     * Returns a copy of this entity attributes.
     *
     * @author gerkin
     * @returns Attributes of this entity.
     */
    public getProperties( dataSource: TDataSource ): ( TEntity & IEntityProperties ) | null {
        // Get the target data source
        const dataSourceFixed = this.getDataSource( dataSource );
        const adapterEntity = this._dataSources.get( dataSourceFixed );
        return adapterEntity ? adapterEntity.properties as any : null;
    }
    
    /**
     * Save this entity in specified data source.
     *
     * @fires Entity#beforeUpdate
     * @fires Entity#afterUpdate
     * @author gerkin
     * @param   sourceName - Name of the data source to persist entity in.
     * @param   options    - Hash of options for this query. You should not use this parameter yourself: Diaspora uses it internally.
     * @returns Promise resolved once entity is saved. Resolved with `this`.
     */
    public async persist(
        dataSource?: TDataSource,
        options: Entity.IOptions = {}
    ) {
        defaults( options, DEFAULT_OPTIONS );
        // Change the state of the entity
        const beforeState = this.state;
        this._state = EEntityState.SYNCING;
        // Get the target data source & its name
        const dataSourceFixed = this.getDataSource( dataSource );
        const finalSourceName = dataSourceFixed.name;
        // Generate events args
        const eventsArgs = [finalSourceName, this.serialize()];
        const _maybeEmit = this.maybeEmit.bind( this, options, eventsArgs );
        
        // Get suffix. If entity was orphan, we are creating. Otherwise, we are updating
        const suffix = 'orphan' === beforeState ? 'Create' : 'Update';
        
        // Trigger events & validation
        await _maybeEmit( ['beforePersist', 'beforeValidate'] );
        this.attributes = this.validate( this.attributes );
        this.attributes = this.applyDefaults( this.attributes );
        await _maybeEmit( ['afterValidate', `beforePersist${suffix}`] );
        
        // Depending on state, we are going to perform a different operation
        const dataStoreEntity: AAdapterEntity | undefined = await ( beforeState === 'orphan'
        ? this.persistCreate( dataSourceFixed )
        : this.persistUpdate( dataSourceFixed, options ) );
        if ( !dataStoreEntity ) {
            throw new Error( 'Insert/Update returned nothing.' );
        }
        // Now we insert data in stores
        this.setLastDataSourceEntity( dataSourceFixed, dataStoreEntity );
        
        return _maybeEmit( [`afterPersist${suffix}`, 'afterPersist'] );
    }
    
    /**
     * Reload this entity from specified data source.
     *
     * @fires Entity#beforeFind
     * @fires Entity#afterFind
     * @author gerkin
     * @param   sourceName         - Name of the data source to fetch entity from.
     * @param   options            - Hash of options for this query. You should not use this parameter yourself: Diaspora uses it internally.
     * @returns Promise resolved once entity is reloaded. Resolved with `this`.
     */
    public async fetch( dataSource?: TDataSource, options: Entity.IOptions = {} ) {
        defaults( options, DEFAULT_OPTIONS );
        // Change the state of the entity
        const beforeState = this.state;
        this._state = EEntityState.SYNCING;
        // Generate events args
        const dataSourceFixed = this.getDataSource( dataSource );
        const eventsArgs = [dataSourceFixed.name, this.serialize()];
        const _maybeEmit = this.maybeEmit.bind( this, options, eventsArgs );
        
        await _maybeEmit( 'beforeFetch' );
        
        const dataStoreEntity = await this.execIfOkState(
            beforeState,
            dataSourceFixed,
            'findOne'
        );
        // Now we insert data in stores
        this.setLastDataSourceEntity( dataSourceFixed, dataStoreEntity );
        
        return _maybeEmit( 'afterFetch' );
    }
    
    /**
     * Delete this entity from the specified data source.
     *
     * @fires Entity#beforeDelete
     * @fires Entity#afterDelete
     * @author gerkin
     * @param   sourceName - Name of the data source to delete entity from.
     * @param   options    - Hash of options for this query. You should not use this parameter yourself: Diaspora uses it internally.
     * @returns Promise resolved once entity is destroyed. Resolved with `this`.
     */
    public async destroy(
        dataSource?: TDataSource,
        options: Entity.IOptions = {}
    ) {
        defaults( options, DEFAULT_OPTIONS );
        // Change the state of the entity
        const beforeState = this.state;
        this._state = EEntityState.SYNCING;
        // Generate events args
        const dataSourceFixed = this.getDataSource( dataSource );
        const eventsArgs = [dataSourceFixed.name, this.serialize()];
        const _maybeEmit = this.maybeEmit.bind( this, options, eventsArgs );
        
        await _maybeEmit( 'beforeDestroy' );
        
        await this.execIfOkState( beforeState, dataSourceFixed, 'deleteOne' );
        
        // Now we insert data in stores
        this.setLastDataSourceEntity( dataSourceFixed, null );
        
        return _maybeEmit( 'afterDestroy' );
    }
    
    /**
     * Get the ID for the given source name. This ID is retrieved from the Data Store entity, not the latest ID hash of the entity itself
     *
     * @param   sourceName - Name of the source to get ID from.
     * @returns Id of this entity in requested data source.
     */
    public getId( dataSource: TDataSource ): EntityUid | null {
        const dataSourceFixed = this.getDataSource( dataSource );
        const entity = this.dataSources.get( dataSourceFixed );
        if ( entity ) {
            return entity.id;
        } else {
            return null;
        }
    }
    
    /**
     * Generate a diff update query by checking deltas with last source interaction.
     *
     * @author gerkin
     * @param   dataSource - Data source to diff with.
     * @returns Diff query.
     */
    public getDiff( dataSource?: TDataSource ): IEntityAttributes | undefined {
        const dataSourceFixed = this.getDataSource( dataSource );
        const dataStoreEntity = this.dataSources.get( dataSourceFixed );
        // All is diff if not present
        if ( isNil( dataStoreEntity ) || isNil( this.attributes ) ) {
            return this.attributes || undefined;
        }
        const dataStoreObject = dataStoreEntity.attributes;
        const currentAttributes = this.attributes;
        
        // Remove duplicates
        const potentialChangedKeys = uniq(
            concat(
                // Get all keys in current attributes
                keys( currentAttributes ),
                // Add to it the keys of the stored object
                keys( dataStoreObject ) ) );
        
        // Omit values that did not changed between now & stored object
        const diffKeys = reject( potentialChangedKeys, ( key: string ) => isEqual( dataStoreEntity.attributes[key], currentAttributes[key] ) );
        
        return transform( diffKeys, ( accumulator: IEntityAttributes, key: string ) => {
            accumulator[key] = currentAttributes[key];
            return accumulator;
        },                {} );
    }
    
    /**
     * Get the data access layer object that matches with the input type.
     *
     * @author Gerkin
     * @param dataSource - String, Adapter or DataAccessLayer to get in the DataAccessLayer form
     */
    protected getDataSource( dataSource?: TDataSource ) {
        return this.ctor.model.getDataSource( dataSource );
    }
    
    /**
     * Serialize an entity
     */
    protected serialize() {
        return Entity.serialize( this.attributes );
    }
    
    /**
     * Deserialize an entity
     */
    protected deserialize() {
        return Entity.deserialize( this.attributes );
    }
    
    /**
     * Cast fields from their raw type to their expected type
     *
     * @author Gerkin
     * @param source - Raw entity properties to cast
     * @returns the casted properties
     */
    private castTypes( source: {[key in keyof TEntity]: any} ): TEntity {
        const attrs = this.model.modelDesc.attributes;
        const foo: Partial<TEntity> | undefined = undefined;
        return mapValues( source, <TKey extends keyof TEntity>( currentVal: any, attrName: keyof TEntity ) => {
            const attrDesc = attrs[attrName as string];
            if ( isObject( attrDesc ) ) {
                switch ( attrDesc.type ) {
                    case 'datetime':
                    {
                        if ( isString( currentVal ) || isInteger( currentVal ) ) {
                            return new Date( currentVal );
                        } else if ( !( currentVal instanceof Date ) ) {
                            logger.error(
                                'Incoherent data type received, expected DateTime castable data, but received: ' +
                                currentVal
                            );
                            return undefined;
                        }
                    }
                    break;
                }
            }
            return currentVal as TEntity[TKey];
        } ) as TEntity;
    }
    
    /**
     * Conditionaly triggers the provided events names with provided arguments if the options requires it.
     *
     * @author Gerkin
     * @param options    - Options of the current entity operation
     * @param eventsArgs - Arguments to transmit by the events
     * @param events     - Event name(s) to trigger
     */
    private async maybeEmit(
        options: Entity.IOptions,
        eventsArgs: any[],
        events: string | string[]
    ): Promise<this> {
        events = castArray( events );
        if ( options.skipEvents ) {
            return this;
        } else {
            await this.emit( events[0], ...eventsArgs );
            if ( events.length > 1 ) {
                return this.maybeEmit( options, eventsArgs, slice( events, 1 ) );
            } else {
                return this;
            }
        }
    }
    
    /**
     * Runs the provided query if the entity is not in {@link EEntityState.ORPHAN} mode.
     *
     * @author Gerkin
     * @param beforeState - The last stable state of the entity (before the current operation)
     * @param dataSource  - The DataAccessLayer to execute the operation in.
     * @param method      - Name of the action to execute
     */
    private execIfOkState<TAdapterEntity extends AAdapterEntity>(
        beforeState: EEntityState,
        dataSource: DataAccessLayer,
        method: 'findOne' | 'deleteOne'
    ): Promise<TAdapterEntity> {
        // Depending on state, we are going to perform a different operation
        if ( EEntityState.ORPHAN === beforeState ) {
            return Promise.reject(
                new Errors.EntityStateError( "Can't fetch an orphan entity." )
            );
        } else {
            this._lastDataSource = dataSource;
            const execMethod: (
                collectionName: string,
                query: object
            ) => Promise<TAdapterEntity> = ( dataSource as any )[method];
            return execMethod.call(
                dataSource,
                this.collectionName( dataSource.name ),
                this.uidQuery( dataSource )
            );
        }
    }
    
    /**
     * Refresh last data source, attributes, state & data source entity
     *
     * @author Gerkin
     * @param dataSource       - Data source to set as last used
     * @param dataSourceEntity - New entity returned by this data source
     */
    private setLastDataSourceEntity(
        dataSource: DataAccessLayer,
        dataSourceEntity: AAdapterEntity | null
    ) {
        // We have used data source, store it
        this._lastDataSource = dataSource;
        // Refresh data source's entity
        this._dataSources.set( dataSource, dataSourceEntity );
        // Set attributes from dataSourceEntity
        if ( dataSourceEntity ) {
            // Set the state
            this._state = EEntityState.SYNC;
            const attrs = this.castTypes( dataSourceEntity.attributes as any );
            this.idHash = cloneDeep( dataSourceEntity.properties.idHash );
            this._attributes = cloneDeep( attrs );
        } else {
            this._attributes = null;
            // If this was our only data source, then go back to orphan state
            if ( isEmpty( without( values( this.model.dataSources ), dataSource ) ) ) {
                this._state = EEntityState.ORPHAN;
            } else {
                this._state = EEntityState.SYNC;
                this.idHash = {};
            }
        }
        return this;
    }
    
    /**
     * Persist the entity in the data source by performing an `insertOne` action
     *
     * @author Gerkin
     * @param dataSource     - Data source to persist entity into
     */
    private async persistCreate( dataSource: DataAccessLayer ) {
        if ( this.attributes ) {
            return dataSource.insertOne(
                this.collectionName( dataSource ),
                this.attributes
            );
        } else {
            return undefined;
        }
    }
    
    /**
     * Persist the entity in the data source by performing an `updateOne` action
     *
     * @author Gerkin
     * @param dataSource - Data source to persist entity into
     * @param options    - Optional options hash for the `update` operation
     */
    private async persistUpdate(
        dataSource: DataAccessLayer,
        options?: Entity.IOptions
    ) {
        const diff = this.getDiff( dataSource );
        return diff
        ? dataSource.updateOne(
            this.collectionName( dataSource ),
            this.uidQuery( dataSource ),
            diff,
            options as any
        )
        : undefined;
    }
}

export namespace Entity {
    export interface IOptions {
        skipEvents?: boolean;
    }
    
    export interface IEntitySpawner<TEntity extends IEntityAttributes> {
        model: Model<TEntity>;
        name: string;
        new ( source?: IEntityAttributes ): Entity<TEntity>;
    }
    export interface IDataSourceMap<TAdapterEntity extends AAdapterEntity>
    extends WeakMap<DataAccessLayer<TAdapterEntity, AAdapter<TAdapterEntity>>, TAdapterEntity | null> {}
    
    /**
     * This factory function generate a new class constructor, prepared for a specific model.
     *
     * @author Gerkin
     * @param   name      - Name of this model.
     * @param   modelDesc - Model configuration that generated the associated `model`.
     * @param   model     - Model that will spawn entities.
     * @returns Entity constructor to use with this model.
     */
    export interface IEntityFactory{
        <TEntity>( name: string, modelDesc: _ModelDescription.IModelDescription, model: Model<TEntity> ): IEntitySpawner<TEntity>;
        Entity: typeof Entity;
    }
}

// =====
// ## Lifecycle Events

// -----
// ### Persist

/**
 * @event Entity#beforePersist
 * @type {String}
 */

/**
 * @event Entity#beforeValidate
 * @type {String}
 */

/**
 * @event Entity#afterValidate
 * @type {String}
 */

/**
 * @event Entity#beforePersistCreate
 * @type {String}
 */

/**
 * @event Entity#beforePersistUpdate
 * @type {String}
 */

/**
 * @event Entity#afterPersistCreate
 * @type {String}
 */

/**
 * @event Entity#afterPersistUpdate
 * @type {String}
 */

/**
 * @event Entity#afterPersist
 * @type {String}
 */

// -----
// ### Find

/**
 * @event Entity#beforeFind
 * @type {String}
 */

/**
 * @event Entity#afterFind
 * @type {String}
 */

// -----
// ### Destroy

/**
 * @event Entity#beforeDestroy
 * @type {String}
 */

/**
 * @event Entity#afterDestroy
 * @type {String}
 */