Home Reference Source Repository

lib/actions.js

import { ddbClient } from './core';
import { difference, intersection, isNumber, isArray, isBoolean, isString, pluck, isObject, isFunction, isEmpty } from 'underscore';
import { buildUpdateExpression, genTableDigest, iterateArrayOverPromise, buildAction } from './utils';
import { v4 } from 'uuid';

const DEBUG = false;
const log = (msg, json) => {if(DEBUG) {console.log(`${msg}\n${json ? JSON.stringify(json, null, 2) : ''}\n`)}};

export const COUNTERS_TABLE_NAME = 'table_counters';

export default class PyropeActions {
  static tablePrefix;
  static tableName;
  static tableSuffix;
  static fullTableName;
  
  /**
   * PyropeActions
   *
   * Low level DynamoDB actions.
   *
   * @param {object} opts - The options object.
   * @param {string} opts.tablePrefix - Prefix to the lookup table.
   * @param {string} opts.tableName - Table name.
   * @param {string} opts.tableSuffix - Suffix to the lookup table.
   */
  constructor(opts) {
    const { tablePrefix, tableName, tableSuffix } = opts;
    
    if(!tableName || !isString(tableName)) throw new Error(`PyropeActions#constructor(): 'tableName' is undefined or not a string.`);
    
    this.tablePrefix = tablePrefix || '';
    this.tableName = tableName;
    this.tableSuffix = tableSuffix || '';
    this.fullTableName = this.tablePrefix + this.tableName + this.tableSuffix;
  }
  
  /**
   * Returns an array with the mappings for every record in the table.
   *
   * Results are returned sorted ascending. You can specify a limit  and a cursor
   * to do batch fetching.
   *
   * ```
   * opts = {
   *  ascending: Boolean,    // Should the results be returned in a ascending manner?
   *  limit: Integer,        // The amount of items to fetch
   *  cursor: String         // A base64 encoded map of the key containing {uuid: String, createdAt: Number, _table: String}
   * }
   * ```
   *
   *
   * @param {string} opts.order - Order ('asc'|'desc')
   * @param {number} opts.limit - Limit to return.
   * @param {string} opts.cursor - Cursor.
   * @return {Promise<array>} An array with the QUERY result, has the structure {Items: [], Count: 0, Cursor: ''}
   *
   * @TODO: Select specific fields to return.
   */
  all(opts = {}) {
    return new Promise((resolve, reject) => {
      // if(opts === undefined) return reject(`PyropeActions#all() > 'opts' is not defined.`);
  
      let { order = 'asc', limit = 0, cursor = undefined } = opts;
      
      // if(tableName === undefined || !isString(tableName)) return reject(`all() > 'tableName' is undefined or not a string.`);
      if(order && !isString(order)) return reject(`PyropeActions#all() > 'order' is not a string.`);
      if(order && order !== 'asc' && order !== 'desc') return reject(`PyropeActions#all() > 'order' is neither 'asc' nor 'desc'.`);
      if(limit && !isNumber(limit)) return reject(`PyropeActions#all() > 'limit' is not a number.`);
      if(cursor && !isString(cursor)) return reject(`PyropeActions#all() > 'cursor' is not a string.`);
  
      let params = {
        TableName: this.fullTableName,
        IndexName: '_tableIndex',
        KeyConditionExpression: `#t = :t`,
        ExpressionAttributeNames: {
          '#t': '_table'
        },
        ExpressionAttributeValues: {
          ':t': this.fullTableName
        },
        ScanIndexForward: order === 'asc',
        Select: 'ALL_ATTRIBUTES'
      };
  
      if(limit && isNumber(limit) && limit > 0) {
        params = {...params, Limit: limit};
      }
  
      if(cursor) {
        let startKey;
    
        try {
          startKey = JSON.parse(new Buffer(cursor, 'base64').toString('ascii'));
        } catch(err) {
          return reject(`all() > Error parsing cursor: ${err}`);
        }
    
        params = {...params, ExclusiveStartKey: startKey};
      }
  
      ddbClient('query', params)
        .then(res => {
          if(res.LastEvaluatedKey) {
            res.Cursor = new Buffer(JSON.stringify(res.LastEvaluatedKey)).toString('base64');
          }
      
          resolve(res);
        })
        .catch(err => reject(`all() > ${err}`));
    });
  }
    
  
  take(opts = {}) {
    let { limit = 1, cursor, order = 'asc' } = opts;
    
    return this.all({limit, cursor, order});
  };
  
