Home Reference Source Repository

lib/models.js

import * as pyrope from './index';
import { filter, isArray, isObject, sortBy, find, isString, mapObject, omit, each, isFunction } from 'underscore';
import Inflector from 'inflected';
import { buildAssociationTableName, buildItemIndices } from './utils';

import { PyropeActions } from './index';

const DEBUG = false;

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

/**
 * PyropeModel ORM
 */
export default class PyropeModel {
  static name;
  static humanName;
  static fields;
  static tablePrefix;
  static tableName;
  static tableSuffix;
  static fullTableName;
  static validations;
  static defaultQueryKey;
  static actions;
  
  /**
   * PyropeModel#constructor
   *
   * Generates a new PyropeModel object.
   * Useful for interacting with DynamoDB with ease.
   *
   * @param {GraphQLObjectType} schema - The GraphQL Object Type.
   * @param {object} opts - The options object.
   * @param {string} opts.name - Custom name (used to calculate table names).
   * @param {string} opts.humanName - Custom name for errors.
   * @param {string} opts.tablePrefix - Custom table to query from.
   * @param {string} opts.tableName - Custom table to query from.
   * @param {string} opts.tableSuffix - Custom table to query from.
   * @param {function(fields: object, fieldName: string): object} opts.validations - Validation function, should resolve the [modified] fields.
   */
  constructor(schema, opts = {}) {
    if(!schema) throw new Error(`PyropeModel#constructor(): 'schema' is undefined.`);
    if(!schema.name) throw new Error(`PyropeModel#constructor(): 'schema.name' is undefined.`);
    if(!schema._typeConfig.fields) throw new Error(`PyropeModel#constructor(): 'schema.fields' is undefined.`);
    
    this.name = opts.name || schema.name;
    this.humanName = Inflector.humanize(this.name);
    this.fields = schema._typeConfig.fields();
    this.defaultQueryKey = 'uuid';  // opts.defaultQueryKey || 'uuid'; // todo: implement defaultQueryKey
    this.validations = opts.validations;
    
    this.tablePrefix = opts.tablePrefix || '';
    this.tableSuffix = opts.tableSuffix || '';
    this.tableName = opts.tableName || Inflector.tableize(schema.name);
    this.fullTableName = this.tablePrefix + this.tableName + this.tableSuffix;
    
    this.actions = new PyropeActions({tablePrefix: this.tablePrefix, tableName: this.tableName, tableSuffix: this.tableSuffix})
  }
  
  /**
   * Retrieve a record given an index
   *
   * @param {object} index - The index used to query the record.
   * @param {any} index.key - The index key used to query the record.
   * @param {any} index.value - The index value used to query the record.
   * @returns {Promise<object|boolean>} - The retrieved record map. False if record is not found.
   */
  get(index) {
    return new Promise((resolve, reject) => {
      if(!index || !isObject(index)) return reject(`PyropeModel#get(): 'index' is undefined or not an object.`);
      
      this.actions.findByIndex({
        index
      })
        .then(records => {
          if(records === false) {
            resolve(false);
          } else if(isArray(records) && records.length > 1) {
            reject('PyropeModel#get(): More than one item with the specified index were found.');
          } else {
            resolve(records[0])
          }
        })
        .catch(err => reject(`PyropeModel#get() > ${err}`));
    });
  }
  
  /**
   * Retrieve all records
   *
   * @param {object} opts - Options object.
   * @param {string} opts.order - Sort by 'asc' or 'desc'
   * @param {number} opts.limit - The amount of records to retrieve.
   * @param {string} opts.cursor - The cursor from which the query continues.
   * @returns {Promise<Array<object>>} - An array with the maps of the retrieved objects. Empty array if none is found.
   */
  getAll(opts = {}) {
    return new Promise((resolve, reject) => {
      this.actions.all(opts)
        .then(records => {
          if(records.Cursor) {
            records.Items[records.Items.length-1].cursor = records.Cursor; // assign cursor to the last item
          }
      
          resolve(records.Items)
        })
        .catch(err => reject(`PyropeModel#getAll() > ${err}`));
    });
  }
  
  /**
   * Create a new record
   *
   * (!) It does not check if a record with the provided index previously exists.
   *
   * @param {object} fields - Fields to the new record.
   * @param {object} opts - Options object.
   * @param {function(fields: object, fieldName: string): object} opts.beforeValidation - beforeValidation hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.afterValidation - afterValidation hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.beforeCreate - beforeCreate hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.afterCreate - afterCreate hook, should return the [modified] fields.
   * @param {string} opts.fieldName - fieldName. Passed by the resolver function from GraphQL. ie. the name of the Query or Mutation.
   * @returns {Promise<object>} - A promise that resolves to the newly created record.
   */
  create(fields = {}, opts = {}) {
    return new Promise((resolve, reject) => {
      if(!fields || !isObject(fields)) return reject(`PyropeModel#create(): 'fields' is undefined or not an object.`);
      if(opts && !isObject(opts)) return reject(`PyropeModel#create(): 'opts' is not an object.`);
      
      const {
        beforeValidation,
        afterValidation,
        beforeCreate,
        afterCreate,
        fieldName
      } = opts;
      
      const emptyHook = (fields) => new Promise((resolve, reject) => resolve(fields));
      
      const createHook = (fields, fieldName) => new Promise((res, rej) => {
        this.actions.create({fields})
        .then(record => res(record))
        .catch(err => rej(`createHook() > ${err}`));
      });
  
      let hookChain = [];
      
      hookChain.push(beforeValidation || emptyHook);
      hookChain.push(this.validations || emptyHook);
      hookChain.push(afterValidation  || emptyHook);
      hookChain.push(beforeCreate     || emptyHook);
      hookChain.push(createHook);
      hookChain.push(afterCreate      || emptyHook);
  
      // todo: check if validations is a Promise
      hookChain.reduce((prevPromise, currPromise) => prevPromise
        .then(_fields => currPromise(_fields, fieldName)), Promise.resolve(fields))
        .then(record => resolve(record))
        .catch(err => reject(`PyropeModel#create() > ${err}`));
    });
  }
  
