Source: types/collection.js

'use strict';

const pluralize = require('pluralize');
const monitor = require('fast-json-patch');
const pointer = require('json-pointer');

const Entity = require('./entity');
const Stack = require('./stack');
const State = require('./state');

/**
 * The {@link Collection} type maintains an ordered list of {@link State} items.
 * @property {Object} @entity Fabric-bound entity object.
 */
class Collection extends Stack {
  /**
   * Create a list of {@link Entity}-like objects for later retrieval.
   * @param  {Object}  [configuration={}] Configuration object.
   * @return {Collection}                 Configured instance of the the {@link Collection}.
   */
  constructor (configuration = {}) {
    super(configuration);

    // TODO: document `listeners` handler (currently only `create`)
    this.settings = Object.assign({
      atomic: true,
      type: Entity,
      deterministic: true,
      name: '@fabric/store',
      path: `./collections`,
      fields: { id: 'id' },
      key: 'id'
    }, configuration);

    this['@type'] = 'Collection';
    this['@entity']['@type'] = 'Collection';

    // Set name to plural version, define path for storage
    this.name = pluralize(this.settings.name);
    this.path = `/` + this.name.toLowerCase();

    this._state = {};
    this.value = {};

    this.set(`${this.path}`, this.settings.data || {});
    this.observer = monitor.observe(this.value);

    Object.defineProperty(this, '@allocation', { enumerable: false });
    Object.defineProperty(this, '@buffer', { enumerable: false });
    Object.defineProperty(this, '@encoding', { enumerable: false });
    Object.defineProperty(this, '@parent', { enumerable: false });
    Object.defineProperty(this, '@preimage', { enumerable: false });
    Object.defineProperty(this, 'frame', { enumerable: false });
    Object.defineProperty(this, 'services', { enumerable: false });

    return this;
  }

  get routes () {
    return this.settings.routes;
  }

  /**
   * Current elements of the collection as a {@link MerkleTree}.
   * @returns {MerkleTree}
   */
  asMerkleTree () {
    let list = pointer.get(this.value, this.path);
    let stack = new Stack(Object.keys(list));
    return stack.asMerkleTree();
  }

  /**
   * Sets the `key` property of collection settings.
   * @param {String} name Value to set the `key` setting to.
   */
  _setKey (name) {
    this.settings.key = name;
  }

  /**
   * Retrieve an element from the collection by ID.
   * @param {String} id Document identifier.
   */
  getByID (id) {
    if (!id) return null;

    let result = null;

    try {
      if (this.settings.verbosity >= 5) console.log(`getting ${this.path}/${id} from:`, this.value);
      result = pointer.get(this.value, `${this.path}/${id}`);
    } catch (E) {
     // console.debug('[FABRIC:COLLECTION]', `@${this.name}`, Date.now(), `Could not find ID "${id}" in tree ${this.asMerkleTree()}`);
    }

    result = this._wrapResult(result);

    return result;
  }

  /**
   * Retrieve the most recent element in the collection.
   */
  getLatest () {
    let items = pointer.get(this.value, this.path);
    return items[items.length - 1];
  }

  /**
   * Find a document by specific field.
   * @param {String} name Name of field to search.
   * @param {String} value Value to match.
   */
  findByField (name, value) {
    let result = null;
    let items = pointer.get(this.value, this.path);
    // constant-time loop
    for (let id in items) {
      if (items[id][name] === value) {
        // use only first result
        result = (result) ? result : items[id];
      }
    }
    return result;
  }

  /**
   * Find a document by the "name" field.
   * @param {String} name Name to search for.
   */
  findByName (name) {
    let result = null;
    let items = pointer.get(this.value, this.path);
    // constant-time loop
    for (let id in items) {
      if (items[id].name === name) {
        // use only first result
        result = (result) ? result : items[id];
      }
    }
    return result;
  }

  /**
   * Find a document by the "symbol" field.
   * @param {String} symbol Value to search for.
   */
  findBySymbol (symbol) {
    let result = null;
    let items = pointer.get(this.value, this.path);
    // constant-time loop
    for (let id in items) {
      // TODO: fix bug here (check for symbol)
      if (items[id].symbol === symbol) {
        // use only first result
        result = (result) ? result : items[id];
      }
    }
    return result;
  }