  first(opts = {}) {
    let { limit = 1, cursor } = opts;
    
    return this.take({limit, cursor});
  };
  
  last(opts = {}) {
    let { limit = 1, cursor } = opts;
    
    return this.take({limit, order: 'desc', cursor});
  };
  
  increaseCounter( opts ) {
    return this.updateCounter({...opts, step: 1});
  }
  
  decreaseCounter( opts ) {
    return this.updateCounter({...opts, step: -1});
  }
  
  updateCounter( opts ) {
    return new Promise((resolve, reject) => {
      const { step } = opts;
    
      // if(fullTableName === undefined) return reject(`updateCounter(): Missing 'fullTableName'`);
      if(!isNumber(step)) return reject(`PyropeActions#updateCounter(): 'step' is not a number.`);
    
      const counterTableName = this.tablePrefix + COUNTERS_TABLE_NAME  + this.tableSuffix;
      const tableDigest = genTableDigest(this.fullTableName);
    
      ddbClient('update', {
        TableName: counterTableName,
        Key: {tableDigest},
        UpdateExpression: `ADD #count :step`,
        ExpressionAttributeNames: {
          '#count': 'count'
        },
        ExpressionAttributeValues: {
          ':step': step
        },
        ReturnValues: 'ALL_NEW'
      })
        .then(res => {
          if(res.Attributes.count === undefined) {
            reject(`Error while updating counter for '${this.fullTableName}'.`);
          } else {
            resolve(res.Attributes.count);
          }
        })
        .catch(err => reject(`updateCounter(): ${err}`))
    });
  }
  
  /**
   * Returns the table's item count
   *
   * @return {Promise<number>} The item count.
   */
  count() {
    return new Promise((resolve, reject) => {
      // if(opts === undefined) return reject(`PyropeActions#count() > 'opts' is not defined.`);
      
      // if(tableName && (tableName === undefined || !isString(tableName))) return reject(`PyropeActions#count() > 'tableName' is undefined or not a string.`);
    
      const counterTableName = this.tablePrefix + COUNTERS_TABLE_NAME + this.tableSuffix;
    
      let count = 0;
    
      this.findByIndex({
        tableName: counterTableName,
        index: {tableDigest: genTableDigest(this.fullTableName)}
      })
        .then(res => {
          if(res === false) {
            resolve(0);
          } else {
            resolve(res[0].count);
          }
        })
        .catch(err => reject(`count() > ${err}`));
    });
  }
  