  /**
   * Update a record
   *
   * To set or unset child/children, pass in the fields the setContact/setContacts/unsetContact/unsetContacts argument.
   *
   * It will look in the schema for fields that are a scalar Object Type or GraphQLList with the given name.
   *
   * You should use these arguments in your GraphQL query.
   *
   * * **setRecord** accepts a scalar value of the uuid and calls this.setChild()
   * * **setRecords** accepts an array of uuids and calls this.setChildren()
   * * **unsetRecord** accepts an empty string and calls this.unsetChild()
   * * **unsetRecords** accepts an array and null for the uuid and calls this.unsetChildren()
   * * (!) When empty is passed, **all** the records are dissociated.
   *
   * _Record_ being the name of the associated field.
   *
   * @example
   *
   *  mutation {
   *    updateUser(
   *      uuid: "123"
   *      setContact: "contact-uuid"
   *    ) {
   *      uuid
   *      contact {
   *        uuid
   *      }
   *    }
   *  }
   *
   * @example
   *
   *  mutation {
   *    updateUser(
   *      uuid: "123"
   *      setContacts: ["contact-uuid-1", "contact-uuid-2"]
   *    ) {
   *      uuid
   *      contact {
   *        uuid
   *      }
   *    }
   *  }
   *
   *  @example
   *
   *  mutation {
   *    updateUser(
   *      uuid: "123"
   *      unsetContact: ""
   *    ) {
   *      uuid
   *      contact {
   *        uuid
   *      }
   *    }
   *  }
   *
   *  @example
   *
   *  mutation {
   *    updateUser(
   *      uuid: "123"
   *      unsetContacts: ["contact-uuid-1", "contact-uuid-2"]
   *    ) {
   *      uuid
   *      contact {
   *        uuid
   *      }
   *    }
   *  }
   *
   *  @example
   *
   *  mutation {
   *    updateUser(
   *      uuid: "123"
   *      unsetContacts: ""
   *    ) {
   *      uuid
   *      contact {
   *        uuid
   *      }
   *    }
   *  }
   *
   * @param {object} index - The index used to query the record.
   * @param {any} index.key - The index key used to query the record.
   * @param {any} index.value - The index value used to query the record.
   * @param {object} fields - The map to the fields to update.
   * @param {object} opts - Options object.
   * @param {function(fields: object, fieldName: string): object} opts.beforeValidation - beforeValidation hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.afterValidation - afterValidation hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.beforeUpdate - beforeUpdate hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.afterUpdate - afterUpdate hook, should return the [modified] fields.
   * @param {string} opts.fieldName - fieldName. Passed by the resolver function from GraphQL. ie. the name of the Query or Mutation.
   * @returns {Promise<object|boolean>} - A promise tha resolves to the updated record fields. Promise resolves to false if the record does not exist.
   */
  update(index, fields = {}, opts = {}) {
    return new Promise((resolve, reject) => {
      if(!index || !isObject(index)) return reject(`PyropeModel#update(): 'index' is undefined or not an object.`);
      if(fields && !isObject(fields)) return reject(`PyropeModel#update(): 'fields' is not an object.`);
  
      const {
        beforeValidation,
        afterValidation,
        beforeUpdate,
        afterUpdate,
        fieldName
      } = opts;
      
      const itemIndexValue = index[Object.keys(index)[0]];
      
      let assocPromiseChain = [];
  
      const emptyHook = (fields) => new Promise((resolve, reject) => resolve(fields));
      
      const updateHook = (fields) => new Promise((res, rej) => {
        this.actions.update({
          index,
          fields
        })
          .then(record => {
            if(record === false) {
              return res(false);
            } else {
              const indexKey = Object.keys(index)[0];
              const newIndex = {
                [indexKey]: record[indexKey]
              };
        
              return res(this.get(newIndex)); // todo: optimize. Get is needed in order to retieve nested fields.
            }
          })
          .catch(err => rej(`updateHook() > ${err}`));
      });
      
      const preAssociationsHook = (fields) => {
        let fieldsToRemove = [];
        
        mapObject(fields, (val, key) => {
          let associationKey;
          let associationHasMany = false;
          
          // todo: refactor
          if(key.startsWith('set')) {
            associationKey = key.substr(3, key.length - 3).toLocaleLowerCase();
            fieldsToRemove.push(key);
            
            if(find(Object.keys(this.fields), k => k === associationKey)) {
              associationHasMany = this.fields[associationKey].hasMany;
  
              if (associationHasMany) {
                assocPromiseChain.push(this.setChildren(itemIndexValue, {[associationKey]: val}))
              } else {
                assocPromiseChain.push(this.setChild(itemIndexValue, {[associationKey]: val}))
              }
            }
          } else if(key.startsWith('unset')) {
            associationKey = key.substr(5, key.length - 5).toLocaleLowerCase();
            fieldsToRemove.push(key);

            if(find(Object.keys(this.fields), k => k === associationKey)) {
              associationHasMany = this.fields[associationKey].hasMany;

              if (associationHasMany) {
                assocPromiseChain.push(this.unsetChildren(itemIndexValue, {[associationKey]: val}))
              } else {
                assocPromiseChain.push(this.unsetChild(itemIndexValue, associationKey))
              }
            }
          }
        });
        
        return Promise.resolve(omit(fields, fieldsToRemove));
      };
      
      const postAssociationsHook = (fields) => new Promise((res, rej) => {
        Promise.all(assocPromiseChain)
          .then(() => res(fields))
          .catch(err => rej(err))
      });
      
      let hookChain = [];
  
      hookChain.push(beforeValidation || emptyHook);
      hookChain.push(this.validations || emptyHook);
      hookChain.push(afterValidation  || emptyHook);
      hookChain.push(beforeUpdate     || emptyHook);
      hookChain.push(preAssociationsHook);
      hookChain.push(updateHook);
      hookChain.push(postAssociationsHook);
      hookChain.push(afterUpdate      || emptyHook);
  
      hookChain.reduce((prevPromise, currPromise) => prevPromise
        .then(_fields => currPromise(_fields, fieldName, index)), Promise.resolve(fields))
        .then(record => resolve(record))
        .catch(err => reject(`PyropeModel#update() > ${err}`));
    });
  }
  
