decorators/ModelProperties.js

/** @namespace decorators */

import {
  GQLBase, MODEL_KEY, META_KEY, GETTERS, SETTERS, PROPS, AUTO_PROPS
} from '../GQLBase'
import { isArray, extendsFrom } from 'ne-types'
import { inspect } from 'util'
import { GraphQLEnumType, parse } from 'graphql'
import { SyntaxTree } from '../SyntaxTree'

/**
 * For each of the decorators, Getters, Setters, and Properties, we take a
 * list of property names used to create the appropriate accessor types. In
 * some cases, however, the instance of GQLBase's data model may have a
 * different name. Finally if the return type for the getter should be wrapped
 * in a another GQLBase class type, we will need a way to specify those things
 * too.
 *
 * The `extractBits()` takes a single argument value from the decorator as it
 * parses them and converts it into an object, properly sorted, into values that
 * allow the above described behavior.
 *
 * Examples:
 *
 * ```
 * // Create a class with a name and age property that map directly to the
 * // underlying data model
 * @Getters('name', 'age')
 * class MyType extends GQLBase {...}
 *
 * // Create a class with a name property that maps to a different property
 * // name in the underlying data model
 * @Getters(['name', '_fake_name'])
 * class MyMockType extends GQLBase {...}
 *
 * // Create a class with an employee property that returns an Employee
 * @Getters(['employee', Employee])
 * class MyRoleType extends GQLBase {...}
 *
 * // Finally create a class with an employe property that returns an Employee
 * // with data under a different name in the underlying data model.
 * @Getters(['employee', '_worker', Employee])
 * class MyMockRoleType extends GQLBase {...}
 * ```
 *
 * @memberof decorators
 * @method ⌾⠀extractBits
 * @since 2.5
 *
 * @param {String|Array<String|Function>} property name of a property, or list
 * of property names and a Class.
 * @return {Object} an object with the following format ```
 * {
 *   fieldName: name of root instance property to create
 *   modelName: name of its associated internal model property
 *   typeClass: an optional class to wrap around the results in a getter
 * }
 * ```
 */
function extractBits(property) {
  let array = isArray(property) ? property : [property, property, null]
  let reply;

  if (!property) {
    let error = new Error(
      'Invalid property. Given\n  %o',
      inspect(property, {depth: 2})
    );

    return {
      fieldName: 'anErrorOccurred',
      modelName: 'anErrorOccurred',
      typeClass: null,
      getterMaker: function() { return () => error },
      setterMaker: function() { return (v) => undefined }
    }
  }

  //
  if (array.length === 3) {
    reply = {
      fieldName: array[0],
      modelName: array[1],
      typeClass: typeof array[2] === 'function' && array[2] || null
    }
  }

  //
  else if (array.length === 2) {
    reply = {
      fieldName: array[0],
      modelName: typeof array[1] === 'string'
        ? array[1]
        : array[0],
      typeClass: typeof array[1] === 'function' && array[1] || null
    }
  }

  //
  else {
    reply = {
      fieldName: array[0],
      modelName: array[0],
      typeClass: array[0]
    }
  }

  reply.getterMaker = function() {
    let { modelName, fieldName, typeClass } = reply;

    return function() {
      const thisClass = this.constructor
      const model = this[MODEL_KEY] || null
      let val

      if (!extendsFrom(thisClass, GQLBase)) {
        console.error(`${thisClass.name} is not derived from GQLBase`);
        return undefined
      }

      if (!thisClass.SCHEMA) {
        throw new Error(`
        All GQLBase extended classes should have a defined SCHEMA. Please
        manually define a static get SCHEMA() in your class or use the
        @Schema() decorator to do so.
        `)
      }

      if (typeClass) {
        // If the value of the model is already the type of class we expect
        // we do not need to do any processing and we can just grab it and
        // go.
        if (model[modelName] && extendsFrom(model[modelName], typeClass)) {
          val = model[modelName]
        }

        // Otherwise we need to return an instance of the determined typeClass
        // and pass that back instead; as requested.
        else {
          const results = SyntaxTree.findField(
            parse(this.constructor.SCHEMA),
            this.constructor.name,
            modelName
          )
          const { meta } = results || { meta: null };

          let args = [model[modelName], this.requestData];

          if (meta && !meta.nullable && !model) {
            throw new Error(`
              Using @Getters or @Properties decorators with a null or
              undefined model when the schema states that this field
              cannot be null.

              Type      : ${typeClass.name}
              Field (AST data)
                name    : ${meta.name}
                type    : ${meta.type}
                nullable: ${meta.nullable}
              [getter]  : ${fieldName}
              [maps to] : ${modelName}
              [model  ] : ${model}
            `)
          }

          // If the following is true, it means that despite allowing nulls
          // for this field in the schema, we do have a valid model and should
          // proceed.
          if (model) {
            if (extractBits.DIRECT_TYPES.includes(typeClass.name)) {
              val = typeClass(...args)
            }
            else {
              val = new typeClass(...args)
            }

            if (typeClass.GQL_TYPE === GraphQLEnumType) { return val.value; }
          }
        }
      }
      else {
        val = model[modelName];
      }

      if (val === 'undefined' || val === undefined) {
        val = null;
      }

      return val;
    }
  }

  reply.setterMaker = function() {
    let { modelName } = reply;
    return function (value) {
      this[MODEL_KEY][modelName] = value;
    }
  }

  return reply;
}

