daemonraco/dfdb

View on GitHub
src/includes/index.dfdb.ts

Summary

Maintainability
A
25 mins
Test Coverage
/**
 * @file index.dfdb.ts
 * @author Alejandro D. Simi
 */

import { Promise } from 'es6-promise';
import * as JSZip from 'jszip';
import * as jsonpath from 'jsonpath-plus';

import { BasicDictionary, DBDocument } from './basic-types.dfdb';
import { Collection } from './collection/collection.dfdb';
import { ConditionsList } from './condition.dfdb';
import { Condition } from './condition.dfdb';
import { Connection, ConnectionSavingQueueResult } from './connection/connection.dfdb';
import { IDelayedResource, IResource } from './resource.i.dfdb';
import { IErrors } from './errors.i.dfdb';
import { Rejection } from './rejection.dfdb';
import { RejectionCodes } from './rejection-codes.dfdb';
import { SubLogicErrors } from './errors.sl.dfdb';

/**
 * This class represents a document's field index associated to a collection.
 *
 * @class Index
 */
export class Index implements IErrors, IResource, IDelayedResource {
    //
    // Protected properties.
    protected _collection: Collection = null;
    protected _connected: boolean = false;
    protected _connection: Connection = null;
    protected _data: BasicDictionary = {};
    protected _field: string = null;
    protected _resourcePath: string = null;
    protected _skipSave: boolean = false;
    protected _subLogicErrors: SubLogicErrors<Index> = null;
    //
    // Constructor.
    /**
     * @constructor
     * @param {Collection} collection Collection to which this index is
     * associated.
     * @param {string} field Name of the field to be index.
     * @param {Connection} connection Collection in which it's stored.
     */
    constructor(collection: Collection, field: string, connection: Connection) {
        //
        // Shortcuts.
        this._field = field;
        this._collection = collection;
        this._connection = connection;
        //
        // Main path.
        this._resourcePath = `${this._collection.name()}/${this._field}.idx`;
        //
        // Sub-logics.
        this._subLogicErrors = new SubLogicErrors<Index>(this);
    }
    //
    // Public methods.
    /**
     * This method takes a document and adds into this index if it contains the
     * index field.
     *
     * @method addDocument
     * @param {DBDocument} doc Document to be indexed.
     * @returns {Promise<void>} Return a promise that gets resolved when the
     * operation finishes.
     */
    public addDocument(doc: DBDocument): Promise<void> {
        //
        // This anonymous function takes a value for a index entry and adds the
        // given document ID to it.
        const addValue = (value: any) => {
            //
            // Value should be indexed in lower case for case-insensitive search.
            value = `${value}`.toLowerCase();
            //
            // Is it a new index value?
            if (typeof this._data[value] === 'undefined') {
                this._data[value] = [doc._id];
            } else {
                //
                // Is it already indexed for this value?
                if (this._data[value].indexOf(doc._id) < 0) {
                    this._data[value].push(doc._id);
                    this._data[value].sort();
                }
            }
        }
        //
        // Restarting error messages.
        this._subLogicErrors.resetError();
        //
        // Building promise to return.
        return new Promise<void>((resolve: () => void, reject: (err: Rejection) => void) => {
            //
            // Parsing object for the right field.
            const jsonPathValues = jsonpath({ json: doc, path: `\$.${this._field}` });
            //
            // Is it connected and is it the required field present?
            if (this._connected && typeof jsonPathValues[0] !== 'undefined') {
                //
                // If it's an array, each element should be indexes separately.
                if (Array.isArray(jsonPathValues[0])) {
                    //
                    // Indexing each value.
                    jsonPathValues[0].forEach((value: any) => {
                        if (typeof value !== 'object') {
                            addValue(value);
                        }
                    });
                    //
                    // Saving data on file.
                    this.save()
                        .then(resolve)
                        .catch(reject);
                } else if (typeof jsonPathValues[0] === 'object' && jsonPathValues[0] !== null) {
                    //
                    // At this point, if the value is an object, it cannot be
                    // indexed and should be treated as an error.
                    this._subLogicErrors.setLastRejection(new Rejection(RejectionCodes.NotIndexableValue));
                    reject(this._subLogicErrors.lastRejection());
                } else {
                    //
                    // Indexing field's value.
                    addValue(jsonPathValues[0]);
                    //
                    // Saving data on file.
                    this.save()
                        .then(resolve)
                        .catch(reject);
                }
            } else if (!this._connected) {
                this._subLogicErrors.setLastRejection(new Rejection(RejectionCodes.IndexNotConnected));
                reject(this._subLogicErrors.lastRejection());
            } else {
                //
                // IF the required field isn't present, the given document is
                // ignored.
                resolve();
            }
        });
    }
    /**
     * Creating an index object doesn't mean it is connected to physical
     * information, this method does that.
     * It connects and loads information from the physical storage in zip file.
     *
     * @method connect
     * @returns {Promise<void>} Return a promise that gets resolved when the
     * operation finishes.
     */
    public connect(): Promise<void> {
        //
        // Restarting error messages.
        this._subLogicErrors.resetError();
        //
        // Building promise to return.
        return new Promise<void>((resolve: () => void, reject: (err: Rejection) => void) => {
            //
            // Is it connected?
            if (!this._connected) {
                //
                // Setting default data.
                this._data = {};
                //
                // Retrieving data from zip.
                this._connection.loadFile(this._resourcePath)
                    .then((results: ConnectionSavingQueueResult) => {
                        //
                        // Did it return any data?
                        if (results.error) {
                            //
                            // Setting as connected.
                            this._connected = true;
                            //
                            // Forcing to save.
                            this._connected = true;
                            //
                            // Physically saving data.
                            this.save()
                                .then(resolve)
                                .catch(reject);
                        } else {
                            //
                            // Sanitization.
                            results.data = results.data ? results.data : '';
                            //
                            // Parsing data.
                            results.data.split('\n')
                                .filter(line => line != '')
                                .forEach(line => {
                                    const pieces: string[] = line.split('|');
                                    const key: string = pieces.shift();
                                    this._data[key] = pieces;
                                });
                            //
                            // Setting as connected.
                            this._connected = true;

                            resolve();
                        }
                    });
            } else {
                //
                // If it's not connected, nothing is done.
                resolve();
            }
        });
    }
    /**
     * This method saves current data and then closes this index.
     *
     * @method close
     * @returns {Promise<void>} Return a promise that gets resolved when the
     * operation finishes.
     */
    public close(): Promise<void> {
        //
        // Restarting error messages.
        this._subLogicErrors.resetError();
        //
        // Building promise to return.
        return new Promise<void>((resolve: () => void, reject: (err: Rejection) => void) => {
            //
            // Is it connected?
            if (this._connected) {
                //
                // Saving data on file.
                this.save()
                    .then(() => {
                        //
                        // Freeing memory
                        this._data = {};
                        //
                        // Disconnecting this index.
                        this._connected = false;

                        resolve();
                    })
                    .catch(reject);
            } else {
                //
                // If it's not connected, nothing is done.
                resolve();
            }
        });
    }
    /**
     * This method removes this index from its connection and erases all
     * traces of it.
     *
     * @method drop
     * @returns {Promise<void>} Return a promise that gets resolved when the
     * operation finishes.
     */
    public drop(): Promise<void> {
        //
        // Restarting error messages.
        this._subLogicErrors.resetError();
        //
        // Building promise to return.
        return new Promise<void>((resolve: () => void, reject: (err: Rejection) => void) => {
            //
            // Is it connected?
            if (this._connected) {
                this._connection.removeFile(this._resourcePath)
                    .then(() => {
                        // no need to ask collection to forget this index because it's the
                        // collection's responsibillity to invoke this method and then
                        // forget it.

                        this._connected = false;
                        resolve();
                    })
                    .catch(reject);
            } else {
                //
                // If it's not connected, nothing is done.
                resolve();
            }
        });
    }
    /**
     * Provides a way to know if there was an error in the last operation.
     *
     * @method error
     * @returns {boolean} Returns TRUE when there was an error.
     */
    public error(): boolean {
        //
        // Forwarding to sub-logic.
        return this._subLogicErrors.error();
    }
    /**
     * This method searches for document IDs associated to a certain value or
     * piece of value.
     *
     * @method find
     * @param {ConditionsList} conditions List of conditions to check.
     * @returns {Promise<string[]>} Returns a promise that gets resolve when the
     * search completes. In the promise it returns the list of found document IDs.
     */
    public find(conditions: ConditionsList): Promise<string[]> {
        //
        // Restarting error messages.
        this._subLogicErrors.resetError();
        //
        // Building promise to return.
        return new Promise<string[]>((resolve: (res: string[]) => void, reject: (err: Rejection) => void) => {
            //
            // Selecting only those conditions that apply to this index.
            const conditionsToUse: ConditionsList = [];
            conditions.forEach((cond: Condition) => {
                if (this._field === cond.field()) {
                    conditionsToUse.push(cond);
                }
            });
            //
            // Initializing a list of findings.
            let findings: string[] = [];
            //
            // Checking every index value becuase the one being search may be
            // equal or contained in it.
            Object.keys(this._data).forEach((indexValue: string) => {
                //
                // Check all conditions against current value.
                let allValid = true;
                conditionsToUse.forEach((cond: Condition) => {
                    allValid = allValid && cond.validate(indexValue);
                });
                //
                // Does current condition applies?
                if (allValid) {
                    //
                    // Adding IDs.
                    findings = findings.concat(this._data[indexValue]);
                }
            });
            //
            // Removing duplicates.
            findings = Array.from(new Set(findings));
            //
            // Finishing and returning found IDs.
            resolve(findings);
        });
    }
    /**
     * Provides access to the error message registed by the last operation.
     *
     * @method lastError
     * @returns {string|null} Returns an error message.
     */
    public lastError(): string | null {
        //
        // Forwarding to sub-logic.
        return this._subLogicErrors.lastError();
    }
    /**
     * Provides access to the rejection registed by the last operation.
     *
     * @method lastRejection
     * @returns {Rejection} Returns an rejection object.
     */
    public lastRejection(): Rejection {
        //
        // Forwarding to sub-logic.
        return this._subLogicErrors.lastRejection();
    }
    /**
     * This method unindexes a document from this index.
     *
     * @method removeDocument
     * @param {string} id ID of the documento to remove.
     * @returns {Promise<void>} Return a promise that gets resolved when the
     * operation finishes.
     */
    public removeDocument(id: string): Promise<void> {
        //
        // Restarting error messages.
        this._subLogicErrors.resetError();
        //
        // Building promise to return.
        return new Promise<void>((resolve: () => void, reject: (err: Rejection) => void) => {
            //
            // Checking each indexed value because it may point to the document
            // to remove.
            Object.keys(this._data).forEach(key => {
                //
                // Does it have it?
                const idx = this._data[key].indexOf(id);
                if (idx > -1) {
                    this._data[key].splice(idx, 1);
                }
                //
                // If current value is empty, it should get forgotten.
                if (this._data[key].length === 0) {
                    delete this._data[key];
                }
            });
            //
            // Saving changes.
            this.save()
                .then(resolve)
                .catch(reject);
        });
    }
    /**
     * When the physical file saving is trigger by a later action, this method
     * avoids next file save attempt for this sequence.
     *
     * @method skipSave
     */
    public skipSave(): void {
        this._skipSave = true;
    }
    /**
     * This methods provides a proper value for string auto-castings.
     *
     * @method toString
     * @returns {string} Returns a simple string identifying this index.
     */
    public toString = (): string => {
        return `index:${this._field}`;
    }
    /**
     * This method removes all data of this index.
     *
     * @method truncate
     * @returns {Promise<void>} Return a promise that gets resolved when the
     * operation finishes.
     */
    public truncate(): Promise<void> {
        //
        // Restarting error messages.
        this._subLogicErrors.resetError();
        //
        // Building promise to return.
        return new Promise<void>((resolve: () => void, reject: (err: Rejection) => void) => {
            //
            // Is it connected?
            if (this._connected) {
                //
                // Freeing memory.
                this._data = {};
                //
                // Saving changes.
                this.save()
                    .then(resolve)
                    .catch(reject);
            } else {
                //
                // If it's not connected, nothing is done.
                resolve();
            }
        });
    }
    //
    // Protected methods.
    /**
     * This method triggers the physical saving of this index file.
     *
     * @protected
     * @method save
     * @returns {Promise<void>} Return a promise that gets resolved when the
     * operation finishes.
     */
    protected save(): Promise<void> {
        //
        // Building promise to return.
        return new Promise<void>((resolve: () => void, reject: (err: Rejection) => void) => {
            let data: any = [];
            //
            // Converting data into a list of strings that can be physically
            // stored.
            Object.keys(this._data).forEach(key => {
                data.push(`${key}|${this._data[key].join('|')}`);
            });
            //
            // Saving file.
            this._connection.updateFile(this._resourcePath, data.join('\n'), this._skipSave)
                .then((results: ConnectionSavingQueueResult) => {
                    //
                    // Skipped once.
                    this._skipSave = false;

                    resolve();
                })
                .catch(reject);
        });
    }
}