mekanika/skematic

View on GitHub
src/format.js

Summary

Maintainability
D
1 day
Test Coverage

/**
 * Format methods are **not directly available** on the API, but are used by the
 * `Skematic.format()` function to modify provided data.
 *
 * @namespace Format
 */

/**
 * Import utilities
 * @ignore
 */

const is = require('./is')

/**
 * Import Skematic tools
 * @ignore
 */

const setDefault = require('./default')
const strip = require('./strip')
const {canCompute, computeValue} = require('./compute')
const idMap = require('./idmap')

// Shorthand accessor
const compute = computeValue

/**
 * Formats a data object according to model rules.
 *
 * Order of application is significant: 1. Defaults, 2. Generate, 3. Transform.
 *
 * The options hash may contain:
 * > Legend: **name** _{Type}_ `default`:
 *
 * - **strict** _{Boolean}_ - `false` Strips any fields not declared on model
 * - **sparse** _{Boolean}_ - `false` only process keys on data (not full model)
 * - **defaults** _{Boolean}_ - `true` set default values
 * - **generate** _{Boolean|String}_ - `true` Compute values (pass `"once"` to run compute-once fields)
 * - **transform** _{Boolean}_ - `true` apply transform functions
 * - **strip** _{Array}_ `undefined` list of field values to strip from `data`
 * - **mapIdFrom** _{String}_ `undefined` maps a primarykey field from the field name provided
 *
 * @example
 * const Model = {name: {default: 'Player 1'}, created: {generate: Date.now}}
 *
 * format(Model, {mydata: 'demo'})
 * // -> {name: 'Player 1', created: 1467139008992, mydata: 'demo'}
 *
 * format(Model, {name: 'Mo', mydata: 'demo'}, {strict: true})
 * // -> {name: 'Mo', created: 1467139049234}
 *
 * @param {Model} model The model to format to
 * @param {Mixed} data The data to format
 * @param {Object} opts Options hash
 *
 * @returns {Object} A fresh copy of formatted data
 *
 * @memberof Skematic
 * @alias format
 */

function format (model, data, opts = {}) {
  if (data == null) return createFrom(model)

  // Apply bulk formatters
  let res = _dive(model, data, opts)

  // Map the idField if provided
  if (opts.mapIdFrom) idMap(model, res, opts.mapIdFrom)

  return res
}

/**
 * Returns an object built on ALL values present in the model, set to defaults
 * and having been run through `.format()` with default flags.
 *
 * @param {Model} model To initialise object
 * @param {Mixed} nullValue
 *
 * @returns {Object}
 * @private
 */

function createFrom (model, nullValue) {
  let o = {}

  if (!model) return o

  for (let k in model) {
    if (!model.hasOwnProperty(k)) continue
    o[k] = setDefault(nullValue, model[k])
    // Ensure undefined type:'array' is set to [] (unless overridden)
    if (model[k].type === 'array' && o[k] === nullValue) o[k] = []

    // Setup the models for any defined sub-model on OBJECT types
    if (model[k].model) {
      // Only apply to objects or assume 'object' if type not defined
      if (!model[k].type || model[k].type === 'object') {
        o[k] = createFrom(model[k].model)
      }
    }
  }

  // Now format the new object
  o = format(model, o, {once: true})

  return o
}

/**
 * Checks that `source` permissions (what you HAVE) meet `target` permissions
 * (what you NEED). Returns `true` if so, `false` if not.
 *
 * @param {String|String[]} source A scope string or Array of scopes (HAVE)
 * @param {String|String[]} target A scope string or Array of scopes (NEED)
 *
 * @returns {Boolean} Are target permissions present in source permissions
 * @private
 */

function isIn (source, target) {
  // No target permissions? Always passes
  if (!target || !target.length) return true

  // Convert to arrays so we don't accidentally do PARTIAL string matches
  // using the .indexOf method (which would match 'thisperm' with 'hisp')
  if (!Array.isArray(source)) source = [source]
  if (!Array.isArray(target)) target = [target]

  let present = false
  source.forEach(val => {
    if (target.indexOf(val) > -1) present = true
  })

  return present
}

/**
 * Internal method to apply the modifier functions (default, generate etc)
 *
 * @param {Object} data The parent (root) data to pass to generate for 'this' ref
 * @param {Model} ss The model to apply (default: {})
 * @param {Object} opts The options hash
 * @param {Mixed} value The (likely SCALAR) value to be formatted
 *
 * @returns Formatted value
 * @private
 */

