balderdashy/waterline

View on GitHub
lib/waterline/utils/query/private/normalize-sort-clause.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * Module dependencies
 */

var util = require('util');
var _ = require('@sailshq/lodash');
var flaverr = require('flaverr');
var getModel = require('../../ontology/get-model');
var getAttribute = require('../../ontology/get-attribute');
var isValidAttributeName = require('./is-valid-attribute-name');


/**
 * normalizeSortClause()
 *
 * Validate and normalize the `sort` clause, rejecting obviously unsupported usage,
 * and tolerating certain backwards-compatible things.
 *
 * --
 *
 * @param  {Ref} sortClause
 *         A hypothetically well-formed `sort` clause from a Waterline criteria.
 *         (i.e. in a "stage 1 query")
 *         > WARNING:
 *         > IN SOME CASES (BUT NOT ALL!), THE PROVIDED VALUE WILL
 *         > UNDERGO DESTRUCTIVE, IN-PLACE CHANGES JUST BY PASSING IT
 *         > IN TO THIS UTILITY.
 *
 * @param {String} modelIdentity
 *        The identity of the model this `sort` clause is referring to (e.g. "pet" or "user")
 *        > Useful for looking up the Waterline model and accessing its attribute definitions.
 *
 * @param {Ref} orm
 *        The Waterline ORM instance.
 *        > Useful for accessing the model definitions.
 *
 * @param {Dictionary?} meta
 *        The contents of the `meta` query key, if one was provided.
 *        > Useful for propagating query options to low-level utilities like this one.
 * --
 *
 * @returns {Array}
 *          The successfully-normalized `sort` clause, ready for use in a stage 2 query.
 *          > Note that the originally provided `sort` clause MAY ALSO HAVE BEEN
 *          > MUTATED IN PLACE!
 *
 *
 * @throws {Error} If it encounters irrecoverable problems or unsupported usage in
 *                 the provided `sort` clause.
 *         @property {String} code
 *                   - E_SORT_CLAUSE_UNUSABLE
 *
 *
 * @throws {Error} If anything else unexpected occurs.
 */