/**
 * An array of proper class names that are used to test for cases where the
 * proper usage of instantiating an instance should preclude the use of `new`
 *
 * @memberof decorators
 * @type {Array<String>}
 */
extractBits.DIRECT_TYPES = [
  String.name
];

/**
 * A small suite of functions a getter that allows easy manipulation of the
 * the DIRECT_TYPES workaround needed for some types of complex class
 * wrapping allowed by the @Getters and @Properties decorators. Namely the
 * ability to do something like @Getters('name', String) which would wrap the
 * contents of whatever is in the objects model in a String call.
 *
 * Direct types are those that need to be called without `new` in order for the
 * desired behavior to present itself.
 *
 * @memberof decorators
 * @type {Object}
 * @since 2.7.0
 */
export const DirectTypeManager = {
  /**
   * A getter that retrieves the array of direct types
   *
   * @method DirectTypeManager#types
   * @member {Array<String>} types
   *
   * @return {Array<String>} an array of class name strings.
   */
  get types(): Array<String> {
    return extractBits.DIRECT_TYPES
  },

  /**
   * Appends the supplied class name to the list of registered direct types. If
   * a class or function is passed, rather than a String,
   *
   * @method DirectTypeManager#types
   *
   * @param {Function|string|RegExp} className the name of the class to append.
   * Typically it is best to pass the name property of the class in question
   * such as `RegExp.name` or `MyClass.name`.
   */
  add(className: string | RegExp | Function): void {
    if (typeof className === 'function') {
      className = className.name
    }

    extractBits.DIRECT_TYPES.push(className);
  },

  /**
   * Foricbly empties the contents of the extractBits.DIRECT_TYPES array. This
   * is not recommended as it can have unintended consequences. It is
   * recommended to use `reset` instead
   *
   * @method DirectTypeManager#clear
   *
   * @return {Array<string>} an array of class name Strings that were removed
   * when cleared.
   */
  clear(): Array<string> {
    return extractBits.DIRECT_TYPES.splice(0, extractBits.DIRECT_TYPES.length)
  },

  /**
   * The recommended way to reset the DIRECT_TYPES list. This removes all
   * changed values, returns the removed bits, and adds back in the defaults.
   *
   * @method DirectTypeManager#reset
   *
   * @return {Array<string>} an array of class name Strings that were removed
   * during the reset process.
   */
  reset(): Array<string> {
    return extractBits.DIRECT_TYPES.splice(
      0,
      extractBits.DIRECT_TYPES.length,
      String.name
    )
  }
}

/**
 * This decorator allows you to add a Class method to the DirectTypeManager
 * as a function that should not be invoked with the `new` keyword. For all
 * intents and purposes the function should be declared `static`.
 *
 * @method DirectTypeAdd
 * @param {Function} target [description]
 * @constructor
 */
export function DirectTypeAdd(target) {
  DirectTypeManager.add(target);
  return target;
}

/**
 * When applying multiple property getters and setters, knowing some info
 * about what was applied elsewhere can be important. "Tags" can be applied
 * that store the fieldName and descriptor applied via one of these decorators.
 *
 * Multiple "tags" are supported to allow for detecting the difference between
 * decorators applied by the developer using lattice and something auto
 * generated such as auto-props.
 *
 * @param  {GQLBase} Class an instance of GQLBase to apply the tags tp
 * @param  {Array<string|Symbol>} addTags an array of Symbols or strings to be
 * wrapped in Symbols that will be used as tag keys
 * @param  {string} fieldName the name of the field being decorated
 * @param  {Object} descriptor the JavaScript descriptor object to associate
 * with this tagged field.
 */
export function applyTags(
  Class:GQLBase,
  addTags: Array<string|Symbol>,
  fieldName: string,
  descriptor: Object
) {
  let tags = (Array.isArray(addTags) && addTags || [])
    .map(tag => typeof tag === 'string' && Symbol.for(tag) || tag)
    .filter(tag => typeof tag === 'symbol')

  tags.forEach(tag => {
    Class[META_KEY][tag] = Class[META_KEY][tag] || {}
    Class[META_KEY][tag][fieldName] = descriptor
  })
}

