Source: model.js

const _ = require('lodash');
const clone = require('clone');

import _privateKey from './privatekey'

import { typecast, getFunctionName } from './utils';
import { ModelArray } from './modelarray'

const _reservedFields = ['super'];

// Add field to schema and initializes getter and setter for the field.
function addToSchema(index, properties) {
  this.schema.add(index, properties);

  defineGetter.call(this[_privateKey]._getset, index, this.schema.descriptor[index]);
  defineSetter.call(this[_privateKey]._getset, index, this.schema.descriptor[index]);
}

// Defines getter for specific field.
function defineGetter(index, properties) {
  // If the field type is an alias, we retrieve the value through the alias's index.
  let indexOrAliasIndex = properties.type === 'alias' ? properties.index : index;

  this.__defineGetter__(index, () => {
    try {
      return getter.call(this, this[_privateKey]._obj[indexOrAliasIndex], properties);
    } catch (error) {
      // This typically happens when the default value isn't valid -- log error.
      this[_privateKey]._errors.push(error);
    }
  });
}

// Defines setter for specific field.
function defineSetter(index, properties) {
  this.__defineSetter__(index, (value) => {
    // Don't proceed if readOnly is true.
    if (properties.readOnly) {
      return;
    }

    // call custom validate if specified
    if (properties.validate) {
      if (!properties.validate.call(this, value)) {
        return;
      }
    }

    try {
      // this[_privateKey]._this[index] is used instead of this[_privateKey]._obj[index] to route through the public interface.
      writeValue.call(this[_privateKey]._this, typecast.call(this, value, this[_privateKey]._this[index], properties), properties);
    } catch (error) {
      // Setter failed to validate value -- log error.
      this[_privateKey]._errors.push(error);
    }
  });

  // Aliased fields reflect values on other fields and do not need to be initialized.
  if (properties.isAlias === true) {
    return;
  }

  if (properties.virtual === true) {
    return;
  }

  // In case of object & array, they must be initialized immediately.
  if (properties.type === 'object') {
    if (properties.default !== undefined) {
      writeValue.call(this[_privateKey]._this, _.isFunction(properties.default) ? properties.default.call(this) : properties.default, properties);
    } else {
      writeValue.call(this[_privateKey]._this, properties.objectType ? new properties.objectType : {}, properties);
    }

    // Native arrays are never used so that toArray can be globally supported.
    // Additionally, other properties such as unique rely on passing through us.
  } else if (properties.type === 'array') {
    writeValue.call(this[_privateKey]._this, new ModelArray(this, properties), properties);
  }
}

// Used to fetch current values.
function getter(value, properties) {
  // Most calculations happen within the typecast and the value passed is typically the value we want to use.
  // Typically, the getter just returns the value.
  // Modifications to the value within the getter are not written to the object.

  // Getter can transform value after typecast.
  if (properties.get) {
    value = properties.get.call(this, value);
  }

  return value;
}

// Used to write value to object.
function writeValue(value, fieldSchema) {
  // onBeforeValueSet allows you to cancel the operation.
  // It doesn't work like transform and others that allow you to modify the value because all typecast has already happened.
  // For use-cases where you need to modify the value, you can set a new value in the handler and return false.
  if (this.schema.options.onBeforeValueSet) {
    if (this.schema.options.onBeforeValueSet.call(this, fieldSchema.name, value) === false) {
      return;
    }
  }

  // Alias simply copies the value without actually writing it to alias index.
  // Because the value isn't actually set on the alias index, onValueSet isn't fired.
  if (fieldSchema.type === 'alias') {
    this[fieldSchema.index] = value;
    return;
  }

  // if virtual and set specified call it
  if (fieldSchema.virtual === true) {
    if (fieldSchema.set) {
      value = fieldSchema.set.call(this, value);
    }
    else {
      return;
    }
  }

  // Write the value to the inner object.
  this[_privateKey]._obj[fieldSchema.name] = value;

  // onValueSet notifies you after a value has been written.
  if (this.schema.options.onValueSet) {
    this.schema.options.onValueSet.call(this, fieldSchema.name, value);
  }
}