  // TODO: deep search, consider GraphQL (!!!: to discuss)
  match (query = {}) {
    let result = null;
    let items = pointer.get(this.value, this.path);
    let list = Object.keys(items).map((x) => {
      return items[x];
    });

    try {
      result = list.filter((x) => {
        for (let field in query) {
          if (x[field] !== query[field]) return false;
        }
        return true;
      });
    } catch (E) {
      console.error('Could not match:', E);
    }

    return result;
  }

  _wrapResult (result) {
    // TODO: enable upstream specification via pure JSON
    if (this.settings.type.name !== 'Entity') {
      let Type = this.settings.type;
      result = new Type(result || {});
    }

    // TODO: validation of result by calling result.validate()
    // TODO: signing of result by calling result.signWith()
    return result;
  }

  /**
   * Modify a target document using an array of atomic updates.
   * @param {String} path Path to the document to modify.
   * @param {Array} patches List of operations to apply.
   */
  async _patchTarget (path, patches) {
    let link = `${path}`;
    let result = null;

    if (this.settings.verbosity >= 5) console.log('[AUDIT]', 'Patching target:', path, patches);

    try {
      result = monitor.applyPatch(this.value, patches.map((op) => {
        op.path = `${link}${op.path}`;
        return op;
      })).newDocument;
    } catch (E) {
      console.error('Could not patch target:', E, path, patches);
    }

    await this.commit();

    return result;
  }

  /**
   * Adds an {@link Entity} to the {@link Collection}.
   * @param  {Mixed} data {@link Entity} to add.
   * @return {Number}      Length of the collection.
   */
  async push (data, commit = true) {
    super.push(data);

    let state = new State(data);

    this['@entity'].states[this.id] = this['@data'];
    this['@entity'].states[state.id] = state['@data'];

    this['@entity']['@data'] = this['@data'].map(x => x.toString());
    this['@data'] = this['@entity']['@data'];

    this['@id'] = this.id;

    if (commit) {
      try {
        this['@commit'] = await this.commit();
      } catch (E) {
        console.error('Could not commit.', E);
      }
    }

    return this['@data'].length;
  }

  async populate () {
    return Promise.all(this['@entity']['@data'].map(id => {
      return this['@entity'].states[id.toString('hex')];
    }));
  }

  async query (path) {
    return this.get(path);
  }

  /**
   * Retrieve a key from the {@link State}.
   * @param {Path} path Key to retrieve.
   * @returns {Mixed}
   */
  get (path) {
    let result = null;

    try {
      result = pointer.get(this['@entity']['@data'], path);
    } catch (exception) {
      this.emit('warning', `[FABRIC:COLLECTION] Could not retrieve path: ${path} ${JSON.stringify(exception)}`);
      // console.error('[FABRIC:COLLECTION]', 'Could not retrieve path:', path, exception);
    }

    return result;
  }

  /**
   * Set a key in the {@link State} to a particular value.
   * @param {Path} path Key to retrieve.
   * @returns {Mixed}
   */
  set (path, value) {
    pointer.set(this._state, path, value);
    pointer.set(this.value, path, value);
    pointer.set(this['@entity']['@data'], path, value);

    this.commit();
    return true;
  }

  /**
   * Generate a list of elements in the collection.
   * @deprecated
   * @returns {Array}
   */
  list () {
    let map = this.map();
    let ids = Object.keys(map);
    // TODO: `list()` should return an Array
    let result = {};

    for (let i = 0; i < ids.length; i++) {
      result[ids[i]] = this._wrapResult(map[ids[i]]);
    }

    return result;
  }

  /**
   * Provides the {@link Collection} as an {@link Array} of typed
   * elements.  The type of these elments are defined by the collection's
   * type, supplied in the constructor.
   */
  toTypedArray () {
    const map = this.map();
    const ids = Object.keys(map);
    return ids.map((x) => this._wrapResult(map[ids[x]]));
  }

  typedMap () {
    const map = this.map();
    const ids = Object.keys(map);
    // TODO: `list()` should return an Array
    const result = {};

    for (let i = 0; i < ids.length; i++) {
      result[ids[i]] = this._wrapResult(map[ids[i]]);
    }

    return result;
  }

