database.js

let fs = require('fs');
const crypto = require('crypto');
let TableManager = require("./table_manager");
const Errors = require("./error");
let Configuration = require("./configuration");

/**
 * @tutorial Database
 */
class Database {
    /**
     * Creates a new Database object
     * @param {string} directory - The working directory for the database
     * @param {string=} password - An optional password with which to encrypt the data
     * @param {object} [options] - A set of configuration options
     * @param {boolean} [options.write_synchronous=false] Whether to write to disk in a synchronous manner, set to true for important data. Maybe be defaulted to true in future.
     * @constructor 
     */
    constructor(directory, password = 0, options = {}) {
        if (password == {} || password == undefined || password == null || password == "") password = 0;
        this.key = password;
        if (this.key != 0) {
            this.key = crypto.createHash('sha256').update(String(this.key)).digest('base64').substr(0, 32);
        }
        this.options = {
            write_synchronous: false
        };
        Object.assign(this.options, options);
        Configuration.write_synchronous = this.options.write_synchronous;
        this.directory = directory;
        this.configuration_directory = this.directory + "/.conf";
        this.table_configuration_directory = this.directory + "/.conf/tables";
        this.table_directory = this.directory + "/tables";
        this.current_table_name = null;
        this.table_manager = null;
        this.configuration = null;
        this.initialized = false;
        if (fs.existsSync(this.directory)) {
            this.load();
        }
        Database.alpha_num_symbols = Database.alpha_num_symbols.split("").sort((a, b) => {
            return Math.random() - Math.random();
        }).join('');
    }

    /**
     * Cleanup before exit
     * @param {Database} database 
     * @ignore
     */
    exitHandler(database) {
        database.cleanup();
        if (arguments[1] != 0) {
            if (arguments[2] == 'uncaughtException') console.error(arguments[1]);
            process.exit(1);
        } else {
            process.exit(0);
        }
    }

    /**
     * Cleanup and write data
     */
    cleanup() {
        if (this.initialized) {
            if (this.table_manager.awaitingWrite()) {
                this.table_manager.writeAll();
            }
        }
    }

    /**
     * Load configuration and table manager
     * @ignore
     * @throws {DatabaseAlreadyInitializedError}
     */
    load() {
        if (this.initialized) throw new Errors.DatabaseAlreadyInitializedError();
        if (this.key != 0) {
            Configuration.key = this.key;
        } else {
            Configuration.store_key = true;
        }
        this.configuration = new Configuration(this.configuration_directory);
        if (Configuration.key == 0) throw new Errors.InvalidPasswordError();
        this.table_manager = new TableManager(this.table_directory, this.table_configuration_directory);
        //do something when app is closing
        process.on('exit', this.exitHandler.bind(null, this));
        //catches ctrl+c event
        process.on('SIGINT', this.exitHandler.bind(null, this));
        // catches "kill pid" (for example: nodemon restart)
        process.on('SIGUSR1', this.exitHandler.bind(null, this));
        process.on('SIGUSR2', this.exitHandler.bind(null, this));
        //catches uncaught exceptions
        process.on('uncaughtException', this.exitHandler.bind(null, this));
        this.initialized = true;
    }

    /**
     * Initialize a new database
     * @throws {DatabaseAlreadyInitializedError}
     */
    initialize() {
        if (!this.initialized) {
            fs.mkdirSync(this.directory);
            fs.mkdirSync(this.configuration_directory);
            fs.mkdirSync(this.table_configuration_directory);
            fs.mkdirSync(this.table_directory);
            if (this.key == 0) {
                Configuration.store_key = true;
                this.key = Database.rand(20);
                this.key = crypto.createHash('sha256').update(String(this.key)).digest('base64').substr(0, 32);
            }
            fs.writeFileSync(this.configuration_directory + "/conf.json", "{}");
            fs.writeFileSync(this.table_configuration_directory + "/tables.json", "[]");
            this.load();
            this.initialized = true;
        } else {
            throw new Errors.DatabaseAlreadyInitializedError();
        }
    }

