@AppKu/StashKu

@AppKu/StashKu

v1.0.46

source

modeling/model-utility.js

///<reference path="./modeling.d.js" />
import StringUtility from '../utilities/string-utility.js';
import RESTError from '../rest-error.js';
import Sort from '../sort.js';
import Filter from '../filter.js';

/**
 * A utility class for working with StashKu-compatible model objects.
 */
class ModelUtility {

    /**
     * Returns `true` if the model type object provided appears to be a class or constructor function, otherwise a
     * `false` value is returned.
     * @param {Modeling.AnyModelType} modelType - The model "class" or constructor function to be checked.
     * @returns {Boolean}
     */
    static isValidType(modelType) {
        return !!(modelType && modelType.constructor && modelType.prototype);
    }

    /**
     * Returns a map of modeled properties (keys) and their definitions (values). The value is a property definition
     * that details how the modeled property maps to the underlying storage, including the actual storage `target`.
     * 
     * The modeled property (key) is not always the same as the "target" property - it is the property that is
     * represented through the model itself, which may have a definition pointing to a "target" property.
     * @param {Modeling.AnyModelType} modelType - The model "class" or constructor function.
     * @returns {Map.<String, Modeling.PropertyDefinition>}
     */
    static map(modelType) {
        let propMap = new Map();
        if (ModelUtility.isValidType(modelType)) {
            while (modelType) {
                let descriptors = Object.getOwnPropertyDescriptors(modelType);
                //get static "get" property names that are readable and writable or plain values.
                for (let prop in descriptors) {
                    if (propMap.has(prop) === false && /^([$_].+|prototype|name)$/.test(prop) === false) {
                        let input = modelType[prop];
                        let inputType = typeof input;
                        if (inputType === 'string' || inputType === 'object') {
                            let desc = descriptors[prop];
                            if (desc.enumerable || desc.get) {
                                let propDefinition = null;
                                if (inputType === 'string') {
                                    propDefinition = { target: input };
                                } else if (inputType === 'object') {
                                    propDefinition = input;
                                    if (!propDefinition.target) {
                                        //ensure a name is set on the definition object
                                        propDefinition.target = prop;
                                    }
                                }
                                if (propDefinition) {
                                    propMap.set(prop, propDefinition);
                                }
                            }
                        }
                    }
                }
                let proto = Object.getPrototypeOf(modelType);
                if (proto && proto.name && proto.prototype !== undefined) {
                    modelType = proto;
                } else {
                    modelType = null;
                }
            }
        }
        return propMap;
    }

    /**
     * Takes a model type and generates an object with the same properties used to define the model - all
     * property definitions and the `$stashku` configuration. If the `$stashku` property is not found it is generated
     * with a `resource` property and derived value.
     * @throws 500 `RESTError` if the "modelType" argument is missing or not a supported StashKu model type object.
     * @param {Modeling.AnyModelType} modelType - The model "class" or constructor function.
     * @returns {*}
     */
    static schema(modelType) {
        if (ModelUtility.isValidType(modelType) === false) {
            throw new RESTError(500, 'The "modelType" argument is required and must be a supported StashKu model type object.');
        }
        let schema = {};
        let mapping = ModelUtility.map(modelType);
        for (let [p, pDef] of mapping) {
            schema[p] = pDef;
        }
        if (typeof modelType.$stashku === 'object') {
            schema.$stashku = modelType.$stashku;
        } else {
            schema.$stashku = { resource: ModelUtility.resource(modelType) };
        }
        return schema;
    }

    /**
     * Returns the StashKu resource name for the given model, if specified. Optionally checks for a specific action
     * name configuration and uses it if specified. If the resource is a function, it is called with and the return
     * value is returned as the resource name.
     * 
     * If a `resource` static property is not available on the model's `$stashku` property, the model constructor
     * name is used instead.
     * @param {Modeling.AnyModelType} modelType - The model "class" or constructor function.
     * @param {String} [method] - The name of the method that should override the default value if it is explicitly
     * specified. If it is not found, but an `all` or `'*'` property is found, it is used instead.
     * @returns {String}
     */
    static resource(modelType, method) {
        if (ModelUtility.isValidType(modelType)) {
            method = method?.toLowerCase();
            if (modelType.$stashku && modelType.$stashku.resource) {
                let resource = modelType.$stashku.resource;
                let resType = typeof resource;
                if (resType === 'string') {
                    return resource;
                } else if (resType === 'object') {
                    let hasAction = (typeof resource[method] !== 'undefined');
                    if (hasAction === false) {
                        //action not present, so lookf for a default fallback.
                        if (typeof resource['all'] !== 'undefined') {
                            method = 'all';
                        } else if (typeof resource['*'] !== 'undefined') {
                            method = '*';
                        } else {
                            return null;
                        }
                    }
                    //looks like a custom object with the action.
                    resType = typeof resource[method];
                    if (resType === 'string') {
                        return resource[method];
                    }
                }
                //fallback to null if resource is present but no valid match
            } else {
                //no resource property, so default to the class/constructor name pluralized
                return StringUtility.plural(modelType.name) || null;
            }
        }
        return null;
    }