  /**
   * Destroys a record
   *
   * If the schema (GraphQLObjectTYpe) specifies fields with dependent = 'destroy' || 'nullify'
   * the associated records will be either destroyed or dissociated (nullified)
   *
   * @param {object} index - The index used to query the record.
   * @param {any} index.key - The index key used to query the record.
   * @param {any} index.value - The index value used to query the record.
   * @param {object} opts - Options object.
   * @param {function(fields: object, fieldName: string): object} opts.beforeValidation - beforeValidation hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.afterValidation - afterValidation hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.beforeDestroy - beforeDestroy hook, should return the [modified] fields.
   * @param {function(fields: object, fieldName: string): object} opts.afterDestroy - afterDestroy hook, should return the [modified] fields.
   * @param {string} opts.fieldName - fieldName. Passed by the resolver function from GraphQL. ie. the name of the Query or Mutation.
   * @returns {Promise<object|boolean>} - The object of the destroyed record. False if the record was not found.
   */
  destroy(index, opts = {}) {
    return new Promise((resolve, reject) => {
      if(!index || !isObject(index)) return reject(`PyropeModel#destroy(): 'index' is undefined or not an object.`);
    
      const {
        beforeValidation,
        afterValidation,
        beforeDestroy,
        afterDestroy,
        fieldName
      } = opts;
      
      const emptyHook = (fields) => new Promise((resolve, reject) => resolve(fields));
      
      const deleteHook = (fields) => new Promise((res, rej) => {
        this.actions.destroy({
          index
        })
          .then(record => {
            if(record === false) {
              return res(false);
            } else {
              return res(record)
            }
          })
          .catch(err => rej(`destroyHook() > ${err}`));
      });
      
      const updateAssociationsHook = (fields) => new Promise((resolve, reject) => {
        let destroyAssocKeys = (filter(this.fields, f => f.dependent === 'destroy'));
        let nullifyAssocKeys = (filter(this.fields, f => f.dependent === 'nullify' || f.dependent === 'destroy')); // todo: watch behavior

        const parentIndexKey = Inflector.underscore(Inflector.singularize(this.name));
        const parentIndexValue = index[Object.keys(index)[0]];
  
        destroyAssocKeys = destroyAssocKeys.map(i => {
          if(i.type.ofType) {
            return Inflector.underscore(Inflector.singularize(i.type.ofType.name));
          } else {
            return Inflector.underscore(Inflector.singularize(i.type.name));
          }
        });
        
        nullifyAssocKeys = nullifyAssocKeys.map(i => {
          if(i.type.ofType) {
            return Inflector.underscore(Inflector.singularize(i.type.ofType.name));
          } else {
            return Inflector.underscore(Inflector.singularize(i.type.name));
          }
        });
        
        const destroyPromise = () => new Promise((res, rej) => {
          destroyAssocKeys.reduce((prevPromise, currKey, index, arr) =>
            prevPromise.then(() => {
              const associationTableName = buildAssociationTableName(this.tableName, currKey);
              const associationActions = new PyropeActions({tablePrefix: this.tablePrefix, tableName: associationTableName, tableSuffix: this.tableSuffix});
              
              return associationActions.getAssociations({
                items: [
                  {index: {[parentIndexKey]: parentIndexValue}},
                  {index: currKey}
                ]
              })
                .then((assoc) => {
                  if(assoc.length > 0) {
            
                    return assoc.reduce((prevPromise, currAssoc, index, arr) =>
                        prevPromise.then(() => {
                          // todo: refactor with batchWrite ?
                          const destroyAction = new PyropeActions({tablePrefix: this.tablePrefix, tableName: Inflector.tableize(currKey), tableSuffix: this.tableSuffix});
                          
                          return destroyAction.destroy({
                            index: {uuid: currAssoc}
                          })
                        })
              
                      , Promise.resolve());
                  } else {
                    return Promise.resolve();
                  }
                })
                .catch(err => Promise.reject(err));
            })
          , Promise.resolve())
            .then(() => res())
            .catch(err => rej(err));
        });
        
        const nullifyPromise = () => new Promise((res, rej) => {
          nullifyAssocKeys.reduce((prevPromise, currKey, index, arr) =>
            prevPromise.then(() => this.unsetChildren(parentIndexValue, {[currKey]: null})
              .then(() => Promise.resolve())
              .catch(err => Promise.reject(err)))
          , Promise.resolve())
            .then(() => res())
            .catch(err => rej(err));
        });
        
        let promiseArray = [];
        
        if(destroyAssocKeys.length > 0) promiseArray.push(destroyPromise);
        if(nullifyAssocKeys.length > 0) promiseArray.push(nullifyPromise);
        
        promiseArray.reduce((prevPromise, currPromise) => prevPromise
          .then(res => currPromise()), Promise.resolve())
          .then(res => resolve(fields))
          .catch(err => reject(err));
      });
    
      let hookChain = [];
    
      hookChain.push(beforeValidation || emptyHook);
      hookChain.push(this.validations || emptyHook);
      hookChain.push(afterValidation  || emptyHook);
      hookChain.push(beforeDestroy    || emptyHook);
      hookChain.push(deleteHook);
      hookChain.push(updateAssociationsHook);
      hookChain.push(afterDestroy     || emptyHook);
    
      hookChain.reduce((prevPromise, currPromise) => prevPromise
        .then(_fields => currPromise(_fields, fieldName, index)), Promise.resolve(this.get(index)))
        .then(record => resolve(record))
        .catch(err => reject(`PyropeModel#destroy() > ${err}`));
    });
  }
  
