core/persistent_object.js

const { Context } = require('./context');
const { JsonStore } = require('./json_store');
const { Util } = require('./util');

/**
 * PersistentObject is the base class for all objects that we want
 * to be able to persist to a JsonStore (a plain-text JSON file in
 * which we can save, retrieve, update, and delete individual objects.)
 *
 * For example, if you pass 'AppSetting' as the type param, the object
 * you create will be stored in the AppSetting.json file in the application's
 * data directory. (See the link to JsonStore below.)
 *
 * If the underlying data store for your class does not yet exist, it will
 * be created the first time you call save(), find(), or any other
 * function that accesses the underlying datastore.
 *
 * @see {@link JsonStore} for more about the JsonStore object.
 * @see {@link Context#dataStores} for info about how DART keeps track of
 * different datastores.
 *
 */
class PersistentObject {
    /**
     * Creates a new PersistentObject.
     *
     * @param {object} opts - Object containing properties to set.
     *
     * @param {string} opts.id - A UUID in hex-string format. This is
     * the object's unique identifier.
     *
     * @param {boolean} opts.userCanDelete - Indicates whether user is
     * allowed to delete this record.
     *
     * This constructor is meant to be called by the constructors in subclasses,
     * which pass their class name as the type param.
     *
     */
    constructor(opts = {}) {
        /**
          * id is this object's unique identifier, and the best handle
          * to use when retrieving it from storage. This is a version 4
          * UUID in hex string format. It is set by the constructor when
          * you create a new PersistentObject. You should not change it.
          *
          * @type {string}
          */
        this.id = opts.id || Util.uuid4();
        /**
          * userCanDelete indicates whether or not the user can delete
          * this object from storage. The defaults to false, but you may
          * want to set it to true for select items that are required for
          * your plugin to work. For example, your plugin depends on the
          * presence of a pre-installed BagIt profile or an AppSetting
          * called 'Account Number' (or whatever), you can set this property
          * to false and the user will not be able to delete the property.
          *
          * @type {string}
          * @default true
          */
        this.userCanDelete = opts.userCanDelete === false ? false : true;
        /**
          * This is a list of required properties. The DART UI won't let
          * you save an instance of this object unless the required
          * attributes have a value.
          *
          * @type {Array<string>}
          */
        this.required = opts.required || [];
        if (!this.required.includes('id')) {
            this.required.push('id');
        }
        /**
          * The errors property contains information about why this object
          * is not valid. This property will be empty when an object is
          * created, and is populated by the validate() method. If there
          * are validation errors, the keys in the error object will be
          * the names of invalid properties. The values will be error messages
          * describing why the property is invalid. If validate() determines
          * that the object is valid, errors will be empty.
          *
          * @type {Object<string, string>}
          */
        this.errors = {};
    }

    /**
     * validate returns true if this object is valid, false if not. If the
     * object is not valid, this populates the errors property with info
     * describing what is not valid. Classes that derive from PersistentObject
     * should have their own custom implementation of this method.
     *
     * @returns {boolean}
     */
    validate() {
        this.errors = {};
        for (let property of this.required) {
            if (Util.isEmpty(this[property])) {
                this.errors[property] = Context.y18n.__('%s cannot be empty.', Util.camelToTitle(property));
            }
        }
        return Object.keys(this.errors).length == 0;
    }

    /**
     * Save this object to persistent storage. Returns the object after saving.
     *
     * @returns {PersistentObject}
     */
    save() {
        Context.db(this.constructor.name).set(this.id, this);
        return this;
    }


    /**
     * Delete this object from persistent storage. Returns a copy of the
     * deleted object.
     *
     * @returns {Object}
     */
    delete() {
        if (!this.userCanDelete) {
            throw new Error("User cannot delete this object.");
        }
        Context.db(this.constructor.name).delete(this.id);
        return this;
    }


    /**
     * This returns the value of propertyName as read from the
     * property itself or from the process's environment. This will
     * return undefined if it tries to read an undefined environment
     * variable.
     *
     * This is used primarily by the subclasses {@link StorageService}
     * and {@link RemoteRepository}, both of which use credentials that a
     * user may want to store in the environment.
     *
     * For most values, you'll simply want to access the property itself.
     *
     * @example
     *
     * storageService.login = "user@example.com";
     * storageService.getValue("login");  // returns "user@example.com"
     *
     * storageService.login = "env:USER";
     * storageService.getValue("login");  // returns the value of process.env.USER
     *
     * @param {string} propertyName - The name of the property whose value
     * you want to get.
     *
     * @returns {string}
     *
     */
    getValue(propertyName) {
        let value = this[propertyName];
        return Util.looksLikeEnvSetting(value) ? Util.getEnvSetting(value) : value;
    }


