Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

feat(orderBy): make orderBy filter accept a custom compare function. #10368

Closed
wants to merge 1 commit into from
Closed

Conversation

Rowanto
Copy link

@Rowanto Rowanto commented Dec 8, 2014

Expose the inner compare function used by orderBy to the outside world.
This will make it possible to use orderBy with a very specific sort requirement like sorting using the localeCompare or making a few entries appear first in the list.

@googlebot
Copy link

We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for the commit author(s). If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA (login here to double check)? If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.

@googlebot
Copy link

CLAs look good, thanks!

@googlebot googlebot added cla: yes and removed cla: no labels Dec 8, 2014
}, descending);
}
}
return reverseComparator(function(a, b) {
return compare(get(a),get(b));
return compare(get(a),get(b),defaultCompare);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it was like this before, but there should be spaces between the arguments (according to the current JSCS rules).

@Rowanto
Copy link
Author

Rowanto commented Dec 8, 2014

@gkalpak
Fixed. Amended. Pushed.
Thanks, and please notify me again if something is still missing.

@caitp
Copy link
Contributor

caitp commented Dec 8, 2014

I can see how this would be useful for helping people write a custom filter + same algorithm

Expose the inner compare function used by orderBy to the outside world.
This will make it possible to use orderBy with a very specific sort requirement.
@elad
Copy link

elad commented Aug 31, 2015

Yes please, this would be great for i18n support.

@elad
Copy link

elad commented Aug 31, 2015

Okay, in case it helps anyone, here's a quick and dirty solution. I took the orderBy code and slightly modified it to support locale-aware string sorting. I expose a method to let the filter know which locale to use. If the method's unused, it should fallback to the browser's locale. I also introduced a new notation, the @ symbol, that indicates "this string is to be sorted by locale." (Open to alternatives...)

For brevity, controller usage:

angular.module('exampleApp', ['orderByLocaleAware'])
.controller('exampleController', function ($scope, orderByLocaleAwareId) {
  $scope.setLocaleId('he-IL');

  $scope.sortColumns = ['@+firstName', '@-lastName'];

  $scope.data = [
    { firstName: 'Elad', lastName: 'Efrat' },
    { firstName: 'אלעד', lastName: 'אפרת' }
  ];
});

and filter usage:

<div ng-controller="exampleController">
  <table>       
    <tr ng-repeat="record in data | orderByLocaleAware:sortColumns">
      <td>{{ record.firstName }}</td>
      <td>{{ record.lastName }}</td>
    </tr>
  </table>
</div>

I'm going to use it in my Angular i18n/l10n helper module, angular-kencale, but I can release it as a standalone module as well.

Here's a full example you can play with, just copy/paste to (say) example.html and open in a browser:

<!doctype html>
<html ng-app="exampleApp">
  <head>
    <meta charset="utf-8">

    <style>
      .strong { font-weight: bold };
    </style>

    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.3/angular.min.js"></script>

    <script type="text/javascript">
      angular.module('exampleApp', ['orderByLocaleAware'])
      .controller('exampleController', function ($scope, orderByLocaleAwareId) {
        $scope.setLocaleId = function(localeId) {
          orderByLocaleAwareId.localeId = localeId;
        }

        $scope.getLocaleId = function() {
          return orderByLocaleAwareId.localeId;
        }

        // Set an initial locale.
        $scope.setLocaleId('he-IL');

        // The '@' symbol indicates the string is to be ordered by locale.
        $scope.sortColumns = ['@firstName', '@lastName'];

        // Sample data. For he-IL, Hebrew names should appear first. For en-US, English names.
        $scope.data = [
          { firstName: 'Elad', lastName: 'Efrat' },
          { firstName: 'אלעד', lastName: 'אפרת' }
        ];
      });
    </script>
  </head>

  <body>
    <div ng-controller="exampleController">
      <p>
        Locale:
        <a href="#" ng-click="setLocaleId('en-US')" ng-class="{ strong: getLocaleId() === 'en-US' }">en-US</a>
        <a href="#" ng-click="setLocaleId('he-IL')" ng-class="{ strong: getLocaleId() === 'he-IL' }">he-IL</a>
      </p>

      <table>       
        <tr ng-repeat="record in data | orderByLocaleAware:sortColumns">
          <td>{{ record.firstName }}</td>
          <td>{{ record.lastName }}</td>
        </tr>
      </table>
    </div>

    <!-- The module, copied almost verbatim from AngularJS. -->
    <script type="text/javascript">
      angular.module('orderByLocaleAware', [])
      .service('orderByLocaleAwareId', function () {
        this.localeId = null;
      })
      .filter('orderByLocaleAware', function ($parse, orderByLocaleAwareId) {
        return function(array, sortPredicate, reverseOrder) {
          if (!array || !angular.isArray(array)) return array;

          if (!angular.isArray(sortPredicate)) { sortPredicate = [sortPredicate]; }
          if (sortPredicate.length === 0) { sortPredicate = ['+']; }

          var predicates = processPredicates(sortPredicate, reverseOrder);
          // Add a predicate at the end that evaluates to the element index. This makes the
          // sort stable as it works as a tie-breaker when all the input predicates cannot
          // distinguish between two elements.
          predicates.push({ get: function() { return {}; }, descending: reverseOrder ? -1 : 1});

          // The next three lines are a version of a Swartzian Transform idiom from Perl
          // (sometimes called the Decorate-Sort-Undecorate idiom)
          // See https://en.wikipedia.org/wiki/Schwartzian_transform
          var compareValues = Array.prototype.map.call(array, getComparisonObject);
          compareValues.sort(doComparison);
          array = compareValues.map(function(item) { return item.value; });

          return array;

          function getComparisonObject(value, index) {
            return {
              value: value,
              predicateValues: predicates.map(function(predicate) {
                return getPredicateValue(predicate.get(value), index);
              })
            };
          }

          function doComparison(v1, v2) {
            var result = 0;
            for (var index=0, length = predicates.length; index < length; ++index) {
              result = compare(v1.predicateValues[index], v2.predicateValues[index], predicates[index].localeAware) * predicates[index].descending;
              if (result) break;
            }
            return result;
          }
        };

        function processPredicates(sortPredicate, reverseOrder) {
          reverseOrder = reverseOrder ? -1 : 1;
          return sortPredicate.map(function(predicate) {
            var descending = 1, get = angular.identity, localeAware = false;

            if (angular.isFunction(predicate)) {
              get = predicate;
            } else if (angular.isString(predicate)) {
              if (predicate.charAt(0) == '@') {
                localeAware = true;
                predicate = predicate.substring(1);
              }
              if ((predicate.charAt(0) == '+' || predicate.charAt(0) == '-')) {
                descending = predicate.charAt(0) == '-' ? -1 : 1;
                predicate = predicate.substring(1);
              }
              if (predicate !== '') {
                get = $parse(predicate);
                if (get.constant) {
                  var key = get();
                  get = function(value) { return value[key]; };
                }
              }
            }
            return { get: get, descending: descending * reverseOrder, localeAware: localeAware };
          });
        }

        function isPrimitive(value) {
          switch (typeof value) {
            case 'number': /* falls through */
            case 'boolean': /* falls through */
            case 'string':
              return true;
            default:
              return false;
          }
        }

        function objectValue(value, index) {
          // If `valueOf` is a valid function use that
          if (typeof value.valueOf === 'function') {
            value = value.valueOf();
            if (isPrimitive(value)) return value;
          }
          // If `toString` is a valid function and not the one from `Object.prototype` use that
          if (hasCustomToString(value)) {
            value = value.toString();
            if (isPrimitive(value)) return value;
          }
          // We have a basic object so we use the position of the object in the collection
          return index;
        }

        function getPredicateValue(value, index) {
          var type = typeof value;
          if (value === null) {
            type = 'string';
            value = 'null';
          } else if (type === 'string') {
            value = value.toLowerCase();
          } else if (type === 'object') {
            value = objectValue(value, index);
          }
          return { value: value, type: type };
        }

        function compare(v1, v2, localeAware) {
          var result = 0;
          if (v1.type === v2.type) {
            if (v1.value !== v2.value) {
              result = (localeAware && v1.type === 'string') ? v1.value.localeCompare(v2.value, orderByLocaleAwareId.localeId) : (v1.value < v2.value ? -1 : 1);
            }
          } else {
            result = v1.type < v2.type ? -1 : 1;
          }
          return result;
        }

        function hasCustomToString(obj) {
          return angular.isFunction(obj.toString) && obj.toString !== Object.prototype.toString;
        }
      });
    </script>
  </body>
</html>

@jrencz
Copy link

jrencz commented Dec 11, 2015

👍

@jrencz
Copy link

jrencz commented Dec 12, 2015

I think that in addition to being able to set compare function as another filter argument there should be a possibility to set a default compare function with a provider (optimally: also to pass a service or a service name just as we can do with $http interceptors).

I'm not convinced compare function should be another argument.
It should probably extend the 3nd argument by either replacing the reverse argument (which is backwards incompatible) or allowing 3nd argument to be a function. reverse is just a special case of comparison where returned values are multiplied by -1. I suggest that if a function is passed as the 3nd argument it should be treated as a compare function and if a boolean is passed then it will be treated as reverse flag to maintain backwards compatibility.
Support for boolean flag should probably be marked as deprecated and eventually removed for simplicity.

@Maaartinus
Copy link

I've just tried it and it works well.... but very slow: a few seconds for 2000 elements. According to MDN Intl.Collator should be used. I tried it and it helped a lot (nearly back to always instant rendering).

gkalpak added a commit to gkalpak/angular.js that referenced this pull request Apr 19, 2016
With this change the 3rd argument is interpreted as a comparator function, used to compare the
values returned by the predicates. Leaving it empty falls back to the default comparator and a
value of `false` is a special case that also falls back to the default comparator but reverses the
order. Thus the new implementation is backwards compatible.

Helps with angular#12572 (maybe this is as close as we want to get).

Fixes angular#13238
Fixes angular#14455

Closes angular#5123
Closes angular#8112
Closes angular#10368
gkalpak added a commit to gkalpak/angular.js that referenced this pull request Apr 21, 2016
With this change the 3rd argument is interpreted as a comparator function, used to compare the
values returned by the predicates. Leaving it empty falls back to the default comparator and a
value of `false` is a special case that also falls back to the default comparator but reverses the
order. Thus the new implementation is backwards compatible.

This commit also xpands the documentation to cover the algorithm used to sort elements and adds
a few more unit and e2e tests (unrelated to the change).

Helps with angular#12572 (maybe this is as close as we want to get).

Fixes angular#13238
Fixes angular#14455

Closes angular#5123
Closes angular#8112
Closes angular#10368
gkalpak added a commit to gkalpak/angular.js that referenced this pull request Apr 21, 2016
With this change the 3rd argument is interpreted as a comparator function, used to compare the
values returned by the predicates. Leaving it empty falls back to the default comparator and a
value of `false` is a special case that also falls back to the default comparator but reverses the
order. Thus the new implementation is backwards compatible.

This commit also expands the documentation to cover the algorithm used to sort elements and adds
a few more unit and e2e tests (unrelated to the change).

Helps with angular#12572 (maybe this is as close as we want to get).

Fixes angular#13238
Fixes angular#14455

Closes angular#5123
Closes angular#8112
Closes angular#10368
@gkalpak gkalpak closed this in 48c8b23 Jun 6, 2016
gkalpak added a commit that referenced this pull request Jun 6, 2016
Add an optional, 4th argument (`comparator`) for specifying a custom comparator function, used to
compare the values returned by the predicates. Omitting the argument, falls back to the default,
built-in comparator. The 3rd argument (`reverse`) can still be used for controlling the sorting
order (i.e. ascending/descending).

Additionally, the documentation has been expanded to cover the algorithm used by the built-in
comparator and a few more unit and e2e tests (unrelated to the change) have been added.

Helps with #12572 (maybe this is as close as we want to get).

Fixes #13238
Fixes #14455

Closes #5123
Closes #8112
Closes #10368

Closes #14468
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants