Skip to content

Commit 240ff91

Browse files
committed
perf(sort): precompute sort keys and fast-path numeric sorter
_sortItems decorates each row with its sort keys and component once, sorts the decorated array, then writes rows back, instead of re-deriving values inside every comparison. The number sorter returns a-b directly for finite numbers, skipping String()/split. Adds Sort._sortItems and number-sorter unit tests.
1 parent 9539446 commit 240ff91

4 files changed

Lines changed: 310 additions & 57 deletions

File tree

src/js/modules/Sort/Sort.js

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -166,19 +166,20 @@ export default class Sort extends Module{
166166
sorters=[],
167167
match = false;
168168

169-
if(column.modules.sort){
170-
if(column.modules.sort.tristate){
171-
if(column.modules.sort.dir == "none"){
172-
dir = column.modules.sort.startingDir;
169+
const sortModule = column.modules.sort;
170+
if(sortModule){
171+
if(sortModule.tristate){
172+
if(sortModule.dir == "none"){
173+
dir = sortModule.startingDir;
173174
}else{
174-
if(column.modules.sort.dir == column.modules.sort.startingDir){
175-
dir = column.modules.sort.dir == "asc" ? "desc" : "asc";
175+
if(sortModule.dir == sortModule.startingDir){
176+
dir = sortModule.dir == "asc" ? "desc" : "asc";
176177
}else{
177178
dir = "none";
178179
}
179180
}
180181
}else{
181-
switch(column.modules.sort.dir){
182+
switch(sortModule.dir){
182183
case "asc":
183184
dir = "desc";
184185
break;
@@ -188,7 +189,7 @@ export default class Sort extends Module{
188189
break;
189190

190191
default:
191-
dir = column.modules.sort.startingDir;
192+
dir = sortModule.startingDir;
192193
}
193194
}
194195

@@ -340,7 +341,7 @@ export default class Sort extends Module{
340341
//work through sort list sorting data
341342
sort(data, sortOnly){
342343
var self = this,
343-
sortList = this.table.options.sortOrderReverse ? self.sortList.slice().reverse() : self.sortList,
344+
sortList = this.table.options.sortOrderReverse ? self.sortList.toReversed(): self.sortList,
344345
sortListActual = [],
345346
rowComponents = [];
346347

@@ -355,32 +356,29 @@ export default class Sort extends Module{
355356
if(this.table.options.sortMode !== "remote"){
356357

357358
//build list of valid sorters and trigger column specific callbacks before sort begins
358-
sortList.forEach(function(item, i){
359-
var sortObj;
360-
361-
if(item.column){
362-
sortObj = item.column.modules.sort;
363-
359+
for(const item of sortList) {
360+
const column = item.column;
361+
if(column){
362+
const sortObj = column.modules.sort;
364363
if(sortObj){
365-
366364
//if no sorter has been defined, take a guess
367365
if(!sortObj.sorter){
368-
sortObj.sorter = self.findSorter(item.column);
366+
sortObj.sorter = self.findSorter(column);
369367
}
370368

371-
item.params = typeof sortObj.params === "function" ? sortObj.params(item.column.getComponent(), item.dir) : sortObj.params;
369+
item.params = typeof sortObj.params === "function" ? sortObj.params(column.getComponent(), item.dir) : sortObj.params;
372370

373371
sortListActual.push(item);
374372
}
375373

376374
if(!sortOnly) {
377-
self.setColumnHeader(item.column, item.dir);
375+
self.setColumnHeader(column, item.dir);
378376
}
379377
}
380-
});
378+
}
381379

382380
//sort data
383-
if (sortListActual.length) {
381+
if (sortListActual.length && data.length) {
384382
self._sortItems(data, sortListActual);
385383
}
386384

@@ -439,23 +437,58 @@ export default class Sort extends Module{
439437

440438
//sort each item in sort list
441439
_sortItems(data, sortList){
442-
var sorterCount = sortList.length - 1;
443-
444-
data.sort((a, b) => {
445-
var result;
446-
447-
for(var i = sorterCount; i>= 0; i--){
448-
let sortItem = sortList[i];
449-
450-
result = this._sortRow(a, b, sortItem.column, sortItem.dir, sortItem.params);
451-
440+
const sortMeta = sortList.map((sortItem) => {
441+
return {
442+
column: sortItem.column,
443+
dir: sortItem.dir,
444+
params: sortItem.params,
445+
sorter: sortItem.column.modules.sort.sorter,
446+
columnComponent: sortItem.column.getComponent(),
447+
asc: sortItem.dir === "asc",
448+
};
449+
});
450+
451+
const sorterCount = sortMeta.length - 1;
452+
const length = data.length;
453+
454+
//extract each row's sort keys and component once, then sort the decorated array
455+
const decorated = new Array(length);
456+
for(let k = 0; k < length; k++){
457+
const row = data[k];
458+
const rowData = row.getData();
459+
const values = new Array(sortMeta.length);
460+
for(let j = 0; j <= sorterCount; j++){
461+
const value = sortMeta[j].column.getFieldValue(rowData);
462+
values[j] = typeof value !== "undefined" ? value : "";
463+
}
464+
decorated[k] = {values, component: row.getComponent(), row};
465+
}
466+
467+
decorated.sort((a, b) => {
468+
for(let i = sorterCount; i >= 0; i--){
469+
const sortItem = sortMeta[i];
470+
const asc = sortItem.asc;
471+
const result = sortItem.sorter.call(this,
472+
asc ? a.values[i] : b.values[i],
473+
asc ? b.values[i] : a.values[i],
474+
asc ? a.component : b.component,
475+
asc ? b.component : a.component,
476+
sortItem.columnComponent,
477+
sortItem.dir,
478+
sortItem.params
479+
);
480+
452481
if(result !== 0){
453-
break;
482+
return result;
454483
}
455484
}
456-
457-
return result;
485+
486+
return 0;
458487
});
488+
489+
for(let k = 0; k < length; k++){
490+
data[k] = decorated[k].row;
491+
}
459492
}
460493

461494
//process individual rows for a sort function on active data
@@ -477,4 +510,4 @@ export default class Sort extends Module{
477510

478511
return column.modules.sort.sorter.call(this, a, b, el1Comp, el2Comp, column.getComponent(), dir, params);
479512
}
480-
}
513+
}

src/js/modules/Sort/defaults/sorters/number.js

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,30 @@ export default function(a, b, aRow, bRow, column, dir, params){
44
var decimal = params.decimalSeparator;
55
var thousand = params.thousandSeparator;
66
var emptyAlign = 0;
7+
var aEmpty = a === "" || a === null || typeof a === "undefined";
8+
var bEmpty = b === "" || b === null || typeof b === "undefined";
79

8-
a = String(a);
9-
b = String(b);
10-
11-
if(thousand){
12-
a = a.split(thousand).join("");
13-
b = b.split(thousand).join("");
14-
}
15-
16-
if(decimal){
17-
a = a.split(decimal).join(".");
18-
b = b.split(decimal).join(".");
10+
if(typeof a === "number" && typeof b === "number" && isFinite(a) && isFinite(b)){
11+
return a - b;
1912
}
2013

21-
a = parseFloat(a);
22-
b = parseFloat(b);
23-
24-
//handle non numeric values
25-
if(isNaN(a)){
26-
emptyAlign = isNaN(b) ? 0 : -1;
27-
}else if(isNaN(b)){
28-
emptyAlign = 1;
14+
if(aEmpty){
15+
emptyAlign = bEmpty ? 0 : -1;
16+
}else if(bEmpty){
17+
emptyAlign = 1;
2918
}else{
30-
//compare valid values
31-
return a - b;
19+
a = parseValue(a, decimal, thousand);
20+
b = parseValue(b, decimal, thousand);
21+
22+
//handle non numeric values
23+
if(isNaN(a)){
24+
emptyAlign = isNaN(b) ? 0 : -1;
25+
}else if(isNaN(b)){
26+
emptyAlign = 1;
27+
}else{
28+
//compare valid values
29+
return a - b;
30+
}
3231
}
3332

3433
//fix empty values in position
@@ -37,4 +36,22 @@ export default function(a, b, aRow, bRow, column, dir, params){
3736
}
3837

3938
return emptyAlign;
40-
}
39+
}
40+
41+
function parseValue(value, decimal, thousand){
42+
if(typeof value === "number"){
43+
return value;
44+
}
45+
46+
value = String(value);
47+
48+
if(thousand){
49+
value = value.replaceAll(thousand, "");
50+
}
51+
52+
if(decimal && decimal !== "." ){
53+
value = value.replaceAll(decimal, ".");
54+
}
55+
56+
return parseFloat(value);
57+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import Sort from "../../../src/js/modules/Sort/Sort";
2+
import numberSorter from "../../../src/js/modules/Sort/defaults/sorters/number";
3+
import stringSorter from "../../../src/js/modules/Sort/defaults/sorters/string";
4+
5+
// Correctness coverage for Sort._sortItems decorate-sort-undecorate rewrite.
6+
// Validates ordering + stability against a reference implementation of the stock
7+
// per-comparison algorithm. Standalone (no TabulatorFull/luxon) so it runs in the
8+
// unit environment.
9+
10+
const ctx = {}; // sorter `this`; number/string sorters do not use it here
11+
12+
const numParams = { alignEmptyValues: undefined, decimalSeparator: undefined, thousandSeparator: undefined };
13+
const strParams = { alignEmptyValues: undefined, locale: false };
14+
15+
function makeColumn(field, sorter, params) {
16+
return {
17+
field,
18+
_comp: null,
19+
getFieldValue(data) { return data[field]; },
20+
getComponent() { return (this._comp ||= { _col: field }); },
21+
modules: { sort: { sorter, params } },
22+
};
23+
}
24+
25+
function makeRow(data) {
26+
return { data, _comp: null, getData() { return this.data; }, getComponent() { return (this._comp ||= { _row: this.data }); } };
27+
}
28+
29+
// Reference: the stock per-comparison algorithm (matches HEAD _sortItems/_sortRow).
30+
function referenceSort(data, sortList) {
31+
const sorterCount = sortList.length - 1;
32+
return data.slice().sort((a, b) => {
33+
let result;
34+
for (let i = sorterCount; i >= 0; i--) {
35+
const s = sortList[i];
36+
const el1 = s.dir === "asc" ? a : b;
37+
const el2 = s.dir === "asc" ? b : a;
38+
let av = s.column.getFieldValue(el1.getData());
39+
let bv = s.column.getFieldValue(el2.getData());
40+
av = typeof av !== "undefined" ? av : "";
41+
bv = typeof bv !== "undefined" ? bv : "";
42+
result = s.column.modules.sort.sorter.call(ctx, av, bv, el1.getComponent(), el2.getComponent(), s.column.getComponent(), s.dir, s.params);
43+
if (result !== 0) break;
44+
}
45+
return result;
46+
});
47+
}
48+
49+
function runSort(data, sortList) {
50+
const copy = data.slice();
51+
Sort.prototype._sortItems.call(ctx, copy, sortList);
52+
return copy;
53+
}
54+
55+
describe("Sort._sortItems (decorate-sort-undecorate)", () => {
56+
test("single numeric column ascending", () => {
57+
const col = makeColumn("a", numberSorter, numParams);
58+
const rows = [makeRow({ a: 3 }), makeRow({ a: 1 }), makeRow({ a: 2 })];
59+
const out = runSort(rows, [{ column: col, dir: "asc", params: numParams }]);
60+
expect(out.map((r) => r.data.a)).toEqual([1, 2, 3]);
61+
});
62+
63+
test("single numeric column descending", () => {
64+
const col = makeColumn("a", numberSorter, numParams);
65+
const rows = [makeRow({ a: 3 }), makeRow({ a: 1 }), makeRow({ a: 2 })];
66+
const out = runSort(rows, [{ column: col, dir: "desc", params: numParams }]);
67+
expect(out.map((r) => r.data.a)).toEqual([3, 2, 1]);
68+
});
69+
70+
test("mutates the array in place (same reference)", () => {
71+
const col = makeColumn("a", numberSorter, numParams);
72+
const rows = [makeRow({ a: 2 }), makeRow({ a: 1 })];
73+
const ref = rows;
74+
Sort.prototype._sortItems.call(ctx, rows, [{ column: col, dir: "asc", params: numParams }]);
75+
expect(rows).toBe(ref);
76+
expect(rows.map((r) => r.data.a)).toEqual([1, 2]);
77+
});
78+
79+
test("multi-column: primary (last in list) then tie-break", () => {
80+
const colA = makeColumn("a", numberSorter, numParams);
81+
const colB = makeColumn("b", numberSorter, numParams);
82+
const rows = [
83+
makeRow({ a: 2, b: 1 }), makeRow({ a: 1, b: 2 }), makeRow({ a: 1, b: 1 }), makeRow({ a: 2, b: 2 }),
84+
];
85+
const sortList = [
86+
{ column: colB, dir: "asc", params: numParams }, // tie-break (checked first in loop)
87+
{ column: colA, dir: "asc", params: numParams }, // primary (checked last)
88+
];
89+
const out = runSort(rows, sortList);
90+
expect(out.map((r) => [r.data.a, r.data.b])).toEqual(referenceSort(rows, sortList).map((r) => [r.data.a, r.data.b]));
91+
});
92+
93+
test("stability: equal keys preserve original order", () => {
94+
const col = makeColumn("a", numberSorter, numParams);
95+
const rows = [makeRow({ a: 1, id: "x" }), makeRow({ a: 1, id: "y" }), makeRow({ a: 1, id: "z" })];
96+
const out = runSort(rows, [{ column: col, dir: "asc", params: numParams }]);
97+
expect(out.map((r) => r.data.id)).toEqual(["x", "y", "z"]);
98+
});
99+
100+
test("matches reference over randomized multi-column (numeric + string, asc + desc)", () => {
101+
const colA = makeColumn("a", numberSorter, numParams);
102+
const colName = makeColumn("name", stringSorter, strParams);
103+
const sortList = [
104+
{ column: colName, dir: "desc", params: strParams },
105+
{ column: colA, dir: "asc", params: numParams },
106+
];
107+
let seed = 99;
108+
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
109+
for (let trial = 0; trial < 50; trial++) {
110+
const rows = [];
111+
for (let i = 0; i < 200; i++) rows.push(makeRow({ a: Math.floor(rnd() * 5), name: "n" + Math.floor(rnd() * 5), id: i }));
112+
const expected = referenceSort(rows, sortList).map((r) => r.data.id);
113+
const actual = runSort(rows, sortList).map((r) => r.data.id);
114+
expect(actual).toEqual(expected);
115+
}
116+
});
117+
118+
test("undefined field values are coerced to empty string (as stock)", () => {
119+
const col = makeColumn("a", numberSorter, numParams);
120+
const rows = [makeRow({ a: 5 }), makeRow({}), makeRow({ a: 2 })];
121+
const out = runSort(rows, [{ column: col, dir: "asc", params: numParams }]);
122+
expect(out.map((r) => r.data.a)).toEqual(referenceSort(rows, [{ column: col, dir: "asc", params: numParams }]).map((r) => r.data.a));
123+
});
124+
});

0 commit comments

Comments
 (0)