module.exports = function normalizeSortClause(sortClause, modelIdentity, orm, meta) {

  // Look up the Waterline model for this query.
  // > This is so that we can reference the original model definition.
  var WLModel = getModel(modelIdentity, orm);

  //  ╔═╗╔═╗╔╦╗╔═╗╔═╗╔╦╗╦╔╗ ╦╦  ╦╔╦╗╦ ╦
  //  ║  ║ ║║║║╠═╝╠═╣ ║ ║╠╩╗║║  ║ ║ ╚╦╝
  //  ╚═╝╚═╝╩ ╩╩  ╩ ╩ ╩ ╩╚═╝╩╩═╝╩ ╩  ╩
  // If `sort` was provided as a dictionary...
  if (_.isObject(sortClause) && !_.isArray(sortClause) && !_.isFunction(sortClause)) {

    sortClause = _.reduce(_.keys(sortClause), function (memo, sortByKey) {

      var sortDirection = sortClause[sortByKey];

      // It this appears to be a well-formed comparator directive that was simply mistakenly
      // provided at the top level instead of being wrapped in an array, then throw an error
      // specifically mentioning that.
      if (_.isString(sortDirection) && _.keys(sortClause).length === 1) {
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'The `sort` clause in the provided criteria is invalid.  If specified, it should be either '+
          'a string like `\'fullName DESC\'`, or an array like `[ { fullName: \'DESC\' } ]`.  '+
          'But it looks like you might need to wrap this in an array, because instead, got: '+
          util.inspect(sortClause, {depth:5})+''
        ));
      }//-•


      // Otherwise, continue attempting to normalize this dictionary into array
      // format under the assumption that it was provided as a Mongo-style comparator
      // dictionary. (and freaking out if we see anything that makes us uncomfortable)
      var newComparatorDirective = {};
      if (sortDirection === 1) {
        newComparatorDirective[sortByKey] = 'ASC';
      }
      else if (sortDirection === -1) {
        newComparatorDirective[sortByKey] = 'DESC';
      }
      else {
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'The `sort` clause in the provided criteria is invalid.  If specified as a '+
          'dictionary, it should use Mongo-esque semantics, using -1 and 1 for the sort '+
          'direction  (something like `{ fullName: -1, rank: 1 }`).  But instead, got: '+
          util.inspect(sortClause, {depth:5})+''
        ));
      }
      memo.push(newComparatorDirective);

      return memo;

    }, []);//</_.reduce()>

    // IWMIH, then we know a dictionary was provided that appears to be using valid Mongo-esque
    // semantics, or that is at least an empty dictionary.  Nonetheless, this usage is not recommended,
    // and might be removed in the future.  So log a warning:
    console.warn('\n'+
      'Warning: The `sort` clause in the provided criteria is specified as a dictionary (plain JS object),\n'+
      'meaning that it is presumably using Mongo-esque semantics (something like `{ fullName: -1, rank: 1 }`).\n'+
      'But as of Sails v1/Waterline 0.13, this is no longer the recommended usage.  Instead, please use either\n'+
      'a string like `\'fullName DESC\'`, or an array like `[ { fullName: \'DESC\' } ]`.\n'+
      '(Since I get what you mean, tolerating & remapping this usage for now...)\n'
    );

  }//>-


  // Tolerate empty array (`[]`), understanding it to mean the same thing as `undefined`.
  if (_.isArray(sortClause) && sortClause.length === 0) {
    sortClause = undefined;
    // Note that this will be further expanded momentarily.
  }//>-



  //  ╔╦╗╔═╗╔═╗╔═╗╦ ╦╦ ╔╦╗
  //   ║║║╣ ╠╣ ╠═╣║ ║║  ║
  //  ═╩╝╚═╝╚  ╩ ╩╚═╝╩═╝╩
  // If no `sort` clause was provided, give it a default (empty) value,
  // indicating the adapter should use its default sorting strategy
  if (_.isUndefined(sortClause)) {
    sortClause = [];
  }//>-

  // If `sort` was provided as a string, then expand it into an array.
  // (We'll continue cleaning it up down below-- this is just to get
  // it part of the way there-- e.g. we might end up with something like:
  // `[ 'name DESC' ]`)
  if (_.isString(sortClause)) {
    sortClause = [
      sortClause
    ];
  }//>-


  // If, by this time, `sort` is not an array...
  if (!_.isArray(sortClause)) {
    // Then the provided `sort` must have been highly irregular indeed.
    throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
      'The `sort` clause in the provided criteria is invalid.  If specified, it should be either '+
      'a string like `\'fullName DESC\'`, or an array like `[ { fullName: \'DESC\' } ]`.  '+
      'But instead, got: '+
      util.inspect(sortClause, {depth:5})+''
    ));
  }//-•



  // Ensure that each item in the array is a structurally-valid comparator directive:
  sortClause = _.map(sortClause, function (comparatorDirective){

    //  ┌┬┐┌─┐┬  ┌─┐┬─┐┌─┐┌┬┐┌─┐  ┌─┐┌┬┐┬─┐┬┌┐┌┌─┐  ┬ ┬┌─┐┌─┐┌─┐┌─┐
    //   │ │ ││  ├┤ ├┬┘├─┤ │ ├┤   └─┐ │ ├┬┘│││││ ┬  │ │└─┐├─┤│ ┬├┤
    //   ┴ └─┘┴─┘└─┘┴└─┴ ┴ ┴ └─┘  └─┘ ┴ ┴└─┴┘└┘└─┘  └─┘└─┘┴ ┴└─┘└─┘
    //  ┌─  ┌─┐ ┌─┐     ╔═╗╔╦╗╔═╗╦╦  ╔═╗╔╦╗╔╦╗╦═╗╔═╗╔═╗╔═╗  ╔═╗╔═╗╔═╗  ─┐
    //  │   ├┤  │ ┬     ║╣ ║║║╠═╣║║  ╠═╣ ║║ ║║╠╦╝║╣ ╚═╗╚═╗  ╠═╣╚═╗║     │
    //  └─  └─┘o└─┘o    ╚═╝╩ ╩╩ ╩╩╩═╝╩ ╩═╩╝═╩╝╩╚═╚═╝╚═╝╚═╝  ╩ ╩╚═╝╚═╝  ─┘
    // If this is a string, then morph it into a dictionary.
    //
    // > This is so that we tolerate syntax like `'name ASC'`
    // > at the top level (since we would have expanded it above)
    // > AND when provided within the array (e.g. `[ 'name ASC' ]`)
    if (_.isString(comparatorDirective)) {

      var pieces = comparatorDirective.split(/\s+/);
      if (pieces.length === 2) {
        // Good, that's what we expect.
      }
      else if (pieces.length === 1) {
        // If there is only the attribute name specified, then assume that we're implying 'ASC'.
        // > For example, if we worked together at a pet shelter where there were two dogs (named
        // > "Suzy" and "Arnold") and a parrot named "Eleanor", and our boss asked us for a list of
        // > all the animals, sorted by name, we'd most likely assume that the list should begin witih
        // > Arnold the dog.
        pieces.push('ASC');
      }
      else {
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'Invalid `sort` clause in criteria. If specifying a string, it should look like '+
          'e.g. `\'emailAddress ASC\'`, where the attribute name ("emailAddress") is separated '+
          'from the sort direction ("ASC" or "DESC") by whitespace.  But instead, got: '+
          util.inspect(comparatorDirective, {depth:5})+''
        ));
      }//-•

      // Build a dictionary out of it.
      comparatorDirective = {};
      comparatorDirective[pieces[0]] = pieces[1];

    }//>-•


    // If this is NOT a dictionary at this point, then freak out.
    if (!_.isObject(comparatorDirective) || _.isArray(comparatorDirective) || _.isFunction(comparatorDirective)) {
      throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
        'The `sort` clause in the provided criteria is invalid, because, although it '+
        'is an array, one of its items (aka comparator directives) has an unexpected '+
        'data type.  Expected every comparator directive to be a dictionary like `{ fullName: \'DESC\' }`.  '+
        'But instead, this one is: '+
        util.inspect(comparatorDirective, {depth:5})+''
      ));
    }//-•


    // IWMIH, then we know we've got a dictionary.
    //
    // > This is where we assume it is a well-formed comparator directive
    // > and casually/gently/lovingly validate it as such.


    //  ┌─┐┌─┐┬ ┬┌┐┌┌┬┐  ┬┌─┌─┐┬ ┬┌─┐
    //  │  │ ││ ││││ │   ├┴┐├┤ └┬┘└─┐
    //  └─┘└─┘└─┘┘└┘ ┴   ┴ ┴└─┘ ┴ └─┘
    // Count the keys.
    switch (_.keys(comparatorDirective).length) {

      // Must not be an empty dictionary.
      case 0:
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'The `sort` clause in the provided criteria is invalid, because, although it '+
          'is an array, one of its items (aka comparator directives) is `{}`, an empty dictionary '+
          '(aka plain JavaScript object).  But comparator directives are supposed to have '+
          '_exactly one_ key (e.g. so that they look something like `{ fullName: \'DESC\' }`.'
        ));

      case 1:
        // There should always be exactly one key.
        // If we're here, then everything is ok.
        // Keep going.
        break;

      // Must not have more than one key.
      default:
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'The `sort` clause in the provided criteria is invalid, because, although it '+
          'is an array, one of its items (aka comparator directives) is a dictionary (aka '+
          'plain JavaScript object) with '+(_.keys(comparatorDirective).length)+ ' keys...  '+
          'But, that\'s too many keys.  Comparator directives are supposed to have _exactly '+
          'one_ key (e.g. so that they look something like `{ fullName: \'DESC\' }`.  '+
          'But instead, this one is: '+util.inspect(comparatorDirective, {depth:5})+''
        ));

    }//</switch>


    //  ┌─┐┬ ┬┌─┐┌─┐┬┌─  ┌┬┐┬ ┬┌─┐┌┬┐  ┬┌─┌─┐┬ ┬  ┬┌─┐  ┬  ┬┌─┐┬  ┬┌┬┐  ┌─┐┌┬┐┌┬┐┬─┐
    //  │  ├─┤├┤ │  ├┴┐   │ ├─┤├─┤ │   ├┴┐├┤ └┬┘  │└─┐  └┐┌┘├─┤│  │ ││  ├─┤ │  │ ├┬┘
    //  └─┘┴ ┴└─┘└─┘┴ ┴   ┴ ┴ ┴┴ ┴ ┴   ┴ ┴└─┘ ┴   ┴└─┘   └┘ ┴ ┴┴─┘┴─┴┘  ┴ ┴ ┴  ┴ ┴└─
    // Next, check this comparator directive's key (i.e. its "comparator target")
    //  • if this model is `schema: true`:
    //    ° the directive's key must be the name of a recognized attribute
    //  • if this model is `schema: false`:
    //    ° then the directive's key must be a conceivably-valid attribute name

    var sortByKey = _.keys(comparatorDirective)[0];

    var attrName;
    var isDeepTarget;
    var deepTargetHops;
    if (_.isString(sortByKey)){
      deepTargetHops = sortByKey.split(/\./);
      isDeepTarget = (deepTargetHops.length > 1);
    }

    if (isDeepTarget) {
      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      // FUTURE: Replace this opt-in experimental support with official support for
      // deep targets for comparator directives: i.e. dot notation for sorting by nested
      // properties of JSON embeds.
      // This will require additional tests + docs, as well as a clear way of indicating
      // whether a particular adapter supports this feature so that proper error messages
      // can be displayed otherwise.
      // (See https://github.com/balderdashy/waterline/pull/1519)
      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      if (!meta || !meta.enableExperimentalDeepTargets) {
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'Cannot use dot notation as the target for a `sort` comparator without enabling experimental '+
          'support for "deep targets".  Please try again with `.meta({enableExperimentalDeepTargets:true})`.'
        ));
      }//•

      attrName = deepTargetHops[0];
    }
    else {
      attrName = sortByKey;
    }

    // Look up the attribute definition, if possible.
    var attrDef;
    try {
      attrDef = getAttribute(attrName, modelIdentity, orm);
    } catch (e){
      switch (e.code) {
        case 'E_ATTR_NOT_REGISTERED':
          // If no matching attr def exists, then just leave `attrDef` undefined
          // and continue... for now anyway.
          break;
        default: throw e;
      }
    }//</catch>


    // If model is `schema: true`...
    if (WLModel.hasSchema === true) {

      // Make sure this matched a recognized attribute name.
      if (!attrDef) {
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'The `sort` clause in the provided criteria is invalid, because, although it '+
          'is an array, one of its items (aka comparator directives) is problematic.  '+
          'It indicates that we should sort by `'+attrName+'`-- but that is not a recognized '+
          'attribute for this model (`'+modelIdentity+'`).  Since the model declares `schema: true`, '+
          'this is not allowed.'
        ));
      }//-•

    }
    // Else if model is `schema: false`...
    else if (WLModel.hasSchema === false) {

      // Make sure this is at least a valid name for a Waterline attribute.
      if (!isValidAttributeName(attrName)) {
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'The `sort` clause in the provided criteria is invalid, because, although it '+
          'is an array, one of its items (aka comparator directives) is problematic.  '+
          'It indicates that we should sort by `'+attrName+'`-- but that is not a '+
          'valid name for an attribute in Waterline.'
        ));
      }//-•

    } else { throw new Error('Consistency violation: Every instantiated Waterline model should always have a `hasSchema` property as either `true` or `false` (should have been derived from the `schema` model setting when Waterline was being initialized).  But somehow, this model (`'+modelIdentity+'`) ended up with `hasSchema: '+util.inspect(WLModel.hasSchema, {depth:5})+'`'); }



    // Now, make sure the matching attribute is _actually_ something that can be sorted on.
    // In other words: it must NOT be a plural (`collection`) association.
    if (attrDef && attrDef.collection) {
      throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
        'Cannot sort by `'+attrName+'` because it corresponds with an "unsortable" attribute '+
        'definition for this model (`'+modelIdentity+'`).  This attribute is a plural (`collection`) '+
        'association, so sorting by it is not supported.'
      ));
    }//-•


    if (isDeepTarget) {
      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      // FUTURE: See the other note above.  This is still experimental.
      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      if (isDeepTarget && attrDef && attrDef.type !== 'json' && attrDef.type !== 'ref') {
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'Cannot use dot notation to sort by a nested property of `'+attrName+'` because '+
          'the corresponding attribute is not capable of holding embedded JSON data such as '+
          'dictionaries (`{}`) or arrays (`[]`).  '+
          (attrDef.model||attrDef.collection?
            'Dot notation is not currently supported for sorting across associations '+
            '(see https://github.com/balderdashy/waterline/pull/1519 for details).'
            :
            'Sorting with dot notation is only supported for fields which might potentially '+
            'contain embedded JSON.'
          )
        ));
      }//•
    }//fi


    //  ┬  ┬┌─┐┬─┐┬┌─┐┬ ┬  ┌─┐┬┌┬┐┬ ┬┌─┐┬─┐  ╔═╗╔═╗╔═╗  ┌─┐┬─┐  ╔╦╗╔═╗╔═╗╔═╗
    //  └┐┌┘├┤ ├┬┘│├┤ └┬┘  ├┤ │ │ ├─┤├┤ ├┬┘  ╠═╣╚═╗║    │ │├┬┘   ║║║╣ ╚═╗║
    //   └┘ └─┘┴└─┴└   ┴   └─┘┴ ┴ ┴ ┴└─┘┴└─  ╩ ╩╚═╝╚═╝  └─┘┴└─  ═╩╝╚═╝╚═╝╚═╝
    //   ┬   ┌─┐┌┐┌┌─┐┬ ┬┬─┐┌─┐  ┌─┐┬─┐┌─┐┌─┐┌─┐┬─┐  ┌─┐┌─┐┌─┐┬┌┬┐┌─┐┬  ┬┌─┐┌─┐┌┬┐┬┌─┐┌┐┌
    //  ┌┼─  ├┤ │││└─┐│ │├┬┘├┤   ├─┘├┬┘│ │├─┘├┤ ├┬┘  │  ├─┤├─┘│ │ ├─┤│  │┌─┘├─┤ │ ││ ││││
    //  └┘   └─┘┘└┘└─┘└─┘┴└─└─┘  ┴  ┴└─└─┘┴  └─┘┴└─  └─┘┴ ┴┴  ┴ ┴ ┴ ┴┴─┘┴└─┘┴ ┴ ┴ ┴└─┘┘└┘
    // And finally, ensure the corresponding value on the RHS is either 'ASC' or 'DESC'.
    // (doing a little capitalization if necessary)


    // Before doing a careful check, uppercase the sort direction, if safe to do so.
    if (_.isString(comparatorDirective[sortByKey])) {
      comparatorDirective[sortByKey] = comparatorDirective[sortByKey].toUpperCase();
    }//>-

    // Now verify that it is either ASC or DESC.
    switch (comparatorDirective[sortByKey]) {
      case 'ASC':
      case 'DESC': //ok!
        break;

      default:
        throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
          'The `sort` clause in the provided criteria is invalid, because, although it '+
          'is an array, one of its items (aka comparator directives) is problematic.  '+
          'It indicates that we should sort by `'+sortByKey+'`, which is fine.  But then '+
          'it suggests that Waterline should use `'+comparatorDirective[sortByKey]+'` '+
          'as the sort direction. (Should always be either "ASC" or "DESC".)'
        ));
    }//</switch>

    // Return the modified comparator directive.
    return comparatorDirective;

  });//</_.map>


  //  ┌─┐┬ ┬┌─┐┌─┐┬┌─  ┌─┐┌─┐┬─┐  ╔╦╗╦ ╦╔═╗╦  ╦╔═╗╔═╗╔╦╗╔═╗╔═╗
  //  │  ├─┤├┤ │  ├┴┐  ├┤ │ │├┬┘   ║║║ ║╠═╝║  ║║  ╠═╣ ║ ║╣ ╚═╗
  //  └─┘┴ ┴└─┘└─┘┴ ┴  └  └─┘┴└─  ═╩╝╚═╝╩  ╩═╝╩╚═╝╩ ╩ ╩ ╚═╝╚═╝
  // Finally, check that no two comparator directives mention the
  // same target. (Because you can't sort by the same thing twice.)
  var referencedComparatorTargets = [];
  _.each(sortClause, function (comparatorDirective){

    var sortByKey = _.keys(comparatorDirective)[0];
    if (_.contains(referencedComparatorTargets, sortByKey)) {
      throw flaverr('E_SORT_CLAUSE_UNUSABLE', new Error(
        'Cannot sort by the same thing (`'+sortByKey+'`) twice!'
      ));
    }//-•

    referencedComparatorTargets.push(sortByKey);

  });//</_.each>


  // --• At this point, we know we are dealing with a properly-formatted
  // & semantically valid array of comparator directives.
  return sortClause;


};