// Reset field to default value.
function clearField(index, properties) {
  // Aliased fields reflect values on other fields and do not need to be cleared.
  if (properties.isAlias === true) {
    return;
  }

  // In case of object & array, they must be initialized immediately.
  if (properties.type === 'object') {
    if (this[properties.name].clear) {
      this[properties.name].clear();
    }
    else {
      writeValue.call(this[_privateKey]._this, undefined, properties);
    }

    // Native arrays are never used so that toArray can be globally supported.
    // Additionally, other properties such as unique rely on passing through Model.
  } else if (properties.type === 'array') {
    this[properties.name].length = 0;

    // Other field types can simply have their value set to undefined.
  } else {
    writeValue.call(this[_privateKey]._this, undefined, properties);
  }
}

export class Model {
  /**
   * @classdesc Model class represents an actual instance of an object. Clients do not create Models. Generated Models from
   * schema extend this class.
   *
   * @description Clients do not need to create Models manually.
   * @class
   * @param values
   * @param options
   * @param schema
   * @param name
   * @returns {Model}
   */
  constructor(values, options, schema, name) {
    // Object used to store internals.
    const _private = this[_privateKey] = {};

    // Object with getters and setters bound.
    _private._getset = this;

    // Public version of ourselves.
    // Overwritten with proxy if available.
    _private._this = this;

    // Object used to store raw values.
    _private._obj = {};

    /**
     * Schema the schema of this model. This is both a static and instance property.
     * @member {Schema}
     */
    this.schema = schema;

    if (name) {
      /**
       * The name the name of the model. This is both a static and instance property.
       * @member {String}
       * @example
       * var schema = plaster.schema({ name: String });
       * var Cat = plaster.model('Cat', schema);
       * var kitty = new Cat({ name: 'Zildjian' });
       * console.log(Cat.modelName); // 'Cat'
       * console.log(kitty.modelName); // 'Cat'
       */
      this.modelName = name;
    }

    // Errors, retrieved with getErrors().
    _private._errors = [];

    // Reserved keys for storing internal properties accessible from outside.
    _private._reservedFields = {};

    // Define getters/typecasts based off of schema.
    _.each(schema.descriptor, (properties, index) => {
      // Use getter / typecast to intercept and re-route, transform, etc.
      defineGetter.call(_private._getset, index, properties);
      defineSetter.call(_private._getset, index, properties);
    });

    // Proxy used as interface to object allows to intercept all access.
    // Without Proxy we must register individual getter/typecasts to put any logic in place.
    // With Proxy, we still use the individual getter/typecasts, but also catch values that aren't in the schema.
    if (typeof(Proxy) !== 'undefined') {
      const proxy = this[_privateKey]._this = new Proxy(this, {
        // Ensure only public keys are shown
        ownKeys: (target) => {
          return Object.keys(this.toObject());
        },

        // Return keys to iterate
        enumerate: (target) => {
          return Object.keys(this[_privateKey]._this)[Symbol.iterator]();
        },

        // Check to see if key exists
        has: (target, key) => {
          return !!_private._getset[key];
        },

        // Ensure correct prototype is returned.
        getPrototypeOf: () => {
          return _private._getset;
        },

        // Ensure readOnly fields are not writeable.
        getOwnPropertyDescriptor: (target, key) => {
          return {
            value: proxy[key],
            writeable: schema.descriptor[key].readOnly !== true,
            enumerable: true,
            configurable: true
          };
        },

        // Intercept all get calls.
        get: (target, name, receiver) => {
          // First check to see if it's a reserved field.
          if (_reservedFields.includes(name)) {
            return this[_privateKey]._reservedFields[name];
          }

          // Support dot notation via lodash.
          if (this.schema.options.dotNotation && name.indexOf('.') !== -1) {
            return _.get(this[_privateKey]._this, name);
          }

          // Use registered getter without hitting the proxy to avoid creating an infinite loop.
          return this[name];
        },

        // Intercept all set calls.
        set: (target, name, value, receiver) => {
          // Support dot notation via lodash.
          if (this.schema.options.dotNotation && name.indexOf('.') !== -1) {
            return _.set(this[_privateKey]._this, name, value);
          }

          if (!schema.descriptor[name]) {
            if (this.schema.options.strict) {
              // Strict mode means we don't want to deal with anything not in the schema.
              // TODO: SetterError here.
              return;
            } else {
              // Add index to schema dynamically when value is set.
              // This is necessary for toObject to see the field.
              addToSchema.call(this, name, {type: 'any'});
            }
          }

          // This hits the registered setter but bypasses the proxy to avoid an infinite loop.
          this[name] = value;
        },

        // Intercept all delete calls.
        deleteProperty: (target, property) => {
          this[property] = undefined;
          return true;
        }
      });
    }

    // Populate schema defaults into object.
    _.each(schema.descriptor, (properties, index) => {
      if (properties.default !== undefined) {
        // Temporarily ensure readOnly is turned off to prevent the set from failing.
        const readOnly = properties.readOnly;
        properties.readOnly = false;
        this[index] = _.isFunction(properties.default) ? properties.default.call(this) : properties.default;
        properties.readOnly = readOnly;
      }
    });

    // Populate runtime values as provided to this instance of object.
    if (_.isObject(values)) {
      var data = values;
      if (options.clone) {
        data = clone(values);
      }
      this.set(data);
    }

    // if they supplied init() method
    if (this.init && _.isFunction(this.init)) {
      this.init();
    }

    // May return actual object instance or Proxy, depending on harmony support.
    return this[_privateKey]._this;
  }

