Search code examples
javascriptarrayssortingcomparison

How to sort multi-property array-items where each item's property-value has different precedence and sort orientation rules but can be undefined too?


I have an array of employees with each employee having at least fullName, and employeeId. Some employees have crewNumber and cmpId.

This sorting happens on a NestJS server. The client has an AG-Grid which passes an array of the sorts that need to happen on the data on the server.

I am having trouble with figuring out how to sort the array on the server, on multiple "columns" or "properties", when it comes to there being numbers, strings, and in particular undefined values for some of the number properties.

const employees = [
    {
        "employeeId": "JACKAB",
        "fullName": "Jack Absolute",
        "cmpId": 2
    },
    {
        "employeeId": "BLABLA",
        "fullName": "Joe Smith"
    },
    {
        "employeeId": "FORFIVE",
        "fullName": "Tom Scott",
        "cmpId": 109
    },
    {
        "employeeId": "RONBURN",
        "fullName": "Morty Smith"
    },
];

console.log("employees before sorting: ", employees)

const sortBy = [
  { prop: 'fullName', direction: 1, sortIndex: 0 },
  { prop: 'employeeId', direction: -1, sortIndex: 1 },
  { prop: 'cmpId', direction: 1, sortIndex: 2 }
];

employees.sort(function (a, b) {
    let i = 0, result = 0;
    while (i < sortBy.length && result === 0) {

        const prop = sortBy[i].prop;
        const dataA = a[prop];
        const dataB = b[prop];
        const direction = sortBy[i].direction;

        const numberProperties = ["cmpId", "crewNumber"];

        const isNumber = numberProperties.includes(prop);

        if (dataA == undefined && dataB == undefined) {
            result = 0;
        }
        else if (dataA == undefined) {
            result = -1 * direction
        }
        else if (dataB == undefined) {
            result = direction;
        }
        else {
            if (isNumber) {
                result = direction * (dataA < dataB ? -1 : (dataA > dataB ? 1 : 0));
            }
            else {
                result = direction * Intl.Collator().compare(dataA, dataB);
            }
        }

        i++;
    }
            
    return result;
})

console.log("employees after sorting: ", employees);

If I ascend on a column with undefined values I want the undefined values at the top. If I descend on a column with undefined values I want the undefined values at the bottom.

The problem as it stands is that the while loop cancels out when result != 0, and that happens when checking if dataA is undefined, or dataB is undefined. This causes the while loop to not loop through the rest of the sorts that need to happen.

I tried returning 0 when either are undefined but then this causes undefined values to not move anywhere.


