feat(orderBy): add support for custom comparators

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
This commit is contained in:
Georgios Kalpakas
2016-04-19 20:31:24 +03:00
parent 743bfcfe08
commit 48c8b230cb
2 changed files with 878 additions and 182 deletions
+534 -159
View File
@@ -6,44 +6,128 @@
* @kind function
*
* @description
* Orders a specified `array` by the `expression` predicate. It is ordered alphabetically
* for strings and numerically for numbers. Note: if you notice numbers are not being sorted
* as expected, make sure they are actually being saved as numbers and not strings.
* Array-like values (e.g. NodeLists, jQuery objects, TypedArrays, Strings, etc) are also supported.
* Returns an array containing the items from the specified `collection`, ordered by a `comparator`
* function based on the values computed using the `expression` predicate.
*
* @param {Array} array The array (or array-like object) to sort.
* @param {function(*)|string|Array.<(function(*)|string)>=} expression A predicate to be
* used by the comparator to determine the order of elements.
* For example, `[{id: 'foo'}, {id: 'bar'}] | orderBy:'id'` would result in
* `[{id: 'bar'}, {id: 'foo'}]`.
*
* The `collection` can be an Array or array-like object (e.g. NodeList, jQuery object, TypedArray,
* String, etc).
*
* The `expression` can be a single predicate, or a list of predicates each serving as a tie-breaker
* for the preceeding one. The `expression` is evaluated against each item and the output is used
* for comparing with other items.
*
* You can change the sorting order by setting `reverse` to `true`. By default, items are sorted in
* ascending order.
*
* The comparison is done using the `comparator` function. If none is specified, a default, built-in
* comparator is used (see below for details - in a nutshell, it compares numbers numerically and
* strings alphabetically).
*
* ### Under the hood
*
* Ordering the specified `collection` happens in two phases:
*
* 1. All items are passed through the predicate (or predicates), and the returned values are saved
* along with their type (`string`, `number` etc). For example, an item `{label: 'foo'}`, passed
* through a predicate that extracts the value of the `label` property, would be transformed to:
* ```
* {
* value: 'foo',
* type: 'string',
* index: ...
* }
* ```
* 2. The comparator function is used to sort the items, based on the derived values, types and
* indices.
*
* If you use a custom comparator, it will be called with pairs of objects of the form
* `{value: ..., type: '...', index: ...}` and is expected to return `0` if the objects are equal
* (as far as the comparator is concerned), `-1` if the 1st one should be ranked higher than the
* second, or `1` otherwise.
*
* In order to ensure that the sorting will be deterministic across platforms, if none of the
* specified predicates can distinguish between two items, `orderBy` will automatically introduce a
* dummy predicate that returns the item's index as `value`.
* (If you are using a custom comparator, make sure it can handle this predicate as well.)
*
* Finally, in an attempt to simplify things, if a predicate returns an object as the extracted
* value for an item, `orderBy` will try to convert that object to a primitive value, before passing
* it to the comparator. The following rules govern the conversion:
*
* 1. If the object has a `valueOf()` method that returns a primitive, its return value will be
* used instead.<br />
* (If the object has a `valueOf()` method that returns another object, then the returned object
* will be used in subsequent steps.)
* 2. If the object has a custom `toString()` method (i.e. not the one inherited from `Object`) that
* returns a primitive, its return value will be used instead.<br />
* (If the object has a `toString()` method that returns another object, then the returned object
* will be used in subsequent steps.)
* 3. No conversion; the object itself is used.
*
* ### The default comparator
*
* The default, built-in comparator should be sufficient for most usecases. In short, it compares
* numbers numerically, strings alphabetically (and case-insensitively), for objects falls back to
* using their index in the original collection, and sorts values of different types by type.
*
* More specifically, it follows these steps to determine the relative order of items:
*
* 1. If the compared values are of different types, compare the types themselves alphabetically.
* 2. If both values are of type `string`, compare them alphabetically in a case- and
* locale-insensitive way.
* 3. If both values are objects, compare their indices instead.
* 4. Otherwise, return:
* - `0`, if the values are equal (by strict equality comparison, i.e. using `===`).
* - `-1`, if the 1st value is "less than" the 2nd value (compared using the `<` operator).
* - `1`, otherwise.
*
* **Note:** If you notice numbers not being sorted as expected, make sure they are actually being
* saved as numbers and not strings.
*
* @param {Array|ArrayLike} collection - The collection (array or array-like object) to sort.
* @param {(Function|string|Array.<Function|string>)=} expression - A predicate (or list of
* predicates) to be used by the comparator to determine the order of elements.
*
* Can be one of:
*
* - `function`: Getter function. The result of this function will be sorted using the
* `<`, `===`, `>` operator.
* - `string`: An Angular expression. The result of this expression is used to compare elements
* (for example `name` to sort by a property called `name` or `name.substr(0, 3)` to sort by
* 3 first characters of a property called `name`). The result of a constant expression
* is interpreted as a property name to be used in comparisons (for example `"special name"`
* to sort object by the value of their `special name` property). An expression can be
* optionally prefixed with `+` or `-` to control ascending or descending sort order
* (for example, `+name` or `-name`). If no property is provided, (e.g. `'+'`) then the array
* element itself is used to compare where sorting.
* - `Array`: An array of function or string predicates. The first predicate in the array
* is used for sorting, but when two items are equivalent, the next predicate is used.
* - `Function`: A getter function. This function will be called with each item as argument and
* the return value will be used for sorting.
* - `string`: An Angular expression. This expression will be evaluated against each item and the
* result will be used for sorting. For example, use `'label'` to sort by a property called
* `label` or `'label.substring(0, 3)'` to sort by the first 3 characters of the `label`
* property.<br />
* (The result of a constant expression is interpreted as a property name to be used for
* comparison. For example, use `'"special name"'` (note the extra pair of quotes) to sort by a
* property called `special name`.)<br />
* An expression can be optionally prefixed with `+` or `-` to control the sorting direction,
* ascending or descending. For example, `'+label'` or `'-label'`. If no property is provided,
* (e.g. `'+'` or `'-'`), the collection element itself is used in comparisons.
* - `Array`: An array of function and/or string predicates. If a predicate cannot determine the
* relative order of two items, the next predicate is used as a tie-breaker.
*
* If the predicate is missing or empty then it defaults to `'+'`.
* **Note:** If the predicate is missing or empty then it defaults to `'+'`.
*
* @param {boolean=} reverse Reverse the order of the array.
* @returns {Array} Sorted copy of the source array.
* @param {boolean=} reverse - If `true`, reverse the sorting order.
* @param {(Function)=} comparator - The comparator function used to determine the relative order of
* value pairs. If omitted, the built-in comparator will be used.
*
* @returns {Array} - The sorted array.
*
*
* @example
* The example below demonstrates a simple ngRepeat, where the data is sorted
* by age in descending order (predicate is set to `'-age'`).
* `reverse` is not set, which means it defaults to `false`.
<example module="orderByExample">
* ### Ordering a table with `ngRepeat`
*
* The example below demonstrates a simple {@link ngRepeat ngRepeat}, where the data is sorted by
* age in descending order (expression is set to `'-age'`). The `comparator` is not set, which means
* it defaults to the built-in comparator.
*
<example name="orderBy-static" module="orderByExample1">
<file name="index.html">
<div ng-controller="ExampleController">
<table class="friend">
<table class="friends">
<tr>
<th>Name</th>
<th>Phone Number</th>
@@ -58,43 +142,77 @@
</div>
</file>
<file name="script.js">
angular.module('orderByExample', [])
angular.module('orderByExample1', [])
.controller('ExampleController', ['$scope', function($scope) {
$scope.friends =
[{name:'John', phone:'555-1212', age:10},
{name:'Mary', phone:'555-9876', age:19},
{name:'Mike', phone:'555-4321', age:21},
{name:'Adam', phone:'555-5678', age:35},
{name:'Julie', phone:'555-8765', age:29}];
$scope.friends = [
{name: 'John', phone: '555-1212', age: 10},
{name: 'Mary', phone: '555-9876', age: 19},
{name: 'Mike', phone: '555-4321', age: 21},
{name: 'Adam', phone: '555-5678', age: 35},
{name: 'Julie', phone: '555-8765', age: 29}
];
}]);
</file>
<file name="style.css">
.friends {
border-collapse: collapse;
}
.friends th {
border-bottom: 1px solid;
}
.friends td, .friends th {
border-left: 1px solid;
padding: 5px 10px;
}
.friends td:first-child, .friends th:first-child {
border-left: none;
}
</file>
<file name="protractor.js" type="protractor">
// Element locators
var names = element.all(by.repeater('friends').column('friend.name'));
it('should sort friends by age in reverse order', function() {
expect(names.get(0).getText()).toBe('Adam');
expect(names.get(1).getText()).toBe('Julie');
expect(names.get(2).getText()).toBe('Mike');
expect(names.get(3).getText()).toBe('Mary');
expect(names.get(4).getText()).toBe('John');
});
</file>
</example>
* <hr />
*
* The predicate and reverse parameters can be controlled dynamically through scope properties,
* as shown in the next example.
* @example
<example module="orderByExample">
* ### Changing parameters dynamically
*
* All parameters can be changed dynamically. The next example shows how you can make the columns of
* a table sortable, by binding the `expression` and `reverse` parameters to scope properties.
*
<example name="orderBy-dynamic" module="orderByExample2">
<file name="index.html">
<div ng-controller="ExampleController">
<pre>Sorting predicate = {{predicate}}; reverse = {{reverse}}</pre>
<pre>Sort by = {{propertyName}}; reverse = {{reverse}}</pre>
<hr/>
<button ng-click="predicate=''">Set to unsorted</button>
<table class="friend">
<button ng-click="propertyName = null; reverse = false">Set to unsorted</button>
<hr/>
<table class="friends">
<tr>
<th>
<button ng-click="order('name')">Name</button>
<span class="sortorder" ng-show="predicate === 'name'" ng-class="{reverse:reverse}"></span>
</th>
<th>
<button ng-click="order('phone')">Phone Number</button>
<span class="sortorder" ng-show="predicate === 'phone'" ng-class="{reverse:reverse}"></span>
</th>
<th>
<button ng-click="order('age')">Age</button>
<span class="sortorder" ng-show="predicate === 'age'" ng-class="{reverse:reverse}"></span>
</th>
<th>
<button ng-click="sortBy('name')">Name</button>
<span class="sortorder" ng-show="propertyName === 'name'" ng-class="{reverse: reverse}"></span>
</th>
<th>
<button ng-click="sortBy('phone')">Phone Number</button>
<span class="sortorder" ng-show="propertyName === 'phone'" ng-class="{reverse: reverse}"></span>
</th>
<th>
<button ng-click="sortBy('age')">Age</button>
<span class="sortorder" ng-show="propertyName === 'age'" ng-class="{reverse: reverse}"></span>
</th>
</tr>
<tr ng-repeat="friend in friends | orderBy:predicate:reverse">
<tr ng-repeat="friend in friends | orderBy:propertyName:reverse">
<td>{{friend.name}}</td>
<td>{{friend.phone}}</td>
<td>{{friend.age}}</td>
@@ -103,100 +221,335 @@
</div>
</file>
<file name="script.js">
angular.module('orderByExample', [])
angular.module('orderByExample2', [])
.controller('ExampleController', ['$scope', function($scope) {
$scope.friends =
[{name:'John', phone:'555-1212', age:10},
{name:'Mary', phone:'555-9876', age:19},
{name:'Mike', phone:'555-4321', age:21},
{name:'Adam', phone:'555-5678', age:35},
{name:'Julie', phone:'555-8765', age:29}];
$scope.predicate = 'age';
var friends = [
{name: 'John', phone: '555-1212', age: 10},
{name: 'Mary', phone: '555-9876', age: 19},
{name: 'Mike', phone: '555-4321', age: 21},
{name: 'Adam', phone: '555-5678', age: 35},
{name: 'Julie', phone: '555-8765', age: 29}
];
$scope.propertyName = 'age';
$scope.reverse = true;
$scope.order = function(predicate) {
$scope.reverse = ($scope.predicate === predicate) ? !$scope.reverse : false;
$scope.predicate = predicate;
$scope.friends = friends;
$scope.sortBy = function(propertyName) {
$scope.reverse = ($scope.propertyName === propertyName) ? !$scope.reverse : false;
$scope.propertyName = propertyName;
};
}]);
</file>
</file>
<file name="style.css">
.friends {
border-collapse: collapse;
}
.friends th {
border-bottom: 1px solid;
}
.friends td, .friends th {
border-left: 1px solid;
padding: 5px 10px;
}
.friends td:first-child, .friends th:first-child {
border-left: none;
}
.sortorder:after {
content: '\25b2';
content: '\25b2'; // BLACK UP-POINTING TRIANGLE
}
.sortorder.reverse:after {
content: '\25bc';
content: '\25bc'; // BLACK DOWN-POINTING TRIANGLE
}
</file>
<file name="protractor.js" type="protractor">
// Element locators
var unsortButton = element(by.partialButtonText('unsorted'));
var nameHeader = element(by.partialButtonText('Name'));
var phoneHeader = element(by.partialButtonText('Phone'));
var ageHeader = element(by.partialButtonText('Age'));
var firstName = element(by.repeater('friends').column('friend.name').row(0));
var lastName = element(by.repeater('friends').column('friend.name').row(4));
it('should sort friends by some property, when clicking on the column header', function() {
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('John');
phoneHeader.click();
expect(firstName.getText()).toBe('John');
expect(lastName.getText()).toBe('Mary');
nameHeader.click();
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('Mike');
ageHeader.click();
expect(firstName.getText()).toBe('John');
expect(lastName.getText()).toBe('Adam');
});
it('should sort friends in reverse order, when clicking on the same column', function() {
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('John');
ageHeader.click();
expect(firstName.getText()).toBe('John');
expect(lastName.getText()).toBe('Adam');
ageHeader.click();
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('John');
});
it('should restore the original order, when clicking "Set to unsorted"', function() {
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('John');
unsortButton.click();
expect(firstName.getText()).toBe('John');
expect(lastName.getText()).toBe('Julie');
});
</file>
</example>
* <hr />
*
* @example
* ### Using `orderBy` inside a controller
*
* It is also possible to call the `orderBy` filter manually, by injecting `orderByFilter`, and
* calling it with the desired parameters. (Alternatively, you could inject the `$filter` factory
* and retrieve the `orderBy` filter with `$filter('orderBy')`.)
*
<example name="orderBy-call-manually" module="orderByExample3">
<file name="index.html">
<div ng-controller="ExampleController">
<pre>Sort by = {{propertyName}}; reverse = {{reverse}}</pre>
<hr/>
<button ng-click="sortBy(null)">Set to unsorted</button>
<hr/>
<table class="friends">
<tr>
<th>
<button ng-click="sortBy('name')">Name</button>
<span class="sortorder" ng-show="propertyName === 'name'" ng-class="{reverse: reverse}"></span>
</th>
<th>
<button ng-click="sortBy('phone')">Phone Number</button>
<span class="sortorder" ng-show="propertyName === 'phone'" ng-class="{reverse: reverse}"></span>
</th>
<th>
<button ng-click="sortBy('age')">Age</button>
<span class="sortorder" ng-show="propertyName === 'age'" ng-class="{reverse: reverse}"></span>
</th>
</tr>
<tr ng-repeat="friend in friends">
<td>{{friend.name}}</td>
<td>{{friend.phone}}</td>
<td>{{friend.age}}</td>
</tr>
</table>
</div>
</file>
<file name="script.js">
angular.module('orderByExample3', [])
.controller('ExampleController', ['$scope', 'orderByFilter', function($scope, orderBy) {
var friends = [
{name: 'John', phone: '555-1212', age: 10},
{name: 'Mary', phone: '555-9876', age: 19},
{name: 'Mike', phone: '555-4321', age: 21},
{name: 'Adam', phone: '555-5678', age: 35},
{name: 'Julie', phone: '555-8765', age: 29}
];
$scope.propertyName = 'age';
$scope.reverse = true;
$scope.friends = orderBy(friends, $scope.propertyName, $scope.reverse);
$scope.sortBy = function(propertyName) {
$scope.reverse = (propertyName !== null && $scope.propertyName === propertyName)
? !$scope.reverse : false;
$scope.propertyName = propertyName;
$scope.friends = orderBy(friends, $scope.propertyName, $scope.reverse);
};
}]);
</file>
<file name="style.css">
.friends {
border-collapse: collapse;
}
.friends th {
border-bottom: 1px solid;
}
.friends td, .friends th {
border-left: 1px solid;
padding: 5px 10px;
}
.friends td:first-child, .friends th:first-child {
border-left: none;
}
.sortorder:after {
content: '\25b2'; // BLACK UP-POINTING TRIANGLE
}
.sortorder.reverse:after {
content: '\25bc'; // BLACK DOWN-POINTING TRIANGLE
}
</file>
<file name="protractor.js" type="protractor">
// Element locators
var unsortButton = element(by.partialButtonText('unsorted'));
var nameHeader = element(by.partialButtonText('Name'));
var phoneHeader = element(by.partialButtonText('Phone'));
var ageHeader = element(by.partialButtonText('Age'));
var firstName = element(by.repeater('friends').column('friend.name').row(0));
var lastName = element(by.repeater('friends').column('friend.name').row(4));
it('should sort friends by some property, when clicking on the column header', function() {
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('John');
phoneHeader.click();
expect(firstName.getText()).toBe('John');
expect(lastName.getText()).toBe('Mary');
nameHeader.click();
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('Mike');
ageHeader.click();
expect(firstName.getText()).toBe('John');
expect(lastName.getText()).toBe('Adam');
});
it('should sort friends in reverse order, when clicking on the same column', function() {
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('John');
ageHeader.click();
expect(firstName.getText()).toBe('John');
expect(lastName.getText()).toBe('Adam');
ageHeader.click();
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('John');
});
it('should restore the original order, when clicking "Set to unsorted"', function() {
expect(firstName.getText()).toBe('Adam');
expect(lastName.getText()).toBe('John');
unsortButton.click();
expect(firstName.getText()).toBe('John');
expect(lastName.getText()).toBe('Julie');
});
</file>
</example>
* <hr />
*
* @example
* ### Using a custom comparator
*
* If you have very specific requirements about the way items are sorted, you can pass your own
* comparator function. For example, you might need to compare some strings in a locale-sensitive
* way. (When specifying a custom comparator, you also need to pass a value for the `reverse`
* argument - passing `false` retains the default sorting order, i.e. ascending.)
*
<example name="orderBy-custom-comparator" module="orderByExample4">
<file name="index.html">
<div ng-controller="ExampleController">
<div class="friends-container custom-comparator">
<h3>Locale-sensitive Comparator</h3>
<table class="friends">
<tr>
<th>Name</th>
<th>Favorite Letter</th>
</tr>
<tr ng-repeat="friend in friends | orderBy:'favoriteLetter':false:localeSensitiveComparator">
<td>{{friend.name}}</td>
<td>{{friend.favoriteLetter}}</td>
</tr>
</table>
</div>
<div class="friends-container default-comparator">
<h3>Default Comparator</h3>
<table class="friends">
<tr>
<th>Name</th>
<th>Favorite Letter</th>
</tr>
<tr ng-repeat="friend in friends | orderBy:'favoriteLetter'">
<td>{{friend.name}}</td>
<td>{{friend.favoriteLetter}}</td>
</tr>
</table>
</div>
</div>
</file>
<file name="script.js">
angular.module('orderByExample4', [])
.controller('ExampleController', ['$scope', function($scope) {
$scope.friends = [
{name: 'John', favoriteLetter: 'Ä'},
{name: 'Mary', favoriteLetter: 'Ü'},
{name: 'Mike', favoriteLetter: 'Ö'},
{name: 'Adam', favoriteLetter: 'H'},
{name: 'Julie', favoriteLetter: 'Z'}
];
$scope.localeSensitiveComparator = function(v1, v2) {
// If we don't get strings, just compare by index
if (v1.type !== 'string' || v2.type !== 'string') {
return (v1.index < v2.index) ? -1 : 1;
}
// Compare strings alphabetically, taking locale into account
return v1.value.localeCompare(v2.value);
};
}]);
</file>
<file name="style.css">
.friends-container {
display: inline-block;
margin: 0 30px;
}
.friends {
border-collapse: collapse;
}
.friends th {
border-bottom: 1px solid;
}
.friends td, .friends th {
border-left: 1px solid;
padding: 5px 10px;
}
.friends td:first-child, .friends th:first-child {
border-left: none;
}
</file>
<file name="protractor.js" type="protractor">
// Element locators
var container = element(by.css('.custom-comparator'));
var names = container.all(by.repeater('friends').column('friend.name'));
it('should sort friends by favorite letter (in correct alphabetical order)', function() {
expect(names.get(0).getText()).toBe('John');
expect(names.get(1).getText()).toBe('Adam');
expect(names.get(2).getText()).toBe('Mike');
expect(names.get(3).getText()).toBe('Mary');
expect(names.get(4).getText()).toBe('Julie');
});
</file>
</example>
*
* It's also possible to call the orderBy filter manually, by injecting `$filter`, retrieving the
* filter routine with `$filter('orderBy')`, and calling the returned filter routine with the
* desired parameters.
*
* Example:
*
* @example
<example module="orderByExample">
<file name="index.html">
<div ng-controller="ExampleController">
<pre>Sorting predicate = {{predicate}}; reverse = {{reverse}}</pre>
<table class="friend">
<tr>
<th>
<button ng-click="order('name')">Name</button>
<span class="sortorder" ng-show="predicate === 'name'" ng-class="{reverse:reverse}"></span>
</th>
<th>
<button ng-click="order('phone')">Phone Number</button>
<span class="sortorder" ng-show="predicate === 'phone'" ng-class="{reverse:reverse}"></span>
</th>
<th>
<button ng-click="order('age')">Age</button>
<span class="sortorder" ng-show="predicate === 'age'" ng-class="{reverse:reverse}"></span>
</th>
</tr>
<tr ng-repeat="friend in friends">
<td>{{friend.name}}</td>
<td>{{friend.phone}}</td>
<td>{{friend.age}}</td>
</tr>
</table>
</div>
</file>
<file name="script.js">
angular.module('orderByExample', [])
.controller('ExampleController', ['$scope', '$filter', function($scope, $filter) {
var orderBy = $filter('orderBy');
$scope.friends = [
{ name: 'John', phone: '555-1212', age: 10 },
{ name: 'Mary', phone: '555-9876', age: 19 },
{ name: 'Mike', phone: '555-4321', age: 21 },
{ name: 'Adam', phone: '555-5678', age: 35 },
{ name: 'Julie', phone: '555-8765', age: 29 }
];
$scope.order = function(predicate) {
$scope.predicate = predicate;
$scope.reverse = ($scope.predicate === predicate) ? !$scope.reverse : false;
$scope.friends = orderBy($scope.friends, predicate, $scope.reverse);
};
$scope.order('age', true);
}]);
</file>
<file name="style.css">
.sortorder:after {
content: '\25b2';
}
.sortorder.reverse:after {
content: '\25bc';
}
</file>
</example>
*/
orderByFilter.$inject = ['$parse'];
function orderByFilter($parse) {
return function(array, sortPredicate, reverseOrder) {
return function(array, sortPredicate, reverseOrder, compareFn) {
if (array == null) return array;
if (!isArrayLike(array)) {
@@ -206,11 +559,12 @@ function orderByFilter($parse) {
if (!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});
var predicates = processPredicates(sortPredicate);
var descending = reverseOrder ? -1 : 1;
// Define the `compare()` function. Use a default comparator if none is specified.
var compare = isFunction(compareFn) ? compareFn : defaultCompare;
// The next three lines are a version of a Swartzian Transform idiom from Perl
// (sometimes called the Decorate-Sort-Undecorate idiom)
@@ -222,8 +576,12 @@ function orderByFilter($parse) {
return array;
function getComparisonObject(value, index) {
// NOTE: We are adding an extra `tieBreaker` value based on the element's index.
// This will be used to keep the sort stable when none of the input predicates can
// distinguish between two elements.
return {
value: value,
tieBreaker: {value: index, type: 'number', index: index},
predicateValues: predicates.map(function(predicate) {
return getPredicateValue(predicate.get(value), index);
})
@@ -231,18 +589,19 @@ function orderByFilter($parse) {
}
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].descending;
if (result) break;
for (var i = 0, ii = predicates.length; i < ii; i++) {
var result = compare(v1.predicateValues[i], v2.predicateValues[i]);
if (result) {
return result * predicates[i].descending * descending;
}
}
return result;
return compare(v1.tieBreaker, v2.tieBreaker) * descending;
}
};
function processPredicates(sortPredicate, reverseOrder) {
reverseOrder = reverseOrder ? -1 : 1;
return sortPredicate.map(function(predicate) {
function processPredicates(sortPredicates) {
return sortPredicates.map(function(predicate) {
var descending = 1, get = identity;
if (isFunction(predicate)) {
@@ -260,7 +619,7 @@ function orderByFilter($parse) {
}
}
}
return { get: get, descending: descending * reverseOrder };
return {get: get, descending: descending};
});
}
@@ -275,9 +634,9 @@ function orderByFilter($parse) {
}
}
function objectValue(value, index) {
function objectValue(value) {
// If `valueOf` is a valid function use that
if (typeof value.valueOf === 'function') {
if (isFunction(value.valueOf)) {
value = value.valueOf();
if (isPrimitive(value)) return value;
}
@@ -286,8 +645,8 @@ function orderByFilter($parse) {
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;
return value;
}
function getPredicateValue(value, index) {
@@ -295,23 +654,39 @@ function orderByFilter($parse) {
if (value === null) {
type = 'string';
value = 'null';
} else if (type === 'string') {
value = value.toLowerCase();
} else if (type === 'object') {
value = objectValue(value, index);
value = objectValue(value);
}
return { value: value, type: type };
return {value: value, type: type, index: index};
}
function compare(v1, v2) {
function defaultCompare(v1, v2) {
var result = 0;
if (v1.type === v2.type) {
if (v1.value !== v2.value) {
result = v1.value < v2.value ? -1 : 1;
var type1 = v1.type;
var type2 = v2.type;
if (type1 === type2) {
var value1 = v1.value;
var value2 = v2.value;
if (type1 === 'string') {
// Compare strings case-insensitively
value1 = value1.toLowerCase();
value2 = value2.toLowerCase();
} else if (type1 === 'object') {
// For basic objects, use the position of the object
// in the collection instead of the value
if (isObject(value1)) value1 = v1.index;
if (isObject(value2)) value2 = v2.index;
}
if (value1 !== value2) {
result = value1 < value2 ? -1 : 1;
}
} else {
result = v1.type < v2.type ? -1 : 1;
result = type1 < type2 ? -1 : 1;
}
return result;
}
}
+344 -23
View File
@@ -13,15 +13,18 @@ describe('Filter: orderBy', function() {
toThrowMinErr('orderBy', 'notarray', 'Expected array but received: {}');
});
it('should not throw an exception if a null or undefined value is provided', function() {
expect(orderBy(null)).toEqual(null);
expect(orderBy(undefined)).toEqual(undefined);
});
it('should not throw an exception if an array-like object is provided', function() {
expect(orderBy('cba')).toEqual(['a', 'b', 'c']);
});
it('should return sorted array if predicate is not provided', function() {
expect(orderBy([2, 1, 3])).toEqual([1, 2, 3]);
@@ -37,13 +40,6 @@ describe('Filter: orderBy', function() {
});
it('should reverse collection if `reverseOrder` param is truthy', function() {
expect(orderBy([{a:15}, {a:2}], 'a', true)).toEqualData([{a:15}, {a:2}]);
expect(orderBy([{a:15}, {a:2}], 'a', "T")).toEqualData([{a:15}, {a:2}]);
expect(orderBy([{a:15}, {a:2}], 'a', "reverse")).toEqualData([{a:15}, {a:2}]);
});
it('should sort inherited from array', function() {
function BaseCollection() {}
BaseCollection.prototype = Array.prototype;
@@ -93,6 +89,7 @@ describe('Filter: orderBy', function() {
{ a:new Date('01/01/2014'), b:3 }]);
});
it('should compare timestamps when sorting dates', function() {
expect(orderBy([
new Date('01/01/2015'),
@@ -140,22 +137,6 @@ describe('Filter: orderBy', function() {
expect(orderBy(array)).toEqualData(array);
});
it('should reverse array of objects with no predicate and reverse is `true`', function() {
var array = [
{ id: 2 },
{ id: 1 },
{ id: 4 },
{ id: 3 }
];
var reversedArray = [
{ id: 3 },
{ id: 4 },
{ id: 1 },
{ id: 2 }
];
expect(orderBy(array, '', true)).toEqualData(reversedArray);
});
it('should reverse array of objects with predicate of "-"', function() {
var array = [
@@ -237,6 +218,346 @@ describe('Filter: orderBy', function() {
{foo: 1, bar: 8}, {foo: 1, bar: 5}, {foo: 1, bar: 2}
]);
});
describe('(reversing order)', function() {
it('should not reverse collection if `reverse` param is falsy',
function() {
var items = [{a: 2}, {a: 15}];
var expr = 'a';
var sorted = [{a: 2}, {a: 15}];
expect(orderBy(items, expr, false)).toEqual(sorted);
expect(orderBy(items, expr, 0)).toEqual(sorted);
expect(orderBy(items, expr, '')).toEqual(sorted);
expect(orderBy(items, expr, NaN)).toEqual(sorted);
expect(orderBy(items, expr, null)).toEqual(sorted);
expect(orderBy(items, expr, undefined)).toEqual(sorted);
}
);
it('should reverse collection if `reverse` param is truthy',
function() {
var items = [{a: 2}, {a: 15}];
var expr = 'a';
var sorted = [{a: 15}, {a: 2}];
expect(orderBy(items, expr, true)).toEqual(sorted);
expect(orderBy(items, expr, 1)).toEqual(sorted);
expect(orderBy(items, expr, 'reverse')).toEqual(sorted);
expect(orderBy(items, expr, {})).toEqual(sorted);
expect(orderBy(items, expr, [])).toEqual(sorted);
expect(orderBy(items, expr, noop)).toEqual(sorted);
}
);
it('should reverse collection if `reverse` param is `true`, even without an `expression`',
function() {
var originalItems = [{id: 2}, {id: 1}, {id: 4}, {id: 3}];
var reversedItems = [{id: 3}, {id: 4}, {id: 1}, {id: 2}];
expect(orderBy(originalItems, null, true)).toEqual(reversedItems);
}
);
});
describe('(built-in comparator)', function() {
it('should compare numbers numarically', function() {
var items = [100, 3, 20];
var expr = null;
var sorted = [3, 20, 100];
expect(orderBy(items, expr)).toEqual(sorted);
});
it('should compare strings alphabetically', function() {
var items = ['100', '3', '20', '_b', 'a'];
var expr = null;
var sorted = ['100', '20', '3', '_b', 'a'];
expect(orderBy(items, expr)).toEqual(sorted);
});
it('should compare strings case-insensitively', function() {
var items = ['c', 'B', 'a'];
var expr = null;
var sorted = ['a', 'B', 'c'];
expect(orderBy(items, expr)).toEqual(sorted);
});
it('should compare objects based on `index`', function() {
var items = [{c: 3}, {b: 2}, {a: 1}];
var expr = null;
var sorted = [{c: 3}, {b: 2}, {a: 1}];
expect(orderBy(items, expr)).toEqual(sorted);
});
it('should compare values of different type alphabetically by type', function() {
var items = [undefined, '1', {}, 999, noop, false];
var expr = null;
var sorted = [false, noop, 999, {}, '1', undefined];
expect(orderBy(items, expr)).toEqual(sorted);
});
});
describe('(custom comparator)', function() {
it('should support a custom comparator', function() {
var items = [4, 42, 2];
var expr = null;
var reverse = null;
var sorted = [42, 2, 4];
var comparator = function(o1, o2) {
var v1 = o1.value;
var v2 = o2.value;
// 42 always comes first
if (v1 === v2) return 0;
if (v1 === 42) return -1;
if (v2 === 42) return 1;
// Default comparison for other values
return (v1 < v2) ? -1 : 1;
};
expect(orderBy(items, expr, reverse, comparator)).toEqual(sorted);
});
it('should support `reverseOrder` with a custom comparator', function() {
var items = [4, 42, 2];
var expr = null;
var reverse = true;
var sorted = [4, 2, 42];
var comparator = function(o1, o2) {
var v1 = o1.value;
var v2 = o2.value;
// 42 always comes first
if (v1 === v2) return 0;
if (v1 === 42) return -1;
if (v2 === 42) return 1;
// Default comparison for other values
return (v1 < v2) ? -1 : 1;
};
expect(orderBy(items, expr, reverse, comparator)).toEqual(sorted);
});
it('should pass `{value, type, index}` objects to comparators', function() {
var items = [false, noop, 999, {}, '', undefined];
var expr = null;
var reverse = null;
var comparator = jasmine.createSpy('comparator').and.returnValue(-1);
orderBy(items, expr, reverse, comparator);
var allArgsFlat = Array.prototype.concat.apply([], comparator.calls.allArgs());
expect(allArgsFlat).toContain({index: 0, type: 'boolean', value: false });
expect(allArgsFlat).toContain({index: 1, type: 'function', value: noop });
expect(allArgsFlat).toContain({index: 2, type: 'number', value: 999 });
expect(allArgsFlat).toContain({index: 3, type: 'object', value: {} });
expect(allArgsFlat).toContain({index: 4, type: 'string', value: '' });
expect(allArgsFlat).toContain({index: 5, type: 'undefined', value: undefined});
});
it('should treat a value of `null` as `"null"`', function() {
var items = [null, null];
var expr = null;
var reverse = null;
var comparator = jasmine.createSpy('comparator').and.returnValue(-1);
orderBy(items, expr, reverse, comparator);
var arg = comparator.calls.argsFor(0)[0];
expect(arg).toEqual(jasmine.objectContaining({
type: 'string',
value: 'null'
}));
});
it('should not convert strings to lower-case', function() {
var items = ['c', 'B', 'a'];
var expr = null;
var reverse = null;
var sorted = ['B', 'a', 'c'];
var comparator = function(o1, o2) {
return (o1.value < o2.value) ? -1 : 1;
};
expect(orderBy(items, expr, reverse, comparator)).toEqual(sorted);
});
it('should use `index` as `value` if no other predicate can distinguish between two items',
function() {
var items = ['foo', 'bar'];
var expr = null;
var reverse = null;
var comparator = jasmine.createSpy('comparator').and.returnValue(0);
orderBy(items, expr, reverse, comparator);
expect(comparator).toHaveBeenCalledTimes(2);
var lastArgs = comparator.calls.mostRecent().args;
expect(lastArgs).toContain(jasmine.objectContaining({value: 0, type: 'number'}));
expect(lastArgs).toContain(jasmine.objectContaining({value: 1, type: 'number'}));
}
);
it('should support multiple predicates and per-predicate sorting direction', function() {
var items = [
{owner: 'ownerA', type: 'typeA'},
{owner: 'ownerB', type: 'typeB'},
{owner: 'ownerC', type: 'typeB'},
{owner: 'ownerD', type: 'typeB'}
];
var expr = ['type', '-owner'];
var reverse = null;
var sorted = [
{owner: 'ownerA', type: 'typeA'},
{owner: 'ownerC', type: 'typeB'},
{owner: 'ownerB', type: 'typeB'},
{owner: 'ownerD', type: 'typeB'}
];
var comparator = function(o1, o2) {
var v1 = o1.value;
var v2 = o2.value;
var isNerd1 = v1.toLowerCase().indexOf('nerd') !== -1;
var isNerd2 = v2.toLowerCase().indexOf('nerd') !== -1;
// Shamelessly promote "nerds"
if (isNerd1 || isNerd2) {
return (isNerd1 && isNerd2) ? 0 : (isNerd1) ? -1 : 1;
}
// No "nerd"; alpabetical order
return (v1 === v2) ? 0 : (v1 < v2) ? -1 : 1;
};
expect(orderBy(items, expr, reverse, comparator)).toEqual(sorted);
});
});
describe('(object as `value`)', function() {
it('should use the return value of `valueOf()` (if primitive)', function() {
var o1 = {k: 1, valueOf: function() { return 2; }};
var o2 = {k: 2, valueOf: function() { return 1; }};
var items = [o1, o2];
var expr = null;
var sorted = [o2, o1];
expect(orderBy(items, expr)).toEqual(sorted);
});
it('should use the return value of `toString()` (if primitive)', function() {
var o1 = {k: 1, toString: function() { return 2; }};
var o2 = {k: 2, toString: function() { return 1; }};
var items = [o1, o2];
var expr = null;
var sorted = [o2, o1];
expect(orderBy(items, expr)).toEqual(sorted);
});
it('should ignore the `toString()` inherited from `Object`', function() {
/* globals toString: true */
// The global `toString` variable (in 'src/Angular.js')
// has already captured `Object.prototype.toString`
var originalToString = toString;
toString = jasmine.createSpy('toString').and.callFake(originalToString);
var o1 = Object.create({toString: toString});
var o2 = Object.create({toString: toString});
var items = [o1, o2];
var expr = null;
orderBy(items, expr);
expect(o1.toString).not.toHaveBeenCalled();
expect(o2.toString).not.toHaveBeenCalled();
toString = originalToString;
});
it('should use the return value of `valueOf()` for subsequent steps (if non-primitive)',
function() {
var o1 = {k: 1, valueOf: function() { return o3; }};
var o2 = {k: 2, valueOf: function() { return o4; }};
var o3 = {k: 3, toString: function() { return 4; }};
var o4 = {k: 4, toString: function() { return 3; }};
var items = [o1, o2];
var expr = null;
var sorted = [o2, o1];
expect(orderBy(items, expr)).toEqual(sorted);
}
);
it('should use the return value of `toString()` for subsequent steps (if non-primitive)',
function() {
var o1 = {k: 1, toString: function() { return o3; }};
var o2 = {k: 2, toString: function() { return o4; }};
var o3 = {k: 3};
var o4 = {k: 4};
var items = [o1, o2];
var expr = null;
var reverse = null;
var comparator = jasmine.createSpy('comparator').and.returnValue(-1);
orderBy(items, expr, reverse, comparator);
var args = comparator.calls.argsFor(0);
expect(args).toContain(jasmine.objectContaining({value: o3, type: 'object'}));
expect(args).toContain(jasmine.objectContaining({value: o4, type: 'object'}));
}
);
it('should use the object itself as `value` if no conversion took place', function() {
var o1 = {k: 1};
var o2 = {k: 2};
var items = [o1, o2];
var expr = null;
var reverse = null;
var comparator = jasmine.createSpy('comparator').and.returnValue(-1);
orderBy(items, expr, reverse, comparator);
var args = comparator.calls.argsFor(0);
expect(args).toContain(jasmine.objectContaining({value: o1, type: 'object'}));
expect(args).toContain(jasmine.objectContaining({value: o2, type: 'object'}));
});
});
});