    /**
     * Returns the "primary key" property names as specified on a model configuration type.
     * @param {Modeling.AnyModelType} modelType - The model "class" or constructor function.
     * @returns {Array.<String>}
     */
    static pk(modelType) {
        let primaryKeys = [];
        if (ModelUtility.isValidType(modelType)) {
            let mapping = ModelUtility.map(modelType);
            for (let [_, v] of mapping) {
                if (v && v.pk) {
                    primaryKeys.push(v.target);
                }
            }
        }
        return primaryKeys;
    }

    /**
     * @template T
     * @typedef {new(...args: Array) | new(...args: Array) => T} Constructor
     **/

    /**
     * Use a given model type to to convert the given storage object(s) into a model (instance) of the specified model 
     * type. If a property is not mapped in the model, the property (and value) will *not* be assigned to the model.
     * 
     * This method is called by StashKu after a response is returned by the underlying engine (and a model is being
     * used) but before it is handed back to the caller.
     * @throws 500 `RESTError` if the "modelType" argument is missing or not a supported StashKu model type object.
     * @throws 500 `RESTError` if the "method" argument is missing or not a string.
     * @template T
     * @param {T} modelType - The model "class" or constructor function.
     * @param {String} method - The method of the request being processed, typically: "get", "post", "put", "patch",
     * "delete", or "options".
     * @param  {...any} objects - The raw objects to be transmuted into a model.
     * @yields {Constructor.<T>}
     * @generator
     */
    static * model(modelType, method, ...objects) {
        if (ModelUtility.isValidType(modelType) === false) {
            throw new RESTError(500, 'The "modelType" argument is required and must be a supported StashKu model type object.');
        } else if (!method || typeof method !== 'string') {
            throw new RESTError(500, 'The "method" argument is required and must be a string.');
        }
        let mapping = ModelUtility.map(modelType);
        for (let obj of objects) {
            if (obj) {
                if (obj instanceof modelType) {
                    yield obj; //nothing to do, already modelType
                } else {
                    let model = new modelType();
                    for (let [k, v] of mapping) {
                        if (typeof obj[v.target] !== 'undefined' || typeof obj[k] !== 'undefined') {
                            model[k] = obj[v.target];
                            if (typeof obj[k] !== 'undefined') {
                                model[k] = obj[k];
                            }
                            //handle type conversion for objects that may have come from JSON
                            let valueType = typeof model[k];
                            if (v.type === 'Date' && (valueType === 'string' || valueType === 'number')) {
                                model[k] = new Date(model[k]);
                            } else if (v.type === 'Boolean') {
                                if (valueType === 'string') {
                                    model[k] = /^[tTyY1]/.test(model[k]);
                                } else if (valueType === 'number') {
                                    model[k] = (model[k] !== 0);
                                }
                            } else if (v.type === 'Number' && valueType === 'string') {
                                model[k] = parseFloat(model[k]);
                            }
                        } else if (typeof v.default === 'undefined') {
                            //not given by input object, and no default defined- nuke property from model instance.
                            delete model[k];
                        }
                        if (v.transform) { //run a transform if present.
                            model[k] = v.transform.call(modelType, k, model[k], obj, method, 'model');
                        }
                        if (v.omit && typeof model[k] !== 'undefined') { //omit the property if warranted
                            let omitted = (v.omit === true);
                            if (typeof v.omit === 'function') {
                                omitted = v.omit.call(modelType, k, model[k], obj, method, 'model');
                            } else if (v.omit === null && model[k] === null) {
                                omitted = true;
                            } else if (typeof v.omit === 'object') {
                                if (v.omit[method] === true) {
                                    omitted = true;
                                } else if (typeof v.omit[method] === 'function') {
                                    omitted = v.omit[method].call(modelType, k, model[k], obj, method, 'model');
                                } else if (v.omit[method] === null && model[k] === null) {
                                    omitted = true;
                                } else if (v.omit.all === true && v.omit[method] !== false) {
                                    omitted = true;
                                }
                            }
                            if (omitted === true) {
                                delete model[k];
                            }
                        }
                    }
                    yield model;
                }
            } else {
                yield null;
            }
        }
    }