  /**
   * Retrieves the record count given the Model's table
   *
   * @returns {Promise<number>} The record count.
   */
  count() {
    return new Promise((resolve, reject) => {
      this.actions.count()
        .then(count => resolve(count))
        .catch(err => reject(`PyropeModel#count() > ${err}`));
    })
  }
  
  /**
   * Get a child (1:1)
   *
   * @param {any} uuid - The index value to query the parent.
   * @param {string} childIndexKey - The index key used to query the child.
   * @param {function(source: object): any} childResolver - Resolver function.
   * @returns {Promise<object|null>} - An object with the record's fields, null if not found.
   */
  getChild(uuid, childIndexKey, childResolver) {
    return new Promise((resolve, reject) => {
      if(!uuid || !isString(uuid)) return reject(`PyropeModel#getChild(): 'uuid' is undefined or not a string.`);
      if(!childIndexKey || !isString(childIndexKey)) return reject(`PyropeModel#getChild(): 'childIndexKey' is undefined or not a string.`);
      if(!isFunction(childResolver)) return reject(`PyropeModel#getChild(): 'childResolver' is not a function.`);
  
      const parentIndexKey = Inflector.underscore(Inflector.singularize(this.name));
      childIndexKey = Inflector.underscore(Inflector.singularize(childIndexKey));
      
      const tableName = buildAssociationTableName(this.tableName, childIndexKey);
      const associationActions = new PyropeActions({tablePrefix: this.tablePrefix, tableName, tableSuffix: this.tableSuffix});
  
      associationActions.getAssociations({
        items: [
          {index: {[parentIndexKey]: uuid}},
          {index: childIndexKey}
        ]
      })
        .then(associations => {
          if(associations.length > 1) {
            reject(`PyropeModel#getChild(): Expected only one association.`);
          } else if(associations.length === 0) {
            resolve(null);
          } else {
            return childResolver({uuid: associations[0]})
          }
        })
        .then(record => resolve(record))
        .catch(err => reject(`PyropeModel#getChild() > ${err}`))
    });
  }
  
  /**
   * Associates a child (1:1)
   *
   * @param {string} uuid - The index value to query the parent.
   * @param {object} childIndex - The index of the child to associate.
   * @param {string} childIndex.key - The index key of the child to associate.
   * @param {string} childIndex.value - The index value of the child to associate.
   * @returns {Promise<boolean>} - True if the association was made, false otherwise.
   */
  setChild(uuid, childIndex) {
    return new Promise((resolve, reject) => {
      if(!uuid) return reject(`PyropeModel#setChild(): 'uuid' is undefined.`);
      if(!childIndex || !isObject(childIndex)) return reject(`PyropeModel#setChild(): 'index' is undefined or not an object.`);
      if(Object.keys(childIndex).length > 1) return reject(`PyropeModel#setChild(): 'index' should have only one key-value pair.`);
  
      const childIndexKey = Inflector.underscore(Inflector.singularize(Object.keys(childIndex)[0]));
      
      let items;
      
      try {
        items = buildItemIndices(uuid, childIndex, this.name, this.fields);
      } catch(err) {
        return reject(`PyropeModel#setChild() > ${err}`);
      }
  
      const tableName = buildAssociationTableName(this.tableName, childIndexKey);
      const associationActions = new PyropeActions({tablePrefix: this.tablePrefix, tableName, tableSuffix: this.tableSuffix});
  
      associationActions.associate({items})
        .then(() => resolve(true))
        .catch(err => reject(`PyropeModel#setChild() > ${err}`))
    });
  }
  
  /**
   * Dissociates a child (1:1)
   *
   * @param {any} uuid - The index value to query the parent.
   * @param {string} childIndexKey - The index key used to query the child.
   * @returns {Promise<boolean>} - True if successful, false otherwise.
   */
  unsetChild(uuid, childIndexKey) {
    return new Promise((resolve, reject) => {
      if(!uuid) return reject(`PyropeModel#unsetChild(): 'uuid' is undefined.`);
      if(!childIndexKey || !isString(childIndexKey)) return reject(`PyropeModel#getChild(): 'childIndexKey' is undefined or not a string.`);
    
      // todo: check if singularization affects the lookup of the Schema's field name when its singular
      childIndexKey = Inflector.underscore(Inflector.singularize(childIndexKey));
  
      const parentIndexKey = Inflector.underscore(Inflector.singularize(this.name));
      const parentIndexValue = uuid;
  
      const tableName = buildAssociationTableName(this.tableName, childIndexKey);
      const associationActions = new PyropeActions({tablePrefix: this.tablePrefix, tableName, tableSuffix: this.tableSuffix})
  
      associationActions.getAssociations({
        items: [
          {index: {[parentIndexKey]: parentIndexValue}},
          {index: childIndexKey}
        ]
      })
        .then(associations => {
          if(associations.length > 1) {
            reject(`PyropeModel#unsetChild(): Expected only one association.`);
          } else if(associations.length === 0) {
            return Promise.resolve(true);
          } else {
            return Promise.resolve(associations[0]);
          }
        })
        .then(assoc => {
          if(assoc === true) {
            return Promise.resolve(assoc);
          } else {
            return associationActions.dissociate({
              items: [
                {index: {[parentIndexKey]: parentIndexValue}},
                {index: {[childIndexKey]: assoc}}
              ]
            })
          }
        })
        .then((res) => resolve(res))
        .catch(err => reject(`PyropeModel#unsetChild() > ${err}`))
    });
  };
  
  /**
   * Gets the associated children (1:N, N:N)
   *
   * @param {any} uuid - The index value to query the parent item.
   * @param {string} childIndexKey - The index key used to query the children.
   * @param {function(source: object): any} childResolver - Resolver function.
   * @returns {Promise<array>} - An array with the associated records.
   */
  getChildren(uuid, childIndexKey, childResolver) {
    return new Promise((resolve, reject) => {
      if(!uuid) return reject(`PyropeModel#getChildren(): 'uuid' is undefined.`);
      if(!childIndexKey || !isString(childIndexKey)) return reject(`PyropeModel#getChildren(): 'childIndexKey' is undefined or not a string.`);
    
      const parentIndexKey = Inflector.underscore(Inflector.singularize(this.name));
      childIndexKey = Inflector.underscore(Inflector.singularize(childIndexKey));
      
      const tableName = buildAssociationTableName(this.tableName, childIndexKey);
      const associationActions = new PyropeActions({tablePrefix: this.tablePrefix, tableName, tableSuffix: this.tableSuffix});
  
      associationActions.getAssociations({
        items: [
          {index: {[parentIndexKey]: uuid}},
          {index: childIndexKey}
        ]
      })
        .then(associations => {
          if(associations.length === 0) {
            resolve([]);
          } else {
            let children = [];

            // todo: optimize reduction
            resolve(associations.reduce((prevPromise, uuid, index) =>
                prevPromise.then(res => {
                  if(res) children.push(res);

                  // todo: check for promise
                  return childResolver({uuid}).then((res) => {
                    if(index === associations.length-1) {
                      children.push(res);
                      return Promise.resolve(children)
                    } else {
                      return Promise.resolve(res);
                    }
                  });
                })
              , Promise.resolve()));
          }
        })
        .catch(err => reject(`PyropeModel#getChildren() > ${err}`))
    });
  }
  
  /**
   * Associates the specified children (1:N, N:N)
   *
   * @param {string} uuid - The index value to query the parent item.
   * @param {object} childIndex - The index of the children to associate.
   * @param {string} childIndex.key - The index key of the children to associate.
   * @param {string[]|string} childIndex.value - The index value[s] of the children to associate. Can be an array of uuids.
   * @returns {Promise<boolean>} - True if the association was made, false otherwise.
   */
  setChildren(uuid, childIndex) {
    return new Promise((resolve, reject) => {
      if(!uuid) return reject(`PyropeModel#setChildren(): 'uuid' is undefined.`);
      if(!childIndex || !isObject(childIndex)) return reject(`PyropeModel#setChildren(): 'index' is undefined or not an object.`);
      if(Object.keys(childIndex).length > 1) return reject(`PyropeModel#setChildren(): 'index' should have only one key-value pair.`);
  
      const childIndexKey = Inflector.underscore(Inflector.singularize(Object.keys(childIndex)[0]));
      
      const tableName = buildAssociationTableName(this.tableName, childIndexKey);
      const associationActions = new PyropeActions({tablePrefix: this.tablePrefix, tableName, tableSuffix: this.tableSuffix});
      
      let items;
      
      try {
        items = buildItemIndices(uuid, childIndex, this.name, this.fields);
      } catch(err) {
        return reject(`PyropeModel#setChildren() > ${err}`);
      }
      
      associationActions.associate({
        items
      })
        .then(res => resolve(res))
        .catch(err => reject(`PyropeModel#setChildren() > ${err}`))
    });
  }
  
  /**
   * Dissociates children (1:N, N:N)
   *
   * @param {any} uuid - The index value to query the parent item.
   * @param {object} childIndex - The index used to query the children to dissociate. If empty is passed, all children are dissociated.
   * @param {string} childIndex.key - The index key used to query the children to dissociate. If empty is passed, all children are dissociated.
   * @param {<array<string>>|string|empty} childIndex.value - The index value used to query the children to dissociate. If empty is passed, all children are dissociated.
   * @returns {Promise<boolean>} - True if successful, false otherwise.
   */
  unsetChildren(uuid, childIndex) {
    return new Promise((resolve, reject) => {
      if(!uuid) return reject(`PyropeModel#unsetChildren(): 'uuid' is undefined.`);
      if(!childIndex || !isObject(childIndex)) return reject(`PyropeModel#unsetChildren(): 'index' is undefined or not an object.`);
      if(Object.keys(childIndex).length > 1) return reject(`PyropeModel#unsetChildren(): 'index' should have only one key-value pair.`);
    
      // todo: check if singularization affects the lookup of the Schema's field name when it's singular
      const childIndexValue = childIndex[Object.keys(childIndex)[0]];
      const childIndexKey = Inflector.underscore(Inflector.singularize(Object.keys(childIndex)[0]));
    
      const parentIndexKey = Inflector.underscore(Inflector.singularize(this.name));
      const parentIndexValue = uuid;
      
      const tableName = buildAssociationTableName(this.tableName, childIndexKey);
      const associationActions = new PyropeActions({tablePrefix: this.tablePrefix, tableName, tableSuffix: this.tableSuffix});
      
      associationActions.dissociate({
        items: [
          {index: {[parentIndexKey]: parentIndexValue}},
          {index: {[childIndexKey]: childIndexValue}}
        ]
      })
        .then(res => resolve(res))
        .catch(err => reject(`PyropeModel#unsetChildren() > ${err}`))
    });
  }
}