    /**
     * find finds the object with the specified id in the datastore
     * and returns it. Returns undefined if the object is not in the datastore.
     * Some more complex objects may have to override this method to correctly
     * reconstruct the object from the hash representation.
     *
     * @param {string} id - The id (UUID) of the object you want to find.
     *
     * @returns {PersistentObject}
     */
    static find(id) {
        let db = Context.db(this.name); // in static method, this is class name
        let data = db.get(id);
        if (data) {
            return Object.assign(new this(), data);
        }
        return undefined;
    }

    /**
     * mergeDefaultOpts sets missing option values to their default values.
     * This function is meant for internal use.
     *
     * @param {Object} opts - A potentially null hash of options.
     *
     * @returns {Object}
     */
    static mergeDefaultOpts(opts) {
        // Don't overwrite opts. Caller may want to reuse it.
        var mergedOpts = Object.assign({}, opts);
        mergedOpts.limit = mergedOpts.limit || 50;
        mergedOpts.offset = mergedOpts.offset || 0;
        mergedOpts.orderBy = mergedOpts.orderBy || null;
        mergedOpts.sortDirection = mergedOpts.sortDirection || 'asc';
        return mergedOpts;
    }

    /**
     * sort sorts all of the items in the Conf datastore (JSON text file)
     * on the specified property in either 'asc' (ascending) or 'desc' (descending)
     * order. This does not affect the order of the records in the file, but
     * it returns a sorted list of objects.
     *
     * @param {string} property - The property to sort on.
     * @param {string} direction - Sort direction: 'asc' or 'desc'
     *
     * @returns {Object[]}
     */
    static sort(property, direction) {
        let db = Context.db(this.name);  // in static method, this is class name
        let list = [];
        for (var key in db.store) {
            list.push(db.store[key]);
        }
        if (property) {
            let sortFunction = Util.getSortFunction(property, direction);
            list.sort(sortFunction);
        }
        return list;
    }

    /**
     * findMatching returns an array of items matching the specified criteria.
     *
     * @example
     * // Get all objects where obj.name === 'Homer'
     * let results = persistentObject.findMatching('name', 'Homer');
     *
     * @example
     * // Get the first ten objects where obj.name === 'Homer', sorted
     * // by createdAt, with newest first
     * let opts = {limit: 10, offset: 0, orderBy: 'createdAt', sortDir: 'desc'};
     * let results = persistentObject.findMatching('name', 'Homer', opts);
     *
     * @param {string} property - The name of the property to match.
     * @param {string} value - The value of the property to match.
     * @param {Object} opts - Optional additional params.
     * @param {number} opts.limit - Limit to this many results.
     * @param {number} opts.offset - Start results from this offset.
     * @param {string} opts.orderBy - Sort the list on this property.
     * @param {string} opts.sortDirection - Sort the list 'asc' (ascending) or 'desc'. Default is asc.
     *
     * @returns {Object[]}
     */
    static findMatching(property, value, opts) {
        let db = Context.db(this.name); // in static method, this is class name
        let filterFunction = (obj) => { return obj[property] == value };
        return this.list(filterFunction, opts);
    }

    /**
     * firstMatching returns the first item matching the specified criteria,
     * or null if no item matches.
     *
     * @example
     * // Get the first object where obj.name == 'Homer'
     * let obj = persistentObject.findMatching('name', 'Homer');
     *
     * @example
     * // Get the newest object where obj.name == 'Homer'
     * let obj = persistentObject.findMatching('name', 'Homer',
     * {orderBy: 'createdAt', sortDirection: 'desc'});
     *
     * @example
     * // Get the second newest object where obj.name == 'Homer'
     * let obj = persistentObject.first('name', 'Homer',
     * {orderBy: 'createdAt', sortDirection: 'desc', offset: 1});
     *
     * @example
     * // Get the oldest object where obj.name == 'Homer'
     * let obj = persistentObject.findMatching('name', 'Homer',
     * {orderBy: 'createdAt', sortDirection: 'asc'});
     *
     * @param {string} property - The name of the property to match.
     * @param {string} value - The value of the property to match.
     * @param {Object} opts - Optional additional params.
     * @param {string} opts.orderBy - Sort the list on this property.
     * @param {string} opts.sortDirection - Sort the list 'asc' (ascending)
     * or 'desc'. Default is asc.
     *
     * @returns {Object}
     */
    static firstMatching(property, value, opts) {
        let db = Context.db(this.name); // in static method, this is class name
        let filterFunction = (obj) => { return obj[property] == value };
        let mergedOpts = this.mergeDefaultOpts(opts);
        mergedOpts.limit = 1;
        let matches = this.list(filterFunction, mergedOpts);
        return matches[0] || null;
    }