  /**
   * Queries a table and looks for items matching the specified index.
   *
   * The index mapping has the following structure:
   *
   * ```
   * index = {
 *   username: 'john'
 * }
   * ```
   *
   * In order to find the table's index, the word 'Index' is appended, resulting
   * in this example in 'usernameIndex'
   *
   * Every index has a 'createdAt' range key. This is calculated and
   * appended automatically before querying.
   *
   * ```
   * opts = {
 *   tableName: String,
 *   index: {hash: value, [range: value]},
 *   ascending: Boolean
 * }
   * ```
   *
   * @param {object} opts Options mapping.
   * @return {Promise<array|boolean>} Array when there are multiple matches, false when not found
   *
   */
  findByIndex(opts) {
    return new Promise((resolve, reject) => {
      if(opts === undefined) return reject(`PyropeActions#findByIndex() > 'opts' is not defined.`);
    
      let { tableName = this.fullTableName, index, order = 'asc' } = opts;
    
      // if(tableName === undefined || !isString(tableName)) return reject(`PyropeActions#findByIndex() > 'tableName' is undefined or not a string.`);
      if(index === undefined || !isObject(index)) return reject(`PyropeActions#findByIndex() > 'index' is undefined or not an object.`);
      if(Object.keys(index).length > 2) return reject(`PyropeActions#findByIndex() > 'index' should have at most 2 key-value pairs.`);
      if(Object.keys(index).length < 1) return reject(`PyropeActions#findByIndex() > 'index' should have at least 1 key-value pair.`);
      if(order && !isString(order)) return reject(`PyropeActions#findByIndex() > 'order' is not a string.`);
      if(order && order !== 'asc' && order !== 'desc') return reject(`PyropeActions#findByIndex() > 'order' is neither 'asc' nor 'desc'.`);
    
      const indexKey = Object.keys(index)[0];
      const indexValue = index[indexKey];
      const indexName = Object.keys(index)[0] + 'Index';
    
      const params = {
        TableName: tableName,
        IndexName: indexName,
        KeyConditionExpression: `#${indexKey} = :${indexKey}`,
        ExpressionAttributeNames: {
          [`#${indexKey}`]: indexKey,
        },
        ExpressionAttributeValues: {
          [`:${indexKey}`]: indexValue
        },
        ScanIndexForward: order === 'asc',
        Select: 'ALL_ATTRIBUTES'
      };
    
      ddbClient('query', params)
        .then(res => {
          if(res.Items.length === 0) {
            resolve(false); // not found
          } else {
            resolve(res.Items);
          }
        })
        .catch(err => reject(`PyropeActions#findByIndex() > ${err}`));
    });
  }
  
  
  /**
   * Creates a new record in the specified table.
   *
   * It will create the record without any conditions.
   * The following fields are auto-generated: uuid, createdAt, updatedAt
   *
   * ```
   * opts = {
 *   tableName: 'users',
 *   fields: {
 *     username: 'someguy55',
 *     password: 'myPassword123'
 *   }
 * }
   *
   * return = {
 *   uuid: '3f3e1091-8e43-41ca-a19a-881241370c31' // String = 'S'
 *   username: 'someguy55',                       // String = 'S'
 *   password: 'myPassword123'                    // String = 'S'
 *   createdAt: 1470345881706,                    // Number = 'N'
 *   updatedAt: 1470345881755                     // Number = 'N'
 * }
   * ```
   *
   * @param {object} opts Options mapping.
   * @return {object} The fields of the newly created record, rejection on error
   * @todo: Handle unprocessedItems
   * @todo: Handle failed increaseCounter()
   * @todo: let uuid, createdAt, updatedAt to be cofigurable ?
   *
   */
  create(opts) {
    return new Promise((resolve, reject) => {
      // todo: Sanitize variables (convert empty strings to null)
      if (opts === undefined) return reject(`PyropeActions#create() > 'opts' is not defined.`);
  
      let {fields} = opts;
  
      // if (tableName === undefined || !isString(tableName)) return reject(`PyropeActions#create() > 'tableName' is undefined or not a string.`);
      if (fields === undefined || !isObject(fields)) return reject(`PyropeActions#create() > 'fields' is undefined or not an object.`);
  
      fields.uuid = v4();
      fields.createdAt = Number(new Date().getTime());
      fields.updatedAt = Number(new Date().getTime());
      fields._table = this.fullTableName;
  
      ddbClient('put', {
        TableName: this.fullTableName,
        Item: fields
      })
        .then(() => this.increaseCounter())
        .then(() => resolve(fields))
        .catch(err => reject(`create() > ${err}`));
    });
  };
  
