orm/src/schema/Schema.js

/**
 * @module flitter-orm/src/schema/Schema
 */

const DateType = require('./types/Date')
const StringType = require('./types/String')
const NumberType = require('./types/Number')
const BooleanType = require('./types/Boolean')
const ObjectIdType = require('./types/ObjectId')
const ArrayType = require('./types/Array')
const ObjectType = require('./types/Object')
const ModelType = require('./types/Model')
const Type = require('./Type')
const { ObjectId } = require('mongodb')

/**
 * Database schema base class. Provides logic for parsing
 * and recursively building a schema from a definition, and
 * coercing an object, recursively, to comply with the schema
 * definition.
 */
class Schema {

    /**
     * Schema types supported by this Schema class.
     * Provides a mapping from reference name => Type class.
     * @type {object}
     */
    static types = {
        Date: DateType,
        String: StringType,
        Number: NumberType,
        Boolean: BooleanType,
        ObjectId: ObjectIdType,
        Array: ArrayType,
        Object: ObjectType,
        Model: ModelType,
    }

    /**
     * Instantiates the schema and builds the schema object from the provided definition.
     * @param {object} definition - the schema definition
     */
    constructor(definition = {}) {

        /**
         * The original schema definition.
         * @type {object}
         */
        this.definition = definition

        /**
         * The built schema object.
         * @type {object}
         */
        this.schema = this.build_schema(definition)
    }

    /**
     * Cast the provided object to the specified schema. Particularly,
     * this function is used externally to cast objects to this schema,
     * recursively.
     *
     * After this cast, the resulting object is safe to persist, at least
     * for what this library guarantees.
     *
     * @param {object} object - object to cast
     * @param {object} [level = false] - the current level of the schema object (usually leave this blank)
     * @returns {object} - the casted object
     */
    cast_to_schema(object, level = false) {
        if ( !level ) level = this.schema

        // Special handling for arrays, since their children property is _default
        if ( Array.isArray(object) && '_default' in level ) {
            const cast_array = []
            for ( const item of object ) {
                cast_array.push(this.cast_to_schema(item, level._default))
            }

            return cast_array
        }

        // Handle single item casts (e.g. cast 'some string' to a string schema)
        if ( typeof object !== 'object' || (level.type && (level.type === ObjectType || level.type === ModelType) ) ) {
            // Handle primitives and object types
            let value
            if ( typeof object !== 'undefined' ) value = level.type.cast(object)
            else {
                const default_value = level.default_to()
                if ( typeof default_value !== 'undefined' ) value = level.type.cast(default_value)
            }

            if ( typeof value !== 'undefined' ) {
                if (level.children) {
                    // Parse the nested type
                    return this.cast_to_schema(value, level.children)
                } else {
                    // Store the primitive type
                    return value
                }
            }
        }

        // Handle primitive object casts
        else if (typeof level === 'object' && level.type && level.default_to && level.prop && level.def ) {
            let value
            if ( object ) value = level.type.cast(object)
            else {
                const default_value = level.default_to()
                if (typeof default_value !== 'undefined') value = schemata.type.cast(default_value)
            }
            return value
        }

        // Handle object-type casts
        else {
            const cast_object = {}
            for (const prop in level) {
                const schemata = level[prop]
                if (!level.hasOwnProperty(prop)) continue

                // Handle primitives and object types
                let value
                if (prop in object) value = schemata.type.cast(object[prop])
                else {
                    const default_value = schemata.default_to()
                    if (typeof default_value !== 'undefined') value = schemata.type.cast(default_value)
                }

                if (typeof value !== 'undefined') {
                    if (schemata.children) {
                        // Parse the nested type
                        cast_object[prop] = this.cast_to_schema(value, schemata.children)
                    } else {
                        // Store the primitive type
                        cast_object[prop] = value
                    }
                }
            }

            return cast_object
        }
    }

    /**
     * Recursively build a schema object from the provided definition.
     * @param {object} defs - the schema definition
     * @returns {object} - the schema object
     */
    build_schema(defs) {
        const level = {}
        for ( const prop in defs ) {
            const def = defs[prop]
            // Process primitives
            if ( typeof def === 'function' && def.prototype instanceof require('../model/Model') ) {
                const type = this.parse_type(def)
                const default_to = this.parse_default()
                level[prop] = { type, default_to, prop, def, children: this.build_schema(def.schema) }
            }
            else if ( !Array.isArray(def) && typeof def !== 'object' ) {
                const type = this.parse_type(def)
                const default_to = this.parse_default()
                level[prop] = { type, default_to, prop, def }
            }
            // Process primitive objects
            else if ( typeof def === 'object' && def.type && !Array.isArray(def.type) && typeof def.type !== 'object' ) {
                const type = this.parse_type(def.type)
                const default_to = this.parse_default(def.default)
                level[prop] = { type, default_to, prop, def }
            }
            // Process array types
            else if ( Array.isArray(def) ) {
                const type = this.parse_type(def)
                const default_to = this.parse_default()
                level[prop] = { type, default_to, prop, def, children: this.build_schema({_default: def[0]})}
            }
            // Process nested objects
            else {
                const type = this.parse_type(def)
                const default_to = this.parse_default()
                level[prop] = { type, default_to, prop, def, children: this.build_schema(def) }
            }
        }
        return level
    }

    /**
     * Parse the schema type from the specified type identifier.
     * @param {Date|String|Number|Boolean|ObjectId|Array|Object} type
     * @returns {module:flitter-orm/src/schema/Type~Type}
     */
    parse_type(type) {
        const types = this.constructor.types
        if ( [Date, 'date', 'Date'].includes(type) ) return types.Date
        if ( [String, 'string', 'String'].includes(type) ) return types.String
        if ( [Number, 'number', 'Number', 'int', 'Int', 'double', 'Double'].includes(type) ) return types.Number
        if ( [Boolean, 'boolean', 'Boolean'].includes(type) ) return types.Boolean
        if ( [ObjectId, 'ObjectId', 'id'].includes(type) ) return types.ObjectId
        if ( [Array, 'array', 'Array'].includes(type) || Array.isArray(type) ) return types.Array
        if ( [Object, 'object', 'Object'].includes(type) || typeof type === 'object' ) return types.Object
        if ( typeof type === 'function' && type.prototype instanceof require('../model/Model') ) return types.Model
        throw new Error('Unknown or invalid field type encountered!')
    }

    /**
     * Create a function that returns the specified default value.
     * If default_value is a function, it will be called. Otherwise,
     * this will return a function that evaluates to default_value.
     *
     * @param {function|*} default_value
     * @returns {function}
     */
    parse_default(default_value) {
        if ( typeof default_value === 'function' ) return default_value
        else return () => default_value
    }
}

module.exports = exports = Schema