/**
 * When working with `GQLBase` instances that expose properties
 * that have a 1:1 mapping to their own model property of the
 * same name, adding the getters manually can be annoying. This
 * takes an indeterminate amount of strings representing the
 * properties for which getters should be injected.
 *
 * @function 🏷⠀Getters
 * @memberof! decorators
 *
 * @param {Array<String|Array<String>>} propertyNames if the model has 'name'
 * and 'age' as properties, then passing those two strings will result
 * in getters that surface those properties as GraphQL fields.
 * @return {Function} a class decorator method.s
 */
export function Getters(
  ...propertyNames: Array<String|Array<String|Function>>
): Function {
  return function(target: mixed, addTags: Array<string|Symbol> = []): mixed {
    for (let property of propertyNames) {
      let { fieldName, getterMaker } = extractBits(property);
      let desc = Object.getOwnPropertyDescriptor(target.prototype, fieldName)
      let hasImpl = desc && (desc.get || typeof desc.value === 'function')
      let tags = [GETTERS].concat(Array.isArray(addTags) && addTags || [])

      if (!hasImpl) {
        let descriptor = {
          get: getterMaker()
        }

        applyTags(target, tags, fieldName, descriptor)
        Object.defineProperty(target.prototype, fieldName, descriptor);
      }
      else {
        console.warn(
          `Skipping getter for ${target.name}.${fieldName}; already exists`
        )
      }
    }

    return target;
  }
}

/**
 * When working with `GQLBase` instances that expose properties
 * that have a 1:1 mapping to their own model property of the
 * same name, adding the setters manually can be annoying. This
 * takes an indeterminate amount of strings representing the
 * properties for which setters should be injected.
 *
 * @function 🏷⠀Setters
 * @memberof! decorators
 * @since 2.1.0
 *
 * @param {Array<String|Array<String>>} propertyNames if the model has
 * 'name' and 'age' as properties, then passing those two strings will
 * result in setters that surface those properties as GraphQL fields.
 * @return {Function} a class decorator method
 */
export function Setters(
  ...propertyNames: Array<String|Array<String|Function>>
): Function {
  return function(target: mixed, addTags: Array<String|Symbol> = []): mixed {
    for (let property of propertyNames) {
      let { fieldName, setterMaker } = extractBits(property);
      let desc = Object.getOwnPropertyDescriptor(target.prototype, fieldName)
      let hasImpl = desc && (desc.get || typeof desc.value === 'function')
      let tags = [SETTERS].concat(Array.isArray(addTags) && addTags || [])

      if (!hasImpl) {
        let descriptor = {
          set: setterMaker()
        }

        applyTags(target, tags, fieldName, descriptor)
        Object.defineProperty(target.prototype, fieldName, descriptor);
      }
      else {
        console.warn(
          `Skipping setter for ${target.name}.${fieldName}; already exists`
        )
      }
    }

    return target;
  }
}

/**
 * When working with `GQLBase` instances that expose properties
 * that have a 1:1 mapping to their own model property of the
 * same name, adding the getters manually can be annoying. This
 * takes an indeterminate amount of strings representing the
 * properties for which getters should be injected.
 *
 * This method creates both getters and setters
 *
 * @function 🏷⠀Properties
 * @memberof! decorators
 * @since 2.1.0
 *
 * @param {Array<String|Array<String>>} propertyNames if the model has 'name'
 * and 'age' as properties, then passing those two strings will result
 * in getters and setters that surface those properties as GraphQL fields.
 * @return {Function} a class decorator method
 */
export function Properties(
  ...propertyNames: Array<String|Array<String|Function>>
): Function {
  return function(target: mixed, addTags: Array<String|Symbol> = []): mixed {
    for (let property of propertyNames) {
      let {fieldName, getterMaker, setterMaker } = extractBits(property);
      let desc = Object.getOwnPropertyDescriptor(target.prototype, fieldName)
      let hasImpl = desc && (desc.get || typeof desc.value === 'function')
      let tags = [PROPS].concat(Array.isArray(addTags) && addTags || [])

      if (!hasImpl) {
        let descriptor = {
          set: setterMaker(),
          get: getterMaker()
        }

        applyTags(target, tags, fieldName, descriptor)
        Object.defineProperty(target.prototype, fieldName, descriptor);
      }
      else {
        console.warn(
          `Skipping properties for ${target.name}.${fieldName}; already exists`
        )
      }
    }

    return target;
  }
}

export default Properties;