  /**
   * Updates an item
   *
   * Previously checks using findByIndex() if the item exits.
   *  If it doesn't, returns false.
   *
   * ```
   * opts = {
 *   tableName: String,
 *   index: Object,       // Index used to query the item.
 *   args: Object,        // Key-Value mapping of the arguments to change.
 *   beforeHook: (attrName, args) Function // Callback function for every field.
 *                                            Used to modify the key-value mapping of the fields to
 *                                            change before the item is updated. Must return object of
 *                                            shape {fieldName: value}
 *                                            Attribute names may be also modified.
 * }
   * ```
   *
   * @param {Object} opts Options mapping
   * @return {Object|Boolean} A key-value map with the updated fields or false if not found.
   * @todo: Handle unprocessedItems
   *
   */
  update(opts) {
    return new Promise((resolve, reject) => {
      if(opts === undefined) return reject(`PyropeActions#update() > 'opts' is not defined.`);
    
      let { index, fields } = opts;
    
      // if(tableName === undefined || !isString(tableName)) return reject(`PyropeActions#update() > 'tableName' is undefined or not a string.`);
      if(index === undefined || !isObject(index)) return reject(`PyropeActions#update() > 'index' is undefined or not an object.`);
      if(Object.keys(index).length < 1 || Object.keys(index).length > 2) return reject(`PyropeActions#update() > 'index' should have at least 1 item and at most 2.`);
      if(fields === undefined || !isObject(fields)) return reject(`PyropeActions#update() > 'fields' is undefined or not an object.`);
      // if(beforeHook && !isFunction(beforeHook)) return reject(`PyropeActions#update() > 'beforeHook' is not a function.`);
    
      this.findByIndex({
        tableName: this.fullTableName,
        index
      })
        .then(item => {
          if(isEmpty(fields)) {
            return item[0];
          } else if(item === false) {
            return false;
          } else if(isArray(item) && item.length > 1) {
            return reject(`Cannot update an array of records.`);
          } else if(isObject(item)) {
            return this._update({...opts, index: {uuid: item[0].uuid, createdAt: item[0].createdAt}})
              .then(item => resolve(item.Attributes))
              .catch(err => reject(err))
          } else {
            return false;
          }
        })
        .then(item => resolve(item))
        .catch(err => reject(`PyropeActions#update() > ${err}`));
    });
  }

  // Helper function that makes the actual update. This does not check if the item previously exists.
  _update(opts) {
    return new Promise((resolve, reject) => {
      if(opts === undefined) reject(`PyropeActions#_update() > 'opts' is not defined.`);
    
      let { index, fields, beforeHook } = opts;
    
      // if(tableName === undefined || !isString(tableName)) reject(`_update() > 'tableName' is undefined or not a string.`);
      if(index === undefined || !isObject(index)) reject(`_update() > 'index' is undefined or not an object.`);
      if(Object.keys(index).length < 1 || Object.keys(index).length > 2) reject(`_update() > 'index' should have at least 1 item and at most 2.`);
      if(fields === undefined || !isObject(fields)) reject(`_update() > 'fields' is undefined or not an object.`);
      // if(beforeHook && !isFunction(beforeHook)) reject(`_update() > 'beforeHook' is not a function.`);
    
      const hashKey = Object.keys(index)[0];
      const hashValue = index[hashKey];
      const rangeKey = Object.keys(index)[1];
      const rangeValue = index[rangeKey];
    
      // Mock beforeHook returning all argument {key:value} untouched
      if(!beforeHook) beforeHook = (attrName) => ({[attrName]: fields[attrName]});
    
      buildUpdateExpression(fields, beforeHook) // Build expression
        .then(expression => ({ // Build params
          TableName: this.fullTableName,
          Key: {
            [hashKey]: hashValue,
            [rangeKey]: rangeValue
          },
          ReturnValues: 'ALL_NEW',
          ...expression
        }))
        .then(params => ddbClient('update', params)) // DynamoDB Update
        .then(res => resolve(res)) // Resolve results
        .catch(err => reject(`PyropeActions#_update() > ${err}`));
    });
  }
  