    /**
     * Set the working table
     * @param {string} name The name of the table you wish to select
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    selectTable(name) {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        if (this.table_manager.exists(name)) {
            if (this.table_manager.awaitingWrite()) this.table_manager.writeAll();
            this.current_table_name = name;
            this.table_manager.loadTable(name);
        } else
            throw new Errors.NoSuchTableError();
    }

    /**
     * Search specified columns for a specified term
     * @param {object} terms An object with column keys and data values
     * @param {number} [limit=Infinity] Number of search results to return
     * @param {boolean} [or=false] - Search by ( term AND term ), when true ( term OR term )
     * @returns {object[]} Array of matches
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    searchColumns(terms, limit, or) {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        return this.table_manager.searchColumns(this.current_table_name, terms, limit, or);
    }

    /**
     * Search a specified column for a specified term
     * @param {string} name The name of the column you wish to search
     * @param {string} term The term for which you wish to search
     * @returns {object} The matching row
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    searchColumn(name, term) {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        return this.table_manager.searchColumn(this.current_table_name, name, term);
    }

    /**
     * Insert row containing specified values
     * @param {*} arguments Insert a row from an object where {column:data}
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    insertRow() {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        return this.table_manager.insertRow(this.current_table_name, arguments);
    }

    /**
     * Insert a row from an object with key/value pairs
     * @param {any} object An object containing {column:value}
     */
    insertRowObject(object) {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        return this.table_manager.insertRowObject(this.current_table_name, object);
    }

    /**
     * Create a new empty column
     * @param {string} name The name of the column you wish to create
     * @param {string} [fill=""] Content to place in each row of the new column
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    createColumn(name, fill) {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        return this.table_manager.createColumn(this.current_table_name, name, fill);
    }

    /**
     * Delete row containing specified values, returns deleted rows
     * @param {object} where An object defining search criteria {column:term}
     * @param {number} [limit=Infinity] Limit the number of deleted rows
     * @returns {object[]} Deleted rows
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    deleteRows(where, limit) {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        return this.table_manager.deleteRows(this.current_table_name, where, limit);
    }

    /**
     * Updates rows with new data where current data matches
     * @param {object} newData {column:name,data:value}
     * @param {object} where {column:term}
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    updateRows(newData, where) {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        this.table_manager.updateRows(this.current_table_name, newData, where);
    }

    /**
     * List columns from selected table
     * @returns {string[]} Array of table columns
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    tableColumns() {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        return this.table_manager.getColumns(this.current_table_name);
    }

    /**
     * Get all rows from selected table
     * @param {string=} [name] Specify a table from which to get all rows
     * @returns {object[]} Fetched rows
     * @throws {DatabaseNotInitializedError}
     * @throws {NoSuchTableError}
     */
    getRows(name = this.current_table_name) {
        if (!this.initialized) throw new Errors.DatabaseNotInitializedError();
        if (this.table_manager.exists(name))
            return this.table_manager.getRows(name);
        else
            throw new Errors.NoSuchTableError();
    }

    /**
     * Create a new table with specified columns
     * @param {string} name The name of the new table
     * @param {string[]} columns An array of column names
     * @throws {DatabaseNotInitializedError}
     */
    createTable(name, columns) {
        if (!this.initialized) {
            throw new Errors.DatabaseNotInitializedError();
        }
        if (this.table_manager.awaitingWrite()) this.table_manager.writeAll();
        this.table_manager.createTable(name, columns);
        this.current_table_name = name;
    }

    /**
     * @ignore
     */
    static alpha_num_symbols = "{}[];':\",./<>?-=_+$#@!%^&*ABCDEFGHIJKLMONPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
    /**
     * @ignore
     */
    static alpha_num = "ABCDEFGHIJKLMONPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";

    /**
     * Statically initialize a new database
     * @param {string} name Name of the new database
     * @param {string} [password] Password with which to encrypt the new database
     */
    static init(name, password = 0) {
        let db = new Database(name, password);
        if (!db.initialized) db.initialize();
    }

    /**
     * Get a random string
     * @param {number} [length=8]
     * @returns {string}
     */
    static rand(length = 8) {
        let parts = Database.alpha_num_symbols.split("");

        let out = "";
        for (let i = 0; i < length; i++) {
            let i = Math.floor(Math.random() * parts.length);
            out += parts[i];
        }
        return out;
    }

    /**
     * Get a random string with only alpha-numeric values
     * @param {number} [length=8]
     * @returns {string}
     */
    static rand_safe(length = 8) {
        let parts = Database.alpha_num.split("");

        let out = "";
        for (let i = 0; i < length; i++) {
            let i = Math.floor(Math.random() * parts.length);
            out += parts[i];
        }
        return out;
    }
    /**
     * Test a string for randomness
     * @param {string} test
     * @returns {number} 
     */
    static test_random(test) {
        let parts = test.split("");
        let n = test.length;
        let H = 0;
        let unique = [];
        for (let part of parts) {
            if (unique.indexOf(part) == -1) {
                unique.push(part);
                let c = 0;
                parts.forEach((p) => {
                    if (p == part) c++;
                });
                let p = c / n;
                H += p * Math.log2(p);
            }
        }
        return -H;
    }
}


module.exports = Database;