function _makeValue (data = {}, ss = {}, opts, val) {
  // Set defaults
  if (opts.defaults !== false) {
    if (!is.undefined(ss.default)) val = setDefault(val, ss)
  }

  // Run generators
  if (opts.generate !== false) {
    // Sets up the "runOnce" flag if `opts.compute = 'once'`
    var runOnce = (opts.generate !== false) && opts.once

    var args = [ss, {once: runOnce}]
    if (arguments.length > 3) args.push(val)

    if (canCompute.apply(null, args)) {
      // Handle generators flagged as 'once'
      if (ss.generate.once) {
        if (runOnce) val = compute(ss, {once: true}, val, data)

      // All other generators run every time
      } else val = compute(ss, {}, val, data)
    }
  }

  // Apply transforms only if transforms are allowed...
  if (opts.transform !== false) {
    // ...and a transform exists on the model
    // ...and the value exists
    if (ss.transform && val !== undefined && val !== null) {
      // Force an error if .transform is not a function
      // ie. Developer error. Declare your models correctly pls.
      if (!is.function(ss.transform)) {
        throw new Error('Expect .transform value to be a function()')
      }
      // Bind the data as `this` so object values are available for transform
      val = ss.transform.bind(data)(val)
    }
  }

  return val
}

/**
 * Internal method to recurse through a model and apply _makeValue. Handles
 * scalars, arrays and object data.
 *
 * @param {Model} skm The model to apply
 * @param {Mixed} payload The (likely OBJECT) data to be formatted
 * @param {Object} opts The options hash
 * @param {Object} parentData The original data payload (used for ref)
 *
 * @returns A fresh copy of formatted data (no mutation)
 * @private
 */

function _dive (skm, payload, opts, parentData) {
  // On the odd chance we reach here with no `skm` model defined
  if (!skm) return payload

  // Placeholder for formatted data
  let out

  let data = payload
  if (!parentData) parentData = data

  // -- OBJECT
  // Process data as an object
  if (is.object(data)) {
    // Create a copy of the object
    data = {...data}

    // Strip keys not declared on schea if in 'strict' mode
    if (opts.strict) {
      const modelKeys = Object.keys(skm)
      Object.keys(data).forEach(function (k) {
        if (modelKeys.indexOf(k) < 0) delete data[k]
      })
    }

    let step = skm
    // Switch to parsing only provided keys on sparse data
    if (opts.sparse) step = data

    for (let key in step) {
      // Shorthand the model model to use for this value
      let model = skm[key]
      // Some field names won't have a model defined. Skip these.
      if (!model) continue

      // Remove data fields that are flagged as locked
      if (!opts.unlock && skm[key] && skm[key].lock) {
        delete data[key]
      }

      // Show/hide scope permissions projection
      // (`unscope:true` prevents using 'scopes' permissions)
      if (!opts.unscope && model.show && !isIn(opts.scopes, model.show)) {
        delete data[key]
        // Skips any further processing
        continue
      }

      // Handle value of field being an Array
      if (is.array(data[key]) || model.type === 'array') {
        out = _dive(model, data[key], opts, parentData)
      } else {
        let args = [parentData, model, opts]
        if (Object.keys(data).indexOf(key) > -1) args.push(data[key])
        out = _makeValue.apply(null, args)

        // Special case handle objects with sub-model
        // Apply the sub-model to the object output
        if (is.object(out) && model.model) {
          out = _dive(model.model, out, opts, parentData)
        }
      }

      // Only apply new value if changed (ensures 'undefined' values are
      // not automatically added to ABSENT keys on the data object)
      if (out !== data[key]) data[key] = out
    }
  } else if (is.array(data) && skm.model) {
    // Create a copy of the array
    data = data.slice()
    // ARRAY (with sub-model)
    // Process data as an array IF there is a sub-model to format against
    // Note: we use the sub-data AS the parentData in this case because
    // the sub-model evaluates its `this` against the sub-data NOT parent
    for (let i = 0; i < data.length; i++) {
      // Recurse through objects
      if (is.object(data[i])) {
        data[i] = _dive(skm.model, data[i], opts, data[i])
      } else {
        // Or simply "makeValue" for everything else
        out = _makeValue(parentData, skm.model, opts, data[i])
        if (data[i] !== out) data[i] = out
      }
    }
    // Apply the value transformation to the ROOT array data otherwise
    // we skip doing things like transforms on this data
    data = _makeValue(parentData, skm, opts, data)
  } else {
    // NORMAL VALUE
    // Process as scalar value
    out = _makeValue(parentData, skm, opts, data)
    if (out !== data) data = out
  }

  // Remove any matching field values
  if (opts.strip) strip(opts.strip, data)

  return data
}

/**
 * Export module
 * @ignore
 */

module.exports = format
module.exports.isIn = isIn