  /**
   * Deletes an item
   *
   * Previously checks using findByIndex() if the item exits.
   *  If it doesn't, returns false, otherwise returns the deleted item.
   *
   * ```
   * opts = {
 *   tableName: String,
 *   index: Object,       // Index used to query the item.
 * }
   * ```
   *
   * @param {Object} opts Options mapping
   * @return {Object|Boolean} A key-value map with the deleted item's fields, false if not found
   * @todo: Handle unprocessedItems
   *
   */
  destroy(opts) {
    return new Promise((resolve, reject) => {
      if(opts === undefined) return reject(`PyropeActions#destroy() > 'opts' is not defined.`);
    
      let { index } = opts;
    
      // if(tableName === undefined || !isString(tableName)) return reject(`destroy() > 'tableName' is undefined or not a string.`);
      if(index === undefined || !isObject(index)) return reject(`PyropeActions#destroy() > 'index' is undefined or not an object.`);
      // if(index[Object.keys(index)[0]].length > 1) return reject(`destroy() > 'index' can only have one child.`);
      if(Object.keys(index).length < 1 || Object.keys(index).length > 2) return reject(`PyropeActions#destroy() > 'index' should have at least 1 item and at most 2.`);
    
      // console.log(`findByIndex.index: ${JSON.stringify(index, null, 2)}`);
      // console.log(`findByIndex.tableName: ${tableName}`);
    
      this.findByIndex({
        tableName: this.fullTableName,
        index
      })
        .then(item => {
          if(item === false) {
            return Promise.resolve(false);
          } else {
            const deleteRequests = item.map(item => ({DeleteRequest: {Key: {uuid: item.uuid, createdAt: item.createdAt}}}));
          
            const params = {
              RequestItems: {
                [this.fullTableName]: deleteRequests
              }
            };
          
            // console.log(`params: ${JSON.stringify(params, null, 2)}`);
          
            return ddbClient('batchWrite', params)
              .then(res => {
                if(res.UnprocessedItems && Object.keys(res.UnprocessedItems).length > 0) return Promise.reject('destroy() batch: Warning, unprocessed items.' + JSON.stringify(res.UnprocessedItems));
              
                return Promise.resolve(item[0]); //todo: should this return all deleted items?
              });
          }
        })
        .then(item => {
          if(isObject(item)) {
            return this.decreaseCounter().then(() => item);
          } else {
            return false;
          }
        })
        .then((res) => resolve(res))
        .catch(err => reject(`PyropeActions#destroy() > ${err}`))
    });
  }
  