  /**
   * Generate a hashtable of elements in the collection.
   * @returns {Array}
   */
  map () {
    return Collection.pointer.get(this.value, `${this.path}`);
  }

  render () {
    return this.serialize(this.state);
  }

  /**
   * Create an instance of an {@link Entity}.
   * @param  {Object}  entity Object with properties.
   * @return {Promise}        Resolves with instantiated {@link Entity}.
   */
  async create (input, commit = true) {
    if (this.settings.verbosity >= 5) console.log('[FABRIC:COLLECTION]', 'Creating object:', input);
    if (!this.settings.deterministic) input.created = Date.now();

    let result = null;
    let entity = new Entity(input);
    let link = `${this.path}/${entity.id}`;
    // TODO: enable specifying names (again)
    // let link = `${this.path}/${(entity.data[this.settings.fields.id] || entity.id)}`;
    // TODO: handle duplicates (when desired, i.e., "unique" in settings)
    let current = await this.getByID(entity.id);
    if (current) {
      if (this.settings.verbosity >= 5) console.log('[FABRIC:COLLECTION]', 'Exact entity exists:', current);
    }

    if (this.settings.methods && this.settings.methods.create) {
      result = await this.settings.methods.create.call(this, input);
    } else {
      result = entity;
    }

    pointer.set(this._state, link, result.data);

    this.set(link, result.data || result);

    this.emit('message', {
      '@type': 'Create',
      '@data': Object.assign({}, result.data, {
        id: entity.id
      })
    });

    if (commit) {
      try {
        this['@commit'] = await this.commit();
        this.emit('commit', this['@commit']);
      } catch (E) {
        console.error('Could not commit.', E);
      }
    }

    if (this.settings.listeners && this.settings.listeners.create) {
      await this.settings.listeners.create(entity.data);
    }

    result = result.data || entity.data;
    result.id = entity.id;

    return result;
  }

  /**
   * Loads {@link State} into memory.
   * @param {State} state State to import.
   * @param {Boolean} commit Whether or not to commit the result.
   * @emits message Will emit one {@link Snapshot} message.
   */
  async import (input, commit = true) {
    if (input['@data']) input = input['@data'];

    let result = null;
    let size = await this.push(input, false);
    let state = this['@entity'].states[this['@data'][size - 1]];
    let entity = new Entity(state);
    let link = `${this.path}/${input.id || entity.id}`;

    if (this.settings.verbosity >= 4) console.log('state.data:', state.data);
    if (this.settings.verbosity >= 4) console.log('state:', state);
    if (this.settings.verbosity >= 4) console.log('link:', link);

    this.set(link, state.data || state);

    if (commit) {
      try {
        this['@commit'] = await this.commit();
      } catch (E) {
        console.error('Could not commit.', E);
      }
    }

    result = state.data || entity.data;
    result.id = input.id || entity.id;

    // TODO: ensure updates sent on subscriber channels
    // ESPECIALLY when an ID is supplied...
    // TODO: test upstream attack vectors
    if (this.settings.verbosity >= 4) console.log('input.id', input.id);

    this.emit('message', {
      '@type': 'Snapshot',
      '@data': {
        path: this.path,
        state: pointer.get(this.value, this.path)
      }
    });

    return result;
  }

  async importList (list) {
    let ids = [];

    for (let i = 0; i < list.length; i++) {
      let item = await this.import(list[i]);
      ids.push(item.id);
    }

    return ids;
  }

  async importMap (map) {
    return this.importList(Object.values(map));
  }

  commit () {
    if (this.settings.verbosity >= 4) this.emit('debug', '[FABRIC:COLLECTION] Committing...');
    const patches = monitor.generate(this.observer);

    if (patches && patches.length) {
      const body = {
        changes: patches,
        state: this.value
      };

      this.emit('transaction', body);
      this.emit('patches', patches);
      this.emit('message', {
        '@type': 'Transaction',
        '@data': body
      });
    }
  }

  get len () {
    return Object.keys(this.list()).length;
  }
}

module.exports = Collection;