Solution

  • Since simple object items, here employee items, need to be compared by some rules which are provided as kind of config object, one really wants to solve this problem by a generic approach, thus one breaks it into small steps/tasks and provides a handy function for each task.

    The rules come as list of config objects, each providing comparison terms ...

    • Each rule provides its sort-order precedence/importance by an own sortIndex property.
    • A property name is provided, thus identifying by which key-value an object is going to be compared.
    • The direction indicates whether to sort ascending(1) or descending(-1).

    Knowing all this, one starts with writing two almost similar comparison functions which each taking a bound key configuration object into account. Thus such a function will compare, for both of its passed items, the key indicated property values.

    The functions are named compareByBoundKeyAscending and compareByBoundKeyDescending. Both try to utilize the localeCompare method of the 1st passed argument. If such a method is not available the sorting falls back to a generic comparison, either in its ascending or in its descending implementation. The two fallback functions are basicCompareAscending and basicCompareDescending.

    Now one can already start with using the first generic compare function ... The sortBy array of the OP's example code, a list of comparison terms, gets sorted ascending by the value of each of its term-item's sortIndex key ...

    sortBy
      .sort(compareByBoundKeyAscending
        .bind({
          key: 'sortIndex',
        })
      )
    

    Having available now an array of comparison terms, with each term already in the correct order of its importance, one now is going to map over each comparison term while creating from it a custom comparison function for each of a later to be compared object's key-value pair ...

    const precedenceConditionList = sortBy
      .sort(compareByBoundKeyAscending
        .bind({
          key: 'sortIndex',
        })
      )
      .map(({ prop: key, direction }) => ({
          "1": compareByBoundKeyAscending.bind({ key }),
          "-1": compareByBoundKeyDescending.bind({ key }),
        })[direction]
      );
    

    ... the precedenceConditionList itself is an array of custom comparison functions, sorted by each function's importance, where each function compares passed items either ascending or descending by dedicated property values of such items.

    Of cause one still needs a function which does actually compare something like the provided employee-items from the OP's employees array.

    This function does operate upon a bound condition list like the precedenceConditionList we just created above. Its implementation is generic too and takes advantage of the just 3 possible return values -1, 1, and 0. As long as a condition from the operated bound list does not return -1 or 1 but 0, the next available condition from the bound list needs to be invoked. Thus one easily can utilize Array.prototype.some in order to retrieve the correct return value and also to break as early as possible from looping the conditions ...

    function compareByBoundConditionList(a, b) {
      let result = 0;
      this.some(condition => {
        result = condition(a, b);
        return (result !== 0);
      })
      return result;
    }
    

    Example code ...

    // ... introduce `compareIncomparables` ...
    //
    // in order to solve the OP's problem of a meaningful
    // sorting while dealing with undefined property values.
    //
    function compareIncomparables(a, b) {
      const index = {
        'undefined':  5,
        'null':       4,
        'NaN':        3,
        'Infinity':   2,
        '-Infinity':  1,
      };
      return (index[String(a)] || 0) - (index[String(b)] || 0);
    }
    
    function basicCompareAscending(a, b) {
    //return ((a < b) && -1) || ((a > b) && 1) || 0;
      return (
    
        ((a < b) && -1) ||
        ((a > b) && 1) ||
    
        // try to furtherly handle incomparable values like ...
        // undefined, null, NaN, Infinity, -Infinity or object types.
    
        (a === b) ? 0 : compareIncomparables(a, b)
      );
    }
    function basicCompareDescending(a, b) {
    //return ((a > b) && -1) || ((a < b) && 1) || 0;
      return (
    
        ((a > b) && -1) ||
        ((a < b) && 1) ||
    
        // try to furtherly handle incomparable values like ...
        // undefined, null, NaN, Infinity, -Infinity or object types.
    
        (a === b) ? 0 : compareIncomparables(a, b)
      //(a === b) ? 0 : compareIncomparables(b, a)
      );
    }
    
    function compareByBoundKeyAscending(a, b) {
      const { key } = this;
      const aValue = a[key];
      const bValue = b[key];
    
      // take care of undefined and null values as well as of
      // other values which do not feature a `localeCompare`.
      return aValue?.localeCompare
        ? aValue.localeCompare(bValue)
        : basicCompareAscending(aValue, bValue);
    }
    function compareByBoundKeyDescending(a, b) {
      const { key } = this;
      const aValue = a[key];
      const bValue = b[key];
    
      // take care of undefined and null values as well as of
      // other values which do not feature a `localeCompare`.
      return bValue?.localeCompare
        ? bValue.localeCompare(aValue)
        : basicCompareDescending(aValue, bValue);
    }
    
    function compareByBoundConditionList(a, b) {
      let result = 0;
      this.some(condition => {
        result = condition(a, b);
        return (result !== 0);
      });
      return result;
    }
    
    
    const employees = [{
      "employeeId": "FORFIVE",
      "fullName": "Tom Scott",
    }, {
      "employeeId": "BLABLA",
      "fullName": "Joe Smith",
    }, {
      "employeeId": "FORFIVE",
      "fullName": "Tom Scott",
      "cmpId": 109
    }, {
      "employeeId": "JACKAB",
      "fullName": "Jack Absolute",
      "cmpId": 4
    }, {
      "employeeId": "JACKAB",
      "fullName": "Jack Absolute",
      "cmpId": 2
    }, {
      "employeeId": "RONBURN",
      "fullName": "Morty Smith",
    }, {
      "employeeId": "BLABLU",
      "fullName": "Joe Smith",
    }];
    
    const sortBy = [
      { prop: 'fullName', direction: 1, sortIndex: 0 },
      { prop: 'employeeId', direction: -1, sortIndex: 1 },
      { prop: 'cmpId', direction: 1, sortIndex: 2 },
    ];
    
    const precedenceConditionList = sortBy
      .sort(compareByBoundKeyAscending
        .bind({
          key: 'sortIndex',
        })
      )
      .map(({ prop: key, direction }) => ({
          "1": compareByBoundKeyAscending.bind({ key }),
          "-1": compareByBoundKeyDescending.bind({ key }),
        })[direction]
      );
    
    console.log(
      employees.sort(compareByBoundConditionList
        .bind(precedenceConditionList)
      )
    );
    .as-console-wrapper { min-height: 100%!important; top: 0; }