    /**
     * list returns an array of items matched by the filter function.
     *
     * @example
     * function nameAndAge(obj) {
     *    return obj.name === 'Homer' && obj.age > 30;
     * }
     * let results = persistentObject.list(nameAndAge);
     *
     * @example
     * // Get the first ten objects that match the filter, sorted
     * // by createdAt, with newest first
     * let opts = {limit: 10, offset: 0, orderBy: 'createdAt', sortDir: 'desc'};
     * let results = persistentObject.findMatching(nameAndAge, opts);
     *
     * @param {filterFunction} filterFunction - The name of the property to match.
     * @param {Object} opts - Optional additional params.
     * @param {number} opts.limit - Limit to this many results.
     * @param {number} opts.offset - Start results from this offset.
     * @param {string} opts.orderBy - Sort the list on this property.
     * @param {string} opts.sortDirection - Sort the list 'asc' (ascending)
     * or 'desc'. Default is asc.
     *
     * @returns {Object[]}
     */
    static list(filterFunction, opts) {
        opts = this.mergeDefaultOpts(opts);
        if (!filterFunction) {
            filterFunction = () => { return true };
        }
        let db = Context.db(this.name); // in static method, this is class name
        let sortedList = this.sort(opts.orderBy, opts.sortDirection);
        // List of matched objects to return
        let matches = [];
        // Count of objects matched so far. We may skip some, due to opts.offset.
        let matched = 0;
        for (let obj of sortedList) {
            if (typeof filterFunction === 'function' && filterFunction(obj)) {
                matched++;
                if (matched > opts.offset && (opts.limit < 1 || matches.length < opts.limit)) {
                    matches.push(obj);
                }
                if (opts.limit > 0 && matches.length == opts.limit) {
                    break;
                }
            }
        }
        return matches;
    }

    /**
     * first returns the first item matching that passes the filterFunction.
     * You can combine orderBy, sortDirection, and offset to get the second,
     * third, etc. match for the given criteria, but note that this function
     * only returns a single item at most (or null if there are no matches).
     *
     * @example
     * // Define a filter function
     * function nameAndAge(obj) {
     *    return obj.name === 'Homer' && obj.age > 30;
     * }
     *
     * // Get the first matching object
     * let obj = persistentObject.first(nameAndAge);
     *
     * // Get the newest matching object
     * let obj = persistentObject.first(nameAndAge,
     * {orderBy: 'createdAt', sortDirection: 'desc'});
     *
     * // Get the second newest matching object
     * let obj = persistentObject.first(nameAndAge,
     * {orderBy: 'createdAt', sortDirection: 'desc', offset: 1});
     *
     * // Get the oldest matching object
     * let obj = persistentObject.first(nameAndAge,
     * {orderBy: 'createdAt', sortDirection: 'asc'});
     *
     * @param {filterFunction} filterFunction - The name of the property
     * to match.
     * @param {Object} opts - Optional additional params.
     * @param {string} opts.orderBy - Sort the list on this property.
     * @param {string} opts.sortDirection - Sort the list 'asc' (ascending)
     * or 'desc'. Default is asc.
     * @param {number} opts.offset - Skip this many items before choosing
     * a result.
     *
     * @returns {Object}
     */
    static first(filterFunction, opts = {}) {
        opts.limit = 1;
        let matches = this.list(filterFunction, opts);
        return matches[0] || null;
    }
}

/**
 * PersistentObjectFilter is a simple function for filtering object
 * lists. It should take a single object as a parameter and return
 * either true or false to indicate whether the object passes the filter.
 *
 * @example
 * function nameAndTitleFilter(obj) {
 *    return obj.name.startsWith('A') && (obj.title === 'developer' || obj.title === 'manager');
 * }
 *
 * @callback filterFunction
 * @param {Object}
 * @returns {boolean}
 */

module.exports.PersistentObject = PersistentObject;