  /**
   * Sets data on the model based on the schema.
   * Accepts a key of property and value for the property, or object representing the data for document.
   *
   * @api public
   * @example
   * user.set('fistName', 'Joe');
   * user.set({ lastName: 'Smith');
   */
  set(path, value) {
    if (_.isObject(path) && !value) {
      value = path;
      for (const key in value) {
        this[_privateKey]._this[key] = value[key];
      }
    }
    else {
      this[_privateKey]._this[path] = value;
    }
  }

  /**
   *
   * @param options
   * @param json
   * @returns {{}}
   * @private
   */
  _toObject(options, json) {
    var defaultOptions = {transform: true, json: json, minimize: true};

    // When internally saving this document we always pass options,
    // bypassing the custom schema options.
    if (!(options && 'Object' === getFunctionName(options.constructor)) ||
      (options && options.$_useSchemaOptions)) {
      if (json) {
        options = this.schema.options.toJSON ?
          clone(this.schema.options.toJSON) : {};
        options.json = true;
        options.$_useSchemaOptions = true;
      } else {
        options = this.schema.options.toObject ?
          clone(this.schema.options.toObject) : {};
        options.json = false;
        options.$_useSchemaOptions = true;
      }
    }

    for (var key in defaultOptions) {
      if (defaultOptions.hasOwnProperty(key) && options[key] === undefined) {
        options[key] = defaultOptions[key];
      }
    }

    // remember the root transform function
    // to save it from being overwritten by sub-transform functions
    var originalTransform = options.transform;

    let ret = {};

    // Populate all properties in schema.
    _.each(this.schema.descriptor, (properties, index) => {
      // Do not write values to object that are marked as invisible.
      if (properties.invisible && !properties.virtual) {
        return;
      }

      if (properties.virtual && !options.virtuals) {
        return;
      }

      // Fetch value through the public interface.
      let value = this[_privateKey]._this[index];

      if (value === undefined && options.minimize) {
        return;
      }

      // Clone objects so they can't be modified by reference.
      if (typeof value === 'object') {
        if (value._isModelObject) {
          if (options && options.json && 'function' === typeof value.toJSON) {
            value = value.toJSON(options);
          } else {
            value = value.toObject(options);
          }
        } else if (value._isModelArray) {
          value = value.toArray();
        } else if (_.isArray(value)) {
          value = value.splice(0);
        } else if (_.isDate(value)) {
          // https://github.com/documentcloud/underscore/pull/863
          // _.clone doesn't work on Date object.
          var d = new Date(value.getTime());
          if (options.dateToISO === true) {
            ret[index] = d.toISOString();
          }
          else {
            ret[index] = new Date(value.getTime());
          }
        } else {
          value = _.clone(value);
        }

        // Don't write empty objects or arrays.
        if (!_.isDate(value) && options.minimize && !_.size(value)) {
          return;
        }
      }

      // Write to object.
      ret[index] = value;
    });

    var transform = options.transform;

    // In the case where a subdocument has its own transform function, we need to
    // check and see if the parent has a transform (options.transform) and if the
    // child schema has a transform (this.schema.options.toObject) In this case,
    // we need to adjust options.transform to be the child schema's transform and
    // not the parent schema's
    if (true === transform || (this.schema.options.toObject && transform)) {

      var opts = options.json ? this.schema.options.toJSON : this.schema.options.toObject;

      if (opts) {
        transform = (typeof options.transform === 'function' ? options.transform : opts.transform);
      }
    } else {
      options.transform = originalTransform;
    }

    if (typeof transform === 'function') {
      var xformed = transform(this, ret, options);
      if (typeof xformed !== 'undefined') {
        ret = xformed;
      }
    }

    return ret;
  }