  /**
   * Creates associations between entities
   *
   * Handles 1:1, 1:N and N:N associations.
   * (!) Does not check for the existence of the items.
   *
   * ```
   * opts = {
 *   tableName: String,
 *   items: [
 *     {
 *       index: {[indexName]: '123'}, // Can specify multiple values to associate
 *       [hasMany: Boolean = false]   // Used to specify 1:N and 1:N associations
 *     }
 *   ]
 * }
   * ```
   *
   * @param {Object} opts The options object.
   * @return {Boolean|Promise} Resolves to true if successful, throws otherwise
   * @todo: use batchWriteItem and handle unprocessedItems
   *
   */
  associate( opts ) {
    if(opts === undefined) return Promise.reject(`PyropeActions#associate() > opts should be an object.`);
    
    const { items } = opts;
    
    // General validations
    // if(tableName === undefined || !isString(tableName)) return Promise.reject(`PyropeActions#associate() > 'tableName' is undefined or not a string.`);
    if(items === undefined || !isArray(items)) return Promise.reject(`PyropeActions#associate(): 'items' is undefined or not an array.`);
    if(items.length !== 2) return Promise.reject(`PyropeActions#associate(): 'items' should have 2 elements.`);
    if((items[0].hasMany && !isBoolean(items[0].hasMany)) || (items[1].hasMany && !isBoolean(items[1].hasMany))) return Promise.reject(`PyropeActions#associate(): 'hasMany' should be boolean`);
    
    log('===========================================');
    log(`Associating: \n${JSON.stringify(items, null, 2)}`);
    
    // Iterate over the items (max 2)
    return iterateArrayOverPromise(items, (item, prevItemAction = {}, i) => {
      log('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~');
      log(`* item[${i}] = ${JSON.stringify(item, null, 2)}`);
      log(`prevItemAction = ${JSON.stringify(prevItemAction, null, 2)}`);
      
      // Item validations
      if(Object.keys(item.index).length !== 1) return Promise.reject(`PyropeActions#associate(): 'items[${i}].index' should have only 1 key-value pair.`);
      
      const index = item.index;
      const indexKey = Object.keys(index)[0];
      const indexValues = isArray(index[indexKey]) ? index[indexKey] : [index[indexKey]];
      const itemHasMany = item.hasMany;
      
      // log(`itemIndex = ${JSON.stringify(itemIndex, null, 2)}`);
      // log(`itemIndexKey = ${JSON.stringify(itemIndexKey, null, 2)}`);
      // log(`itemIndexValues = ${JSON.stringify(itemIndexValues, null, 2)}`);
      
      // Iterate over each item's uuids
      return iterateArrayOverPromise(indexValues, (indexValue, prevValueAction = {}) => {
        const currIndex = {[indexKey]: indexValue};
        
        log('···········································');
        log(`* currIndex = ${JSON.stringify(currIndex, null, 2)}`);
        log(`prevUuidAction: ${JSON.stringify(prevValueAction, null, 2)}`);
        
        // Get the current item's uuid associations
        return this.findByIndex({
          tableName: this.fullTableName,
          index: currIndex
        }).then(itemAssociations => {
          log('item associations', itemAssociations);
          
          // Write current item's uuid if nothing found
          if(itemAssociations === false) {
            log(`No previous associations...`);
            
            // Add the current item's uuid
            return buildAction('write', prevValueAction, prevItemAction, currIndex);
            
          } else {
            if (!isArray(itemAssociations)) itemAssociations = [itemAssociations];
            
            const otherItem = items[1 - i];
            const otherIndex = otherItem.index;
            const otherIndexKey = Object.keys(otherIndex)[0];
            const otherIndexValues = isArray(otherIndex[otherIndexKey]) ? otherIndex[otherIndexKey] : [otherIndex[otherIndexKey]];
            
            const itemAssociatedValues = pluck(itemAssociations, otherIndexKey);
            const alreadyAssociated = intersection(itemAssociatedValues, otherIndexValues);
            
            log(`${JSON.stringify(currIndex)}.hasMany = ${itemHasMany}`);
            log(`${JSON.stringify(currIndex)}.${otherIndexKey}s = ${JSON.stringify(itemAssociatedValues, null, 2)}`);
            log(`${otherIndexKey}.uuids = ${JSON.stringify(otherIndexValues, null, 2)}`);
            log(`alreadyAssociated = ${JSON.stringify(alreadyAssociated, null, 2)}`);
            
            // Preserve these associations
            if(itemHasMany === true) {
              log(`${index}.hasMany: Preserving previous association(s).`);
              
              if(alreadyAssociated.length === otherIndexValues.length) {
                return buildAction('skip', prevValueAction, prevItemAction, currIndex);
              } else {
                return buildAction('write', prevValueAction, prevItemAction, currIndex);
              }
              
            } else {
              log(`Deleting previous association.`);
              log(`currIndex: ${JSON.stringify(currIndex)}`);
              
              return this.destroy({
                index: currIndex
              }).then((res) => {
                if(res === false) return Promise.reject(`Could not destroy association ${JSON.stringify(index)}`);
                
                log(`Destroyed ${JSON.stringify(currIndex)}`);
                
                return buildAction('write', prevValueAction, prevItemAction, currIndex);
              })
            }
          }
        })
      }, true).then(action => {
        // End of uuid iteration
        // todo: Remove this then()
        log('\nEnd of uuid iteration');
        log(`Action: ${JSON.stringify(action, null, 2)}`);
        
        if(!isObject(action)) return Promise.reject(`Invalid action, should be object.`);
        
        return action;
      })
    }, true).then(action => {
      // End of item iteration
      log('\nEnd of item iteration');
      log('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~');
      log(`Action: ${JSON.stringify(action, null, 2)}`);
      
      if(!isObject(action)) return Promise.reject(`Invalid action, should be object.`);
      
      // If there are actions to write...
      if(action.write) {
        action.skip = action.skip ? action.skip : [];
        
        const writeKeys = Object.keys(action.write);
        const skipKeys = difference(Object.keys(action.skip), writeKeys);
        
        log(`writeKeys = ${writeKeys}`);
        log(`skipKeys = ${skipKeys}`);
        
        const writeItems = writeKeys.map(item => {
          return {index: item, uuids: action.write[item]};
        }).concat(skipKeys.map(item => {
          return {index: item, uuids: action.skip[item]};
        }));
        
        log(`keys = ${JSON.stringify(writeItems, null ,2)}`);
        
        if(Object.keys(writeItems).length !== 2) return Promise.reject(`Attempting to assign with only one item.`);
        
        log(`\nAbout to write associations...`);
        
        // TODO: Use batchWrite
        // TODO: Critical! Watch for failed requests
        return iterateArrayOverPromise(writeItems[0].uuids, item0uuid => {
          return iterateArrayOverPromise(writeItems[1].uuids, item1uuid => {
            return this.create({
              fields: {
                [writeItems[0].index]: item0uuid,
                [writeItems[1].index]: item1uuid
              }
            }).then(res => true)
          }, true)
        }, true)
      } else {
        return false;
      }
    }).catch(err => Promise.reject(`PyropeActions#associate() > ${err}`));
  };
  
  
  /**
   * Dissociates two items if an association is found
   *
   * item[0].uuid should be a scalar
   * item[1].uuid can be an array of associations to remove.
   *
   * ```
   * opts = {
 *   tableName: String,
 *   items: [
 *     {
 *       index: {indexName: any}, // index values should be a scalar
 *     },
 *     {
 *       index: {indexName: [any]}, // index values can be an array
 *     }
 *   ]
 * }
   * ```
   *
   * @param {Object} opts The options object
   * @return {Promise<Boolean>} true if the association was removed, false if there is no association
   *
   * @todo: Handle unprocessed items. (critical)
   *
   */
  dissociate( opts ) {
    if(!isObject(opts)) return Promise.reject(`PyropeActions#dissociate() : 'opts' should be an object.`);
    
    const { items } = opts;
    
    let deleteRequests = [];
    
    // Validations
    if(items === undefined || !isArray(items)) return Promise.reject(`PyropeActions#dissociate() : 'items' is undefined or not an array.`);
    // if(tableName === undefined) return Promise.reject(`PyropeActions#dissociate() : 'tableName' is undefined or not a string.`);
    if(items.length !== 2) return Promise.reject(`PyropeActions#dissociate() : 'items' should have 2 elements.`);
    if(items[0].index === undefined || items[1].index === undefined) return Promise.reject(`PyropeActions#dissociate() : 'items' should have an index.`);
    
    const index = items[0].index;
    const indexKey = Object.keys(index)[0];
    const indexValues = index[indexKey];
    
    const otherIndex = items[1].index;
    const otherIndexKey = Object.keys(otherIndex)[0];
    const otherIndexValues =
      isEmpty(otherIndex[otherIndexKey]) ?
        null :
        isArray(otherIndex[otherIndexKey]) ?
          otherIndex[otherIndexKey] :
          [otherIndex[otherIndexKey]];
    
    if(isArray(indexValues)) return Promise.reject(`PyropeActions#dissociate() : items[0] index value (uuid) cannot be an array.`);
    
    log(`Dissociating ${JSON.stringify(opts, null, 2)}`);
    
    return this.findByIndex({
      tableName: this.fullTableName,
      index
    }).then(res => {
      if(res === false) return Promise.resolve(false); // No association found
      
      log(`${index} associations = ${JSON.stringify(res, null, 2)}`);
      
      let notFound = [];
      
      // Build deleteRequests object
      if(otherIndexValues === null){
        log(`other index lvalues is null for ${otherIndexKey}`);
        
        deleteRequests = res.map(currItem => ({DeleteRequest: {Key: {uuid: currItem.uuid, createdAt: currItem.createdAt}}}));
      } else {
        deleteRequests = res.filter(currItem => {
          let flag = false;
          
          otherIndexValues.forEach(val => {
            flag = flag || (currItem[otherIndexKey] === val)
          });
          
          return flag;
        });
        
        notFound = difference(otherIndexValues, pluck(deleteRequests, otherIndexKey));
        deleteRequests = deleteRequests.map(currItem => ({DeleteRequest: {Key: {uuid: currItem.uuid, createdAt: currItem.createdAt}}}));
      }
      
      if(notFound.length > 0) return Promise.reject(`${otherIndexKey}[${notFound}] is not associated to ${indexKey}[${indexValues}]`);
      if(deleteRequests.length === 0) return Promise.resolve(false);
      
      const params = {
        RequestItems: {
          [this.fullTableName]: deleteRequests
        }
      };
      
      return Promise.resolve(params);
    })
      .then(params => {
        if(params) {
          return ddbClient('batchWrite', params)
            .then(res => {
              if (res.UnprocessedItems && Object.keys(res.UnprocessedItems).length > 0) return Promise.reject('dissociate() batch: Warning, unprocessed items.' + JSON.stringify(res.UnprocessedItems, null, 2));
            })
            .then(() => this.updateCounter({
              step: -1 * deleteRequests.length
            }))
            .then(() => Promise.resolve(true))
        } else {
          return Promise.resolve(false);
        }
      })
      .catch(err => Promise.reject(`PyropeActions#dissociate() > ${err}`))
  };
  