    /**
     * Use a specified model type to revert models back to a regular (untyped) object.
     * 
     * Certain StashKu requests will call this automatically before the request is sent to the underlying engine.    
     * A PATCH request will attempt to unmodel it's template.    
     * A PUT & POST request will attempt to unmodel it's objects.
     * @throws 500 `RESTError` if the "modelType" argument is missing or not a supported StashKu model type object.
     * @throws 500 `RESTError` if the "method" argument is missing or not a string.
     * @template T
     * @param {T} modelType - The model "class" or constructor function.
     * @param {String} method - The method of the request being processed, typically: "get", "post", "put", "patch",
     * "delete", or "options".
     * @param  {...Constructor.<T>} models - The modeled objects to be transformed back into a storage object.
     * @yields {Constructor.<T>}
     * @generator
     */
    static * unmodel(modelType, method, ...models) {
        if (ModelUtility.isValidType(modelType) === false) {
            throw new RESTError(500, 'The "modelType" argument is required and must be a supported StashKu model type object.');
        } else if (!method || typeof method !== 'string') {
            throw new RESTError(500, 'The "method" argument is required and must be a string.');
        }
        let mapping = ModelUtility.map(modelType);
        for (let model of models) {
            if (model) {
                let record = {};
                for (let [k, v] of mapping) {
                    if (typeof model[k] !== 'undefined') { //only set a value if the property value is defined.
                        record[v.target] = model[k];
                    }
                    if (v && v.transform) { //run a transform if present.
                        record[v.target] = v.transform.call(modelType, v.target, record[v.target], model, method, 'unmodel');
                    }
                    if (v && this.unmodelPropertyOmit(modelType, k, v, method, model)) {
                        delete record[v.target];
                    }
                }
                yield record;
            } else {
                yield null;
            }
        }
    }

    /**
     * Determines whether a property should be omitted when unmodeling.
     * @param {T} modelType - The model type used for unmodelling.
     * @param {String} propKey - The model's property key.
     * @param {Modeling.PropertyDefinition} propertyDef - The model's property definition under the specified key.
     * @param {String} method - The method being used.
     * @param {Constructor.<T>} model - The model being evaluated.
     * @returns {Boolean}
     */
    static unmodelPropertyOmit(modelType, propKey, propertyDef, method, model) {
        let v = propertyDef;
        if (v && v.omit) { //omit the property if warranted
            let omitted = (v.omit === true);
            if (typeof v.omit === 'function') {
                omitted = v.omit.call(modelType, propKey, model[propKey], model, method, 'unmodel');
            } else if (v.omit === null && model[propKey] === null) {
                omitted = true;
            } else if (typeof v.omit === 'object') {
                if (v.omit[method] === true) {
                    omitted = true;
                } else if (typeof v.omit[method] === 'function') {
                    omitted = v.omit[method].call(modelType, propKey, model[propKey], model, method, 'unmodel');
                } else if (v.omit[method] === null && model[propKey] === null) {
                    omitted = true;
                } else if (v.omit.all === true && v.omit[method] !== false) {
                    omitted = true;
                }
            }
            return omitted;
        }
        return false;
    }

    /**
     * Translates one or more modelled property names by changing them into their target property names.
     * 
     * If a property name cannot be translated (it is not found on the model), it is left as-is.
     * @throws 500 `RESTError` if the "modelType" argument is missing or not a supported StashKu model type object.
     * @template T
     * @param {T} modelType - The model "class" or constructor function.
     * @param  {...String} propertyNames - The property names to be unmodelled.
     * @returns {Array.<String>} Returns an array of the unmodelled property names.
     */
    static unmodelProperties(modelType, ...propertyNames) {
        let translatedProps = [];
        let map = ModelUtility.map(modelType);
        if (propertyNames && propertyNames.length) {
            for (let pn of propertyNames) {
                if (pn && map.has(pn)) {
                    translatedProps.push(map.get(pn).target);
                } else {
                    translatedProps.push(pn);
                }
            }
        }
        return translatedProps;
    }

    /**
     * Translates one or more `Sort` instance's modeled property names by changing them into their target
     * property names.
     * 
     * If a `Sort` property name cannot be translated (it is not found on the model), it is left as-is.
     * @throws 500 `RESTError` if the "modelType" argument is missing or not a supported StashKu model type object.
     * @template T
     * @param {T} modelType - The model "class" or constructor function.
     * @param  {...Sort} sorts - The `Sort` instances to be modeled.
     */
    static unmodelSorts(modelType, ...sorts) {
        let map = ModelUtility.map(modelType);
        if (sorts && sorts.length) {
            for (let s of sorts) {
                if (s && s.property) {
                    if (map.has(s.property)) {
                        s.property = map.get(s.property).target;
                    }
                }
            }
        }
    }

    /**
     * Translates one or more `Filter` instances and the underlying conditions by changing modeled property names
     * into their target property names.
     * 
     * If a `Filter` instance condition cannot be translated, it is left as-is.
     * @throws 500 `RESTError` if the "modelType" argument is missing or not a supported StashKu model type object.
     * @template T
     * @param {T} modelType - The model "class" or constructor function.
     * @param  {...Filter} filters - The `Sort` instances to be modeled.
     */
    static unmodelFilters(modelType, ...filters) {
        let map = ModelUtility.map(modelType);
        if (filters && filters.length) {
            for (let filter of filters) {
                if (filter instanceof Filter) {
                    filter.walk(f => {
                        if (f.property) { //is a logical condition
                            if (map.has(f.property)) {
                                f.property = map.get(f.property).target;
                            }
                        }
                    });
                }
            }
        }
    }

}

export default ModelUtility;