  /**
   * Converts this document into a plain javascript object.
   *
   * @api public
   * @param {Object} options
   * @param {Function} options.transform - a transform function to apply to the resulting document before returning.
   * @param {Boolean} options.virtuals - apply virtual getters. Default: <code>false</code>
   * @param {Boolean} options.minimize - remove empty objects. Default: <code>true</code>
   * @param {Boolean} options.dateToISO - convert dates to string in ISO format using <code>Date.toISOString()</code>. Default: <code>false</code>
   *
   * @return {Object} Plain javascript object representation of document.
   *
   * @example
   * var userSchema = plaster.schema({ name: String });
   * var User = plaster.model('User', userSchema);
   * var user = new User({name: 'Joe Smith'});
   * console.log(user); // automatically invokes toObject()
   *
   * @example <caption>Example with transform option.</caption>
   * var xform = function (doc, ret, options) {
   *   ret.name = ret.name.toUpperCase();
   *   return ret;
   * };
   * console.dir(user.toObject({transform: xform}); // { name: 'JOE SMITH' }
   */
  toObject(options) {
    return this._toObject(options);
  }

  /**
   * Similar as <code>toObject</code> but applied when <code>JSON.stringify</code> is called
   *
   * @api public
   * @param {Object} options - Same options as <code>toObject</code>.
   * @return {Object} Plain javascript object representation of document.
   */
  toJSON(options) {
    return this._toObject(options, true);
  }

  /**
   * Helper for <code>console.log</code>. Just invokes default <code>toObject</code>.
   * @api public
   */
  inspect() {
    return this.toObject({});
  }

  /**
   * Helper for <code>console.log</code>. Alias for <code>inspect</code>.
   * @api public
   */
  toString() {
    return this.inspect();
  }

  /**
   * Clear the model data.
   */
  clear() {
    _.each(this.schema.descriptor, (properties, index) => {
      clearField.call(this[_privateKey]._this, index, properties);
    });
  }

  /**
   * Gets the errors object.
   */
  getErrors() {
    return this[_privateKey]._errors;
  }

  /**
   * Clears all the errors.
   */
  clearErrors() {
    this[_privateKey]._errors.length = 0;
  }

  /**
   * Checks whether we have any errors.
   * @return {Boolean} <code>true</code> if we have errors, <code>false</code> otherwise.
   */
  hasErrors() {
    return !!this[_privateKey]._errors.length;
  }

  /**
   * Used to detect instance of schema object internally.
   * @private
   */
  _isModelObject() {
    return true;
  }
}