  /**
   *  Returns the associations of the specified item with respect to another item
   *
   * ```
   *  opts = {
 *    tableName: String,
 *    items: [
 *      {
 *        index: {key: value}
 *      },
 *      {
 *        index: String
 *      }
 *    ]
 *  }
   *  ```
   *
   *  @todo accept query expressions
   *  @params {Object} opts The options object.
   *  @return {Promise} - Array of uuids, [] if no assoc. found
   */
  getAssociations( opts ) {
    return new Promise((resolve, reject) => {
      if(!isObject(opts)) return reject(`PyropeActions#getAssociations() : 'opts' should be an object.`);
    
      const { items } = opts;
    
      if(items === undefined || !isArray(items)) return reject(`PyropeActions#getAssociations() : 'items' is undefined or not an array.`);
      // if(tableName && (tableName === undefined || !isString(tableName))) return reject(`PyropeActions#getAssociations() : 'tableName' is undefined or not a string.`);
      if(items.length !== 2) return reject(`PyropeActions#getAssociations() : 'items' should have 2 elements.`);
      if(!isObject(items[0].index)) return reject(`PyropeActions#getAssociations() : items[0].index should be an object.`);
      if(!isString(items[1].index)) return reject(`PyropeActions#getAssociations() : items[1].index should be a string.`);
    
      const indexKey = Object.keys(items[0].index)[0];
      const indexValue = items[0].index[indexKey];
    
      this.findByIndex({
        tableName: this.fullTableName,
        index: {[indexKey]: indexValue}
      })
        .then(res => resolve(pluck(res, items[1].index))) // todo: use query expressions instead
        .catch(err => reject(`PyropeActions#getAssociations() > ${err}`));
    });
  }
}