balderdashy/waterline

View on GitHub
lib/waterline/methods/replace-collection.js

Summary

Maintainability
F
5 days
Test Coverage
/**
 * Module dependencies
 */

var assert = require('assert');
var _ = require('@sailshq/lodash');
var flaverr = require('flaverr');
var parley = require('parley');
var buildOmen = require('../utils/query/build-omen');
var forgeStageTwoQuery = require('../utils/query/forge-stage-two-query');
var getQueryModifierMethods = require('../utils/query/get-query-modifier-methods');
var verifyModelMethodContext = require('../utils/query/verify-model-method-context');


/**
 * Module constants
 */

var DEFERRED_METHODS = getQueryModifierMethods('replaceCollection');



/**
 * replaceCollection()
 *
 * Replace all members of the specified collection in each of the target record(s).
 *
 * ```
 * // For users 3 and 4, change their "pets" collection to contain ONLY pets 99 and 98.
 * User.replaceCollection([3,4], 'pets', [99,98]).exec(...);
 * ```
 *
 * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 *
 * Usage without deferred object:
 * ================================================
 *
 * @param {Array?|String?|Number?} targetRecordIds
 *
 * @param {String?} collectionAttrName
 *
 * @param {Array?} associatedIds
 *
 * @param {Function?} explicitCbMaybe
 *        Callback function to run when query has either finished successfully or errored.
 *        (If unspecified, will return a Deferred object instead of actually doing anything.)
 *
 * @param {Ref?} meta
 *     For internal use.
 *
 * @returns {Ref?} Deferred object if no `explicitCbMaybe` callback was provided
 *
 * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 *
 * The underlying query keys:
 * ==============================
 *
 * @qkey {Array|String|Number} targetRecordIds
 *     The primary key value(s) (i.e. ids) for the parent record(s).
 *     Must be a number or string; e.g. '507f191e810c19729de860ea' or 49
 *     Or an array of numbers or strings; e.g. ['507f191e810c19729de860ea', '14832ace0c179de897'] or [49, 32, 37]
 *     If an empty array (`[]`) is specified, then this is a no-op.
 *
 * @qkey {String} collectionAttrName
 *     The name of the collection association (e.g. "pets")
 *
 * @qkey {Array} associatedIds
 *     The primary key values (i.e. ids) for the child records that will be the new members of the association.
 *     Must be an array of numbers or strings; e.g. ['334724948aca33ea0f13', '913303583e0af031358bac931'] or [18, 19]
 *     Specify an empty array (`[]`) to completely wipe out the collection's contents.
 *
 * @qkey {Dictionary?} meta
 * @qkey {String} using
 * @qkey {String} method
 *
 * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 */

module.exports = function replaceCollection(/* targetRecordIds?, collectionAttrName?, associatedIds?, explicitCbMaybe?, meta? */) {

  // Verify `this` refers to an actual Sails/Waterline model.
  verifyModelMethodContext(this);

  // Set up a few, common local vars for convenience / familiarity.
  var WLModel = this;
  var orm = this.waterline;
  var modelIdentity = this.identity;

  // Build an omen for potential use in the asynchronous callback below.
  var omen = buildOmen(replaceCollection);

  // Build query w/ initial, universal keys.
  var query = {
    method: 'replaceCollection',
    using: modelIdentity
  };


  //  ██╗   ██╗ █████╗ ██████╗ ██╗ █████╗ ██████╗ ██╗ ██████╗███████╗
  //  ██║   ██║██╔══██╗██╔══██╗██║██╔══██╗██╔══██╗██║██╔════╝██╔════╝
  //  ██║   ██║███████║██████╔╝██║███████║██║  ██║██║██║     ███████╗
  //  ╚██╗ ██╔╝██╔══██║██╔══██╗██║██╔══██║██║  ██║██║██║     ╚════██║
  //   ╚████╔╝ ██║  ██║██║  ██║██║██║  ██║██████╔╝██║╚██████╗███████║
  //    ╚═══╝  ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝╚═╝  ╚═╝╚═════╝ ╚═╝ ╚═════╝╚══════╝
  //
  // Handle the various supported usage possibilities
  // (locate the `explicitCbMaybe` callback, and extend the `query` dictionary)

  // The `explicitCbMaybe` callback, if one was provided.
  var explicitCbMaybe;

  // Handle the various supported usage possibilities
  // (locate the `explicitCbMaybe` callback)
  //
  // > Note that we define `args` so that we can insulate access
  // > to the arguments provided to this function.
  var args = arguments;
  (function _handleVariadicUsage(){

    // The metadata container, if one was provided.
    var _meta;


    // Handle first two arguments:
    // (both of which always have exactly one meaning)
    //
    // • replaceCollection(targetRecordIds, collectionAttrName, ...)
    query.targetRecordIds = args[0];
    query.collectionAttrName = args[1];


    // Handle double meaning of third argument, & then handle the rest:
    //
    // • replaceCollection(____, ____, associatedIds, explicitCbMaybe, _meta)
    var is3rdArgArray = !_.isUndefined(args[2]);
    if (is3rdArgArray) {
      query.associatedIds = args[2];
      explicitCbMaybe = args[3];
      _meta = args[4];
    }
    // • replaceCollection(____, ____, explicitCbMaybe, _meta)
    else {
      explicitCbMaybe = args[2];
      _meta = args[3];
    }

    // Fold in `_meta`, if relevant.
    if (!_.isUndefined(_meta)) {
      query.meta = _meta;
    } // >-

  })();


  //  ██████╗ ███████╗███████╗███████╗██████╗
  //  ██╔══██╗██╔════╝██╔════╝██╔════╝██╔══██╗
  //  ██║  ██║█████╗  █████╗  █████╗  ██████╔╝
  //  ██║  ██║██╔══╝  ██╔══╝  ██╔══╝  ██╔══██╗
  //  ██████╔╝███████╗██║     ███████╗██║  ██║
  //  ╚═════╝ ╚══════╝╚═╝     ╚══════╝╚═╝  ╚═╝
  //
  //   ██╗███╗   ███╗ █████╗ ██╗   ██╗██████╗ ███████╗██╗
  //  ██╔╝████╗ ████║██╔══██╗╚██╗ ██╔╝██╔══██╗██╔════╝╚██╗
  //  ██║ ██╔████╔██║███████║ ╚████╔╝ ██████╔╝█████╗   ██║
  //  ██║ ██║╚██╔╝██║██╔══██║  ╚██╔╝  ██╔══██╗██╔══╝   ██║
  //  ╚██╗██║ ╚═╝ ██║██║  ██║   ██║   ██████╔╝███████╗██╔╝
  //   ╚═╝╚═╝     ╚═╝╚═╝  ╚═╝   ╚═╝   ╚═════╝ ╚══════╝╚═╝
  //
  //  ┌┐ ┬ ┬┬┬  ┌┬┐   ┬   ┬─┐┌─┐┌┬┐┬ ┬┬─┐┌┐┌  ┌┐┌┌─┐┬ ┬  ┌┬┐┌─┐┌─┐┌─┐┬─┐┬─┐┌─┐┌┬┐
  //  ├┴┐│ │││   ││  ┌┼─  ├┬┘├┤  │ │ │├┬┘│││  │││├┤ │││   ││├┤ ├┤ ├┤ ├┬┘├┬┘├┤  ││
  //  └─┘└─┘┴┴─┘─┴┘  └┘   ┴└─└─┘ ┴ └─┘┴└─┘└┘  ┘└┘└─┘└┴┘  ─┴┘└─┘└  └─┘┴└─┴└─└─┘─┴┘
  //  ┌─    ┬┌─┐  ┬─┐┌─┐┬  ┌─┐┬  ┬┌─┐┌┐┌┌┬┐    ─┐
  //  │───  │├┤   ├┬┘├┤ │  ├┤ └┐┌┘├─┤│││ │   ───│
  //  └─    ┴└    ┴└─└─┘┴─┘└─┘ └┘ ┴ ┴┘└┘ ┴     ─┘
  // If an explicit callback function was specified, then immediately run the logic below
  // and trigger the explicit callback when the time comes.  Otherwise, build and return
  // a new Deferred now. (If/when the Deferred is executed, the logic below will run.)
  return parley(

    function (done){

      // Otherwise, IWMIH, we know that it's time to actually do some stuff.
      // So...
      //
      //  ███████╗██╗  ██╗███████╗ ██████╗██╗   ██╗████████╗███████╗
      //  ██╔════╝╚██╗██╔╝██╔════╝██╔════╝██║   ██║╚══██╔══╝██╔════╝
      //  █████╗   ╚███╔╝ █████╗  ██║     ██║   ██║   ██║   █████╗
      //  ██╔══╝   ██╔██╗ ██╔══╝  ██║     ██║   ██║   ██║   ██╔══╝
      //  ███████╗██╔╝ ██╗███████╗╚██████╗╚██████╔╝   ██║   ███████╗
      //  ╚══════╝╚═╝  ╚═╝╚══════╝ ╚═════╝ ╚═════╝    ╚═╝   ╚══════╝

      //  ╔═╗╔═╗╦═╗╔═╗╔═╗  ┌─┐┌┬┐┌─┐┌─┐┌─┐  ┌┬┐┬ ┬┌─┐  ┌─┐ ┬ ┬┌─┐┬─┐┬ ┬
      //  ╠╣ ║ ║╠╦╝║ ╦║╣   └─┐ │ ├─┤│ ┬├┤    │ ││││ │  │─┼┐│ │├┤ ├┬┘└┬┘
      //  ╚  ╚═╝╩╚═╚═╝╚═╝  └─┘ ┴ ┴ ┴└─┘└─┘   ┴ └┴┘└─┘  └─┘└└─┘└─┘┴└─ ┴
      //
      // Forge a stage 2 query (aka logical protostatement)
      try {
        forgeStageTwoQuery(query, orm);
      } catch (e) {
        switch (e.code) {

          case 'E_INVALID_TARGET_RECORD_IDS':
            return done(
              flaverr({
                name: 'UsageError',
                code: e.code,
                details: e.details,
                message:
                'The target record ids (i.e. first argument) passed to `.replaceCollection()` '+
                'should be the ID (or IDs) of compatible target records whose collection will '+
                'be modified.\n'+
                'Details:\n'+
                '  ' + e.details + '\n'
              }, omen)
            );

          case 'E_INVALID_COLLECTION_ATTR_NAME':
            return done(
              flaverr({
                name: 'UsageError',
                code: e.code,
                details: e.details,
                message:
                'The collection attr name (i.e. second argument) to `.replaceCollection()` should '+
                'be the name of a collection association from this model.\n'+
                'Details:\n'+
                '  ' + e.details + '\n'
              }, omen)
            );

          case 'E_INVALID_ASSOCIATED_IDS':
            return done(
              flaverr({
                name: 'UsageError',
                code: e.code,
                details: e.details,
                message:
                'The associated ids (i.e. using `.members()`, or the third argument) passed to `.replaceCollection()` should be '+
                'the ID (or IDs) of associated records to use.\n'+
                'Details:\n'+
                '  ' + e.details + '\n'
              }, omen)
            );

          case 'E_NOOP':
            return done();
            // ^ tolerate no-ops -- i.e. empty array of target record ids

          case 'E_INVALID_META':
            return done(
              flaverr({
                name: 'UsageError',
                code: e.code,
                details: e.details,
                message: e.message
              }, omen)
            );
            // ^ when the standard usage error message is good enough as-is, without any further customization

          default:
            return done(e);
            // ^ when an internal, miscellaneous, or unexpected error occurs

        }
      } // >-•


      //  ┌┐┌┌─┐┬ ┬  ╔═╗╔═╗╔╦╗╦ ╦╔═╗╦  ╦ ╦ ╦  ┌┬┐┌─┐┬  ┬┌─  ┌┬┐┌─┐  ┌┬┐┬ ┬┌─┐  ┌┬┐┌┐ ┌─┐
      //  ││││ ││││  ╠═╣║   ║ ║ ║╠═╣║  ║ ╚╦╝   │ ├─┤│  ├┴┐   │ │ │   │ ├─┤├┤    ││├┴┐└─┐
      //  ┘└┘└─┘└┴┘  ╩ ╩╚═╝ ╩ ╚═╝╩ ╩╩═╝╩═╝╩    ┴ ┴ ┴┴─┘┴ ┴   ┴ └─┘   ┴ ┴ ┴└─┘  ─┴┘└─┘└─┘
      (function (proceed){

        // Get the model being used as the parent
        var WLModel = orm.collections[query.using];
        try { assert.equal(query.using.toLowerCase(), query.using, '`query.using` (identity) should have already been normalized before getting here!  But it was not: '+query.using); } catch (e) { return proceed(e); }

        // Look up the association by name in the schema definition.
        var schemaDef = WLModel.schema[query.collectionAttrName];

        // Look up the associated collection using the schema def which should have
        // join tables normalized
        var WLChild = orm.collections[schemaDef.collection];
        try {
          assert.equal(schemaDef.collection.toLowerCase(), schemaDef.collection, '`schemaDef.collection` (identity) should have already been normalized before getting here!  But it was not: '+schemaDef.collection);
          assert.equal(schemaDef.referenceIdentity.toLowerCase(), schemaDef.referenceIdentity, '`schemaDef.referenceIdentity` (identity) should have already been normalized before getting here!  But it was not: '+schemaDef.referenceIdentity);
          assert.equal(Object.getPrototypeOf(WLChild).identity.toLowerCase(), Object.getPrototypeOf(WLChild).identity, '`Object.getPrototypeOf(WLChild).identity` (identity) should have already been normalized before getting here!  But it was not: '+Object.getPrototypeOf(WLChild).identity);
        } catch (e) { return proceed(e); }

        // Flag to determine if the WLChild is a manyToMany relation
        var manyToMany = false;

        // Check if the schema references something other than the WLChild
        if (schemaDef.referenceIdentity !== Object.getPrototypeOf(WLChild).identity) {
          manyToMany = true;
          WLChild = orm.collections[schemaDef.referenceIdentity];
        }

        // Check if the child is a join table
        if (_.has(Object.getPrototypeOf(WLChild), 'junctionTable') && WLChild.junctionTable) {
          manyToMany = true;
        }

        // Check if the child is a through table
        if (_.has(Object.getPrototypeOf(WLChild), 'throughTable') && _.keys(WLChild.throughTable).length) {
          manyToMany = true;
        }


        // Ensure the query skips lifecycle callbacks
        // Build a modified shallow clone of the originally-provided `meta`
        var modifiedMeta = _.extend({}, query.meta || {}, { skipAllLifecycleCallbacks: true });



        //   ██╗███╗   ██╗      ███╗   ███╗██╗
        //  ██╔╝████╗  ██║      ████╗ ████║╚██╗
        //  ██║ ██╔██╗ ██║      ██╔████╔██║ ██║
        //  ██║ ██║╚██╗██║      ██║╚██╔╝██║ ██║
        //  ╚██╗██║ ╚████║██╗██╗██║ ╚═╝ ██║██╔╝
        //   ╚═╝╚═╝  ╚═══╝╚═╝╚═╝╚═╝     ╚═╝╚═╝
        //
        //  ███╗   ███╗ █████╗ ███╗   ██╗██╗   ██╗    ████████╗ ██████╗     ███╗   ███╗ █████╗ ███╗   ██╗██╗   ██╗
        //  ████╗ ████║██╔══██╗████╗  ██║╚██╗ ██╔╝    ╚══██╔══╝██╔═══██╗    ████╗ ████║██╔══██╗████╗  ██║╚██╗ ██╔╝
        //  ██╔████╔██║███████║██╔██╗ ██║ ╚████╔╝        ██║   ██║   ██║    ██╔████╔██║███████║██╔██╗ ██║ ╚████╔╝
        //  ██║╚██╔╝██║██╔══██║██║╚██╗██║  ╚██╔╝         ██║   ██║   ██║    ██║╚██╔╝██║██╔══██║██║╚██╗██║  ╚██╔╝
        //  ██║ ╚═╝ ██║██║  ██║██║ ╚████║   ██║          ██║   ╚██████╔╝    ██║ ╚═╝ ██║██║  ██║██║ ╚████║   ██║
        //  ╚═╝     ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝   ╚═╝          ╚═╝    ╚═════╝     ╚═╝     ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝   ╚═╝
        //
        // If the collection uses a join table, build a query that removes the records
        // from the table.
        if (manyToMany) {

          //  ╔╗ ╦ ╦╦╦  ╔╦╗  ┬─┐┌─┐┌─┐┌─┐┬─┐┌─┐┌┐┌┌─┐┌─┐  ┌┬┐┌─┐┌─┐┌─┐┬┌┐┌┌─┐
          //  ╠╩╗║ ║║║   ║║  ├┬┘├┤ ├┤ ├┤ ├┬┘├┤ ││││  ├┤   │││├─┤├─┘├─┘│││││ ┬
          //  ╚═╝╚═╝╩╩═╝═╩╝  ┴└─└─┘└  └─┘┴└─└─┘┘└┘└─┘└─┘  ┴ ┴┴ ┴┴  ┴  ┴┘└┘└─┘
          //
          // Maps out the parent and child attribute names to use for the query.
          var parentReference;
          var childReference;

          // Find the parent reference
          if (_.has(Object.getPrototypeOf(WLChild), 'junctionTable') && WLChild.junctionTable) {
            // Assumes the generated junction table will only ever have two foreign key
            // values. Should be safe for now and any changes would need to be made in
            // Waterline-Schema where a map could be formed anyway.
            _.each(WLChild.schema, function(wlsAttrDef, key) {
              if (!_.has(wlsAttrDef, 'references')) {
                return;
              }

              // If this is the piece of the join table, set the parent reference.
              if (_.has(wlsAttrDef, 'columnName') && wlsAttrDef.columnName === schemaDef.on) {
                parentReference = key;
              }
            });
          }
          // If it's a through table, grab the parent and child reference from the
          // through table mapping that was generated by Waterline-Schema.
          else if (_.has(Object.getPrototypeOf(WLChild), 'throughTable')) {
            childReference = WLChild.throughTable[WLModel.identity + '.' + query.collectionAttrName];
            _.each(WLChild.throughTable, function(rhs, key) {
              if (key !== WLModel.identity + '.' + query.collectionAttrName) {
                parentReference = rhs;
              }
            });
          }//>-



          // Find the child reference in a junction table
          if (_.has(Object.getPrototypeOf(WLChild), 'junctionTable') && WLChild.junctionTable) {
            // Assumes the generated junction table will only ever have two foreign key
            // values. Should be safe for now and any changes would need to be made in
            // Waterline-Schema where a map could be formed anyway.
            _.each(WLChild.schema, function(wlsAttrDef, key) {
              if (!_.has(wlsAttrDef, 'references')) {
                return;
              }

              // If this is the other piece of the join table, set the child reference.
              if (_.has(wlsAttrDef, 'columnName') && wlsAttrDef.columnName !== schemaDef.on) {
                childReference = key;
              }
            });
          }


          //  ╔╗ ╦ ╦╦╦  ╔╦╗  ┌┬┐┌─┐┌─┐┌┬┐┬─┐┌─┐┬ ┬  ┌─┐ ┬ ┬┌─┐┬─┐┬ ┬
          //  ╠╩╗║ ║║║   ║║   ││├┤ └─┐ │ ├┬┘│ │└┬┘  │─┼┐│ │├┤ ├┬┘└┬┘
          //  ╚═╝╚═╝╩╩═╝═╩╝  ─┴┘└─┘└─┘ ┴ ┴└─└─┘ ┴   └─┘└└─┘└─┘┴└─ ┴
          //
          // When replacing a collection, the first step is to remove all the records
          // for the target id's in the join table.
          var criteriaOfDestruction = {
            where: {}
          };

          criteriaOfDestruction.where[parentReference] = {
            in: query.targetRecordIds
          };

          // Don't worry about fetching
          modifiedMeta.fetch = false;

          //  ╔╗ ╦ ╦╦╦  ╔╦╗  ┬┌┐┌┌─┐┌─┐┬─┐┌┬┐  ┌─┐ ┬ ┬┌─┐┬─┐┬ ┬
          //  ╠╩╗║ ║║║   ║║  ││││└─┐├┤ ├┬┘ │   │─┼┐│ │├┤ ├┬┘└┬┘
          //  ╚═╝╚═╝╩╩═╝═╩╝  ┴┘└┘└─┘└─┘┴└─ ┴   └─┘└└─┘└─┘┴└─ ┴
          //
          // Then build up an insert query for creating the new join table records.
          var insertRecords = [];

          // For each target record, build an insert query for the associated records.
          _.each(query.targetRecordIds, function(targetId) {
            _.each(query.associatedIds, function(associatedId) {
              var record = {};
              record[parentReference] = targetId;
              record[childReference] = associatedId;
              insertRecords.push(record);
            });
          });


          //  ╦═╗╦ ╦╔╗╔  ┌┬┐┌─┐┌─┐┌┬┐┬─┐┌─┐┬ ┬  ┌─┐ ┬ ┬┌─┐┬─┐┬ ┬
          //  ╠╦╝║ ║║║║   ││├┤ └─┐ │ ├┬┘│ │└┬┘  │─┼┐│ │├┤ ├┬┘└┬┘
          //  ╩╚═╚═╝╝╚╝  ─┴┘└─┘└─┘ ┴ ┴└─└─┘ ┴   └─┘└└─┘└─┘┴└─ ┴
          WLChild.destroy(criteriaOfDestruction, function $afterDestroyingChildRecords(err) {
            if (err) { return proceed(err); }

            // If there were no associated id's to insert, exit out
            if (!query.associatedIds.length) {
              return proceed();
            }

            //  ╦═╗╦ ╦╔╗╔  ┌─┐┬─┐┌─┐┌─┐┌┬┐┌─┐  ┌─┐ ┬ ┬┌─┐┬─┐┬ ┬
            //  ╠╦╝║ ║║║║  │  ├┬┘├┤ ├─┤ │ ├┤   │─┼┐│ │├┤ ├┬┘└┬┘
            //  ╩╚═╚═╝╝╚╝  └─┘┴└─└─┘┴ ┴ ┴ └─┘  └─┘└└─┘└─┘┴└─ ┴
            WLChild.createEach(insertRecords, proceed, modifiedMeta);

          }, modifiedMeta);

          return;
        }//-•


        //   ██╗███╗   ██╗      ██╗██╗
        //  ██╔╝████╗  ██║     ███║╚██╗
        //  ██║ ██╔██╗ ██║     ╚██║ ██║
        //  ██║ ██║╚██╗██║      ██║ ██║
        //  ╚██╗██║ ╚████║██╗██╗██║██╔╝
        //   ╚═╝╚═╝  ╚═══╝╚═╝╚═╝╚═╝╚═╝
        //
        //  ██████╗ ███████╗██╗      ██████╗ ███╗   ██╗ ██████╗ ███████╗    ████████╗ ██████╗
        //  ██╔══██╗██╔════╝██║     ██╔═══██╗████╗  ██║██╔════╝ ██╔════╝    ╚══██╔══╝██╔═══██╗
        //  ██████╔╝█████╗  ██║     ██║   ██║██╔██╗ ██║██║  ███╗███████╗       ██║   ██║   ██║
        //  ██╔══██╗██╔══╝  ██║     ██║   ██║██║╚██╗██║██║   ██║╚════██║       ██║   ██║   ██║
        //  ██████╔╝███████╗███████╗╚██████╔╝██║ ╚████║╚██████╔╝███████║       ██║   ╚██████╔╝
        //  ╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═╝  ╚═══╝ ╚═════╝ ╚══════╝       ╚═╝    ╚═════╝
        //
        // Otherwise the child records need to have their foreign keys updated to reflect the
        // new realities of the association.  We'll either (A) set the new child records to
        // have the same fk and null out any other existing child records or (B) just null out
        // all existing child records.  That's because there should only ever be either (A) exactly
        // one target record with >=1 new child records to associate or (B) >=1 target records with
        // zero new child records to associate (i.e. a null-out)
        if (query.targetRecordIds.length >= 2 && query.associatedIds.length > 0) { return proceed(new Error('Consistency violation: Too many target record ids and associated ids-- should never have been possible, because this query should have been halted when it was being forged at stage 2.')); }
        if (query.targetRecordIds.length === 0) { return proceed(new Error('Consistency violation: No target record ids-- should never have been possible, because this query should have been halted when it was being forged at stage 2.')); }


        // First, check whether the foreign key attribute is required/optional so that we know whether
        // it's safe to null things out without checking for collisions beforehand.
        var isFkAttributeOptional = !WLChild.attributes[schemaDef.via].required;
        (function(proceed){
          if (isFkAttributeOptional) {
            return proceed(undefined, 0);
          }//•

          var potentialCollisionCriteria = { where: {} };
          potentialCollisionCriteria.where[schemaDef.via] = { in: query.targetRecordIds };
          potentialCollisionCriteria.where[WLChild.primaryKey] = { nin: query.associatedIds };
          WLChild.count(potentialCollisionCriteria, function(err, total) {
            if (err) { return proceed(err); }
            return proceed(undefined, total);
          });//_∏_  </WLChild.count()>

        })(function (err, numCollisions) {
          if (err) { return proceed(err); }

          if (!isFkAttributeOptional && numCollisions > 0) {
            return proceed(flaverr({
              name: 'PropagationError',
              code: 'E_COLLISIONS_WHEN_NULLING_OUT_REQUIRED_FK',
              message:
              'Cannot '+(query.associatedIds.length===0?'wipe':'replace')+' the contents of '+
              'association (`'+query.collectionAttrName+'`) because there '+
              (numCollisions===1?('is one conflicting '+WLChild.identity+' record'):('are '+numCollisions+' conflicting '+WLChild.identity+' records'))+' '+
              'whose `'+schemaDef.via+'` cannot be set to `null`.  (That attribute is required.)'
              // For example, if you have a car with four tires, and you set out
              // to replace the four old tires with only three new ones, then you'll need to
              // destroy the spare tire before attempting to call `Car.replaceCollection()`)
              // ^^ Actually maybe just do that last bit in FS2Q (see other note there)
            }, omen));
          }//•

          // So to recap: IWMIH we know that one of two things is true.
          //
          // Either:
          // (A) there are >=1 associated record ids, but EXACTLY ONE target record id (**null out fks for existing associated records except for the new ones, then set all the new ones to the same value**), or
          // (B) there is >=1 target record id, but ZERO associated record ids (**just null out fks for all existing associated records**)
          //
          //  ╦═╗╦ ╦╔╗╔  ┌─┐┌─┐┬─┐┌┬┐┬┌─┐┬    ┌┐┌┬ ┬┬  ┬   ┌─┐┬ ┬┌┬┐  ┌─┐ ┬ ┬┌─┐┬─┐┬ ┬  ┌┬┐┬ ┬┌─┐┌┐┌
          //  ╠╦╝║ ║║║║  ├─┘├─┤├┬┘ │ │├─┤│    ││││ ││  │───│ ││ │ │   │─┼┐│ │├┤ ├┬┘└┬┘   │ ├─┤├┤ │││
          //  ╩╚═╚═╝╝╚╝  ┴  ┴ ┴┴└─ ┴ ┴┴ ┴┴─┘  ┘└┘└─┘┴─┘┴─┘ └─┘└─┘ ┴   └─┘└└─┘└─┘┴└─ ┴┘   ┴ ┴ ┴└─┘┘└┘
          //  ┌─┐┌┐┌┌─┐┌┬┐┬ ┬┌─┐┬─┐  ┌─┐ ┬ ┬┌─┐┬─┐┬ ┬  ┌┬┐┌─┐  ┌─┐┌─┐┌┬┐  ┌─┐┌─┐┬─┐┌─┐┬┌─┐┌┐┌  ┬┌─┌─┐┬ ┬┌─┐
          //  ├─┤││││ │ │ ├─┤├┤ ├┬┘  │─┼┐│ │├┤ ├┬┘└┬┘   │ │ │  └─┐├┤  │   ├┤ │ │├┬┘├┤ ││ ┬│││  ├┴┐├┤ └┬┘└─┐
          //  ┴ ┴┘└┘└─┘ ┴ ┴ ┴└─┘┴└─  └─┘└└─┘└─┘┴└─ ┴    ┴ └─┘  └─┘└─┘ ┴   └  └─┘┴└─└─┘┴└─┘┘└┘  ┴ ┴└─┘ ┴ └─┘
          // We'll start with scenario A, where we first null out the fk on any existing records
          // other than the new ones, then update all the foreign key values for new associated
          // records to point to one particular parent record (aka target record).
          if (query.associatedIds.length > 0) {

            // console.log('** partial null-out **  # collisions:', numCollisions);

            var partialNullOutCriteria = { where: {} };
            partialNullOutCriteria.where[WLChild.primaryKey] = { nin: query.associatedIds };
            partialNullOutCriteria.where[schemaDef.via] = query.targetRecordIds[0];
            // ^^ we know there has to be exactly one target record id at this point
            // (see assertions above) so this is safe.

            var partialNullOutVts = {};
            partialNullOutVts[schemaDef.via] = null;

            // If the FK attribute is required, then we've already looked up the # of collisions,
            // so we can use that as an optimization to decide whether we can skip past this query
            // altogether.  (If we already know there are no collisions, there's nothing to "null out"!)
            if (!isFkAttributeOptional && numCollisions === 0) {
              // > To accomplish this, we just use an empty "values to set" query key to make
              // > this first query into a no-op.  This saves us doing yet another self-calling
              // > function.  (One day, when the world has entirely switched to Node >= 7.9,
              // > we could just use `await` for all this exciting stuff.)
              partialNullOutVts = {};
            }//fi

            WLChild.update(partialNullOutCriteria, partialNullOutVts, function(err) {
              if (err) { return proceed(err); }

              var newFkUpdateCriteria = { where: {} };
              newFkUpdateCriteria.where[WLChild.primaryKey] = { in: query.associatedIds };

              var newFkUpdateVts = {};
              newFkUpdateVts[schemaDef.via] = query.targetRecordIds[0];
              // ^^ we know there has to be exactly one target record id at this point
              // (see assertions above) so this is safe.

              WLChild.update(newFkUpdateCriteria, newFkUpdateVts, function(err) {
                if (err) { return proceed(err); }

                return proceed();
              }, modifiedMeta);//_∏_    </WLChild.update() -- the "new fk update" query>

            }, modifiedMeta);//_∏_    </WLChild.update() -- the "partial null-out" query>

          }//‡
          //  ╦═╗╦ ╦╔╗╔  ┌┐ ┬  ┌─┐┌┐┌┬┌─┌─┐┌┬┐  ┌┐┌┬ ┬┬  ┬    ┌─┐┬ ┬┌┬┐  ┌─┐ ┬ ┬┌─┐┬─┐┬ ┬
          //  ╠╦╝║ ║║║║  ├┴┐│  ├─┤│││├┴┐├┤  │   ││││ ││  │    │ ││ │ │   │─┼┐│ │├┤ ├┬┘└┬┘
          //  ╩╚═╚═╝╝╚╝  └─┘┴─┘┴ ┴┘└┘┴ ┴└─┘ ┴   ┘└┘└─┘┴─┘┴─┘  └─┘└─┘ ┴   └─┘└└─┘└─┘┴└─ ┴
          // Alternatively, we'll go with scenario B, where we potentially null all the fks out.
          else {
            // console.log('** BLANKET NULL-OUT **  # collisions:', numCollisions);

            // If the FK attribute is required, then we've already looked up the # of collisions,
            // so we can use that as an optimization to decide whether we can skip past this query
            // altogether.  (If we already know there are no collisions, there's nothing to "null out"!)
            if (!isFkAttributeOptional && numCollisions === 0) {
              return proceed();
            }//•

            // Otherwise, proceed with the "null out"
            var nullOutCriteria = { where: {} };
            nullOutCriteria.where[schemaDef.via] = { in: query.targetRecordIds };

            var blanketNullOutVts = {};
            blanketNullOutVts[schemaDef.via] = null;

            WLChild.update(nullOutCriteria, blanketNullOutVts, function(err) {
              if (err) { return proceed(err); }
              return proceed();
            }, modifiedMeta);//_∏_    </WLChild.update() -- the "blanket null-out" query>

          }//fi

          // (Reminder: don't put any code down here!)

        });//_∏_  </†>

      })(function (err) {
        if (err) { return done(err); }

        // IWMIH, everything worked!
        // > Note that we do not send back a result of any kind-- this it to reduce the likelihood
        // > writing userland code that relies undocumented/experimental output.
        return done();

      });//</ self-calling function (actually talk to the dbs) >

    },


    explicitCbMaybe,


    _.extend(DEFERRED_METHODS, {

      // Provide access to this model for use in query modifier methods.
      _WLModel: WLModel,

      // Set up initial query metadata.
      _wlQueryInfo: query,

    })

  );//</parley>

};