2021-11-03 11:44:28 +00:00
|
|
|
/**
|
|
|
|
* Sort namespace provides helper function to work with sort expression.
|
|
|
|
*
|
|
|
|
* Sort expression is a list of column sort expressions, each describing how to
|
|
|
|
* sort particular column. Column expression can be either:
|
|
|
|
*
|
|
|
|
* - Positive number: column with matching id will be sorted in ascending order
|
|
|
|
* - Negative number: column will be sorted in descending order
|
|
|
|
* - String containing a positive number: same as above
|
|
|
|
* - String containing a negative number: same as above
|
|
|
|
* - String containing a number and sorting options:
|
|
|
|
* '1:flag1;flag2;flag3'
|
|
|
|
* '-1:flag1;flag2;flag3'
|
|
|
|
* Sorting options modifies the sorting algorithm, supported options are:
|
|
|
|
* - orderByChoice: For choice column sorting function will use choice item order
|
|
|
|
* instead of choice label text.
|
|
|
|
* - emptyLast: Treat empty values as greater than non empty (default is empty values first).
|
|
|
|
* - naturalSort: For text based columns, sorting function will compare strings with numbers
|
|
|
|
* taking their numeric value rather then text representation ('a2' before 'a11)
|
|
|
|
*/
|
|
|
|
export namespace Sort {
|
|
|
|
/**
|
|
|
|
* Object base representation for column expression.
|
|
|
|
*/
|
|
|
|
export interface ColSpecDetails {
|
2024-05-21 16:27:06 +00:00
|
|
|
colRef: ColRef;
|
2021-11-03 11:44:28 +00:00
|
|
|
direction: Direction;
|
|
|
|
orderByChoice?: boolean;
|
|
|
|
emptyLast?: boolean;
|
|
|
|
naturalSort?: boolean;
|
|
|
|
}
|
|
|
|
/**
|
2024-05-21 16:27:06 +00:00
|
|
|
* Column expression type. Either number, an object, or virtual id string _vid\d+
|
2021-11-03 11:44:28 +00:00
|
|
|
*/
|
|
|
|
export type ColSpec = number | string;
|
2024-05-21 16:27:06 +00:00
|
|
|
export type ColRef = number | string;
|
2021-11-03 11:44:28 +00:00
|
|
|
/**
|
|
|
|
* Sort expression type, for example [1,-2, '3:emptyLast', '-4:orderByChoice']
|
|
|
|
*/
|
|
|
|
export type SortSpec = Array<ColSpec>;
|
|
|
|
export type Direction = 1 | -1;
|
|
|
|
export const ASC: Direction = 1;
|
|
|
|
export const DESC: Direction = -1;
|
|
|
|
|
|
|
|
const NOT_FOUND = -1;
|
|
|
|
|
|
|
|
// Flag separator
|
|
|
|
const FLAG_SEPARATOR = ";";
|
|
|
|
// Separator between colRef and sorting options.
|
|
|
|
const OPTION_SEPARATOR = ":";
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks if column expression has any sorting options.
|
|
|
|
*/
|
|
|
|
export function hasOptions(colSpec: ColSpec | ColSpecDetails): boolean {
|
|
|
|
if (typeof colSpec === "number") {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const details = typeof colSpec !== "object" ? specToDetails(colSpec) : colSpec;
|
|
|
|
return Boolean(details.emptyLast || details.naturalSort || details.orderByChoice);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Converts column sort expression from object representation to encoded form.
|
|
|
|
*/
|
|
|
|
export function detailsToSpec(d: ColSpecDetails): ColSpec {
|
|
|
|
const head = `${d.direction === ASC ? "" : "-"}${d.colRef}`;
|
|
|
|
const tail = [];
|
|
|
|
if (d.emptyLast) {
|
|
|
|
tail.push("emptyLast");
|
|
|
|
}
|
|
|
|
if (d.naturalSort) {
|
|
|
|
tail.push("naturalSort");
|
|
|
|
}
|
|
|
|
if (d.orderByChoice) {
|
|
|
|
tail.push("orderByChoice");
|
|
|
|
}
|
|
|
|
if (!tail.length) {
|
2024-05-21 16:27:06 +00:00
|
|
|
return maybeNumber(head);
|
2021-11-03 11:44:28 +00:00
|
|
|
}
|
|
|
|
return head + (tail.length ? OPTION_SEPARATOR : "") + tail.join(FLAG_SEPARATOR);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Converts column expression to object representation.
|
|
|
|
*/
|
|
|
|
export function specToDetails(colSpec: ColSpec): ColSpecDetails {
|
|
|
|
return typeof colSpec === "number"
|
|
|
|
? {
|
|
|
|
colRef: Math.abs(colSpec),
|
|
|
|
direction: colSpec >= 0 ? ASC: DESC,
|
|
|
|
}
|
|
|
|
: parseColSpec(colSpec);
|
|
|
|
}
|
|
|
|
|
2024-05-21 16:27:06 +00:00
|
|
|
function maybeNumber(colRef: string): ColRef {
|
|
|
|
const num = parseInt(colRef, 10);
|
|
|
|
return isNaN(num) ? colRef : num;
|
|
|
|
}
|
|
|
|
|
2021-11-03 11:44:28 +00:00
|
|
|
function parseColSpec(colString: string): ColSpecDetails {
|
2024-05-21 16:27:06 +00:00
|
|
|
if (!colString) {
|
|
|
|
throw new Error("Empty column expression");
|
|
|
|
}
|
|
|
|
const REGEX = /^(?<sign>-)?(?<colRef>(_vid)?(\d+))(:(?<flag>[\w\d;]+))?$/;
|
2021-11-03 11:44:28 +00:00
|
|
|
const match = colString.match(REGEX);
|
|
|
|
if (!match) {
|
|
|
|
throw new Error("Error parsing sort expression " + colString);
|
|
|
|
}
|
2024-05-21 16:27:06 +00:00
|
|
|
const {sign, colRef, flag} = match.groups || {};
|
2021-11-03 11:44:28 +00:00
|
|
|
const flags = flag?.split(";");
|
2024-05-21 16:27:06 +00:00
|
|
|
return onlyDefined({
|
|
|
|
colRef: maybeNumber(colRef),
|
2021-11-03 11:44:28 +00:00
|
|
|
direction: sign === "-" ? DESC : ASC,
|
|
|
|
orderByChoice: flags?.includes("orderByChoice"),
|
|
|
|
emptyLast: flags?.includes("emptyLast"),
|
|
|
|
naturalSort: flags?.includes("naturalSort"),
|
2024-05-21 16:27:06 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function onlyDefined<T extends Record<string, any>>(obj: T): T{
|
|
|
|
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;
|
2021-11-03 11:44:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Extracts colRef (column row id) from column sorting expression.
|
|
|
|
*/
|
|
|
|
export function getColRef(colSpec: ColSpec) {
|
|
|
|
if (typeof colSpec === "number") {
|
|
|
|
return Math.abs(colSpec);
|
|
|
|
}
|
|
|
|
return parseColSpec(colSpec).colRef;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Swaps column expressions.
|
|
|
|
*/
|
|
|
|
export function swap(spec: SortSpec, colA: ColSpec, colB: ColSpec): SortSpec {
|
|
|
|
const aIndex = findColIndex(spec, colA);
|
|
|
|
const bIndex = findColIndex(spec, colB);
|
|
|
|
if (aIndex === NOT_FOUND || bIndex === NOT_FOUND) {
|
|
|
|
throw new Error(`Column expressions can be found (${colA} or ${colB})`);
|
|
|
|
}
|
|
|
|
const clone = spec.slice();
|
|
|
|
clone[aIndex] = spec[bIndex];
|
|
|
|
clone[bIndex] = spec[aIndex];
|
|
|
|
return clone;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Converts column expression order.
|
|
|
|
*/
|
|
|
|
export function setColDirection(colSpec: ColSpec, dir: Direction): ColSpec {
|
2024-05-21 16:27:06 +00:00
|
|
|
if (typeof colSpec == "number") {
|
2021-11-03 11:44:28 +00:00
|
|
|
return Math.abs(colSpec) * dir;
|
2024-05-21 16:27:06 +00:00
|
|
|
} else if (colSpec.startsWith(VirtualId.PREFIX)) {
|
|
|
|
return dir === DESC ? `-${colSpec}` : colSpec;
|
|
|
|
} else if (colSpec.startsWith(`-${VirtualId.PREFIX}`)) {
|
|
|
|
return dir === ASC ? colSpec.slice(1) : colSpec;
|
|
|
|
} else {
|
|
|
|
return detailsToSpec({ ...parseColSpec(colSpec), direction: dir });
|
2021-11-03 11:44:28 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates simple column expression.
|
|
|
|
*/
|
2024-05-21 16:27:06 +00:00
|
|
|
export function createColSpec(colRef: ColRef, dir: Direction): ColSpec {
|
|
|
|
if (typeof colRef === "number") {
|
|
|
|
return colRef * dir;
|
|
|
|
} else {
|
|
|
|
return dir === ASC ? colRef : `-${colRef}`;
|
|
|
|
}
|
2021-11-03 11:44:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks if a column expression is already included in sorting spec. Doesn't check sorting options.
|
|
|
|
*/
|
|
|
|
export function contains(spec: SortSpec, colSpec: ColSpec, dir: Direction) {
|
|
|
|
const existing = findCol(spec, colSpec);
|
|
|
|
return !!existing && getColRef(existing) === getColRef(colSpec) && direction(existing) === dir;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function containsOnly(spec: SortSpec, colSpec: ColSpec, dir: Direction) {
|
|
|
|
return spec.length === 1 && contains(spec, colSpec, dir);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks if a column is sorted in ascending order.
|
|
|
|
*/
|
|
|
|
export function isAscending(colSpec: ColSpec): boolean {
|
|
|
|
if (typeof colSpec === "number") {
|
|
|
|
return colSpec >= 0;
|
|
|
|
}
|
|
|
|
return parseColSpec(colSpec).direction === ASC;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function direction(colSpec: ColSpec): Direction {
|
|
|
|
return isAscending(colSpec) ? ASC : DESC;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks if two column expressions refers to the same column.
|
|
|
|
*/
|
|
|
|
export function sameColumn(colSpec: ColSpec, colRef: ColSpec): boolean {
|
|
|
|
return getColRef(colSpec) === getColRef(colRef);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Swaps column id in column expression. Primary use for display columns.
|
|
|
|
*/
|
2024-05-21 16:27:06 +00:00
|
|
|
export function swapColRef(colSpec: ColSpec, colRef: ColRef): ColSpec {
|
2021-11-03 11:44:28 +00:00
|
|
|
if (typeof colSpec === "number") {
|
|
|
|
return colSpec >= 0 ? colRef : -colRef;
|
|
|
|
}
|
|
|
|
const spec = parseColSpec(colSpec);
|
|
|
|
return detailsToSpec({...spec, colRef});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Finds an index of column expression in a sorting expression.
|
|
|
|
*/
|
|
|
|
export function findColIndex(sortSpec: SortSpec, colRef: ColSpec): number {
|
|
|
|
return sortSpec.findIndex(colSpec => sameColumn(colSpec, colRef));
|
|
|
|
}
|
|
|
|
|
|
|
|
export function removeCol(sortSpec: SortSpec, colRef: ColSpec): SortSpec {
|
|
|
|
return sortSpec.filter(col => getColRef(col) !== getColRef(colRef));
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Finds a column expression in sorting expression (regardless sorting option).
|
|
|
|
*/
|
|
|
|
export function findCol(sortSpec: SortSpec, colRef: ColSpec): ColSpec | undefined {
|
|
|
|
const result = sortSpec.find(colSpec => sameColumn(colSpec, colRef));
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Inserts new column sort options at the index of an existing column options (and removes the old one).
|
|
|
|
* If the old column can't be found it does nothing.
|
|
|
|
* @param colRef Column id to remove
|
|
|
|
* @param newSpec New column sort options to put in place of the old one.
|
|
|
|
*/
|
2024-05-21 16:27:06 +00:00
|
|
|
export function replace(sortSpec: SortSpec, colRef: ColRef, newSpec: ColSpec | ColSpecDetails): SortSpec {
|
2021-11-03 11:44:28 +00:00
|
|
|
const index = findColIndex(sortSpec, colRef);
|
|
|
|
if (index >= 0) {
|
|
|
|
const updated = sortSpec.slice();
|
|
|
|
updated[index] = typeof newSpec === "object" ? detailsToSpec(newSpec) : newSpec;
|
|
|
|
return updated;
|
|
|
|
}
|
|
|
|
return sortSpec;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Flips direction for a single column, returns a new object.
|
|
|
|
*/
|
|
|
|
export function flipCol(colSpec: ColSpec): ColSpec {
|
|
|
|
if (typeof colSpec === "number") {
|
|
|
|
return -colSpec;
|
|
|
|
}
|
|
|
|
const spec = parseColSpec(colSpec);
|
|
|
|
return detailsToSpec({ ...spec, direction: spec.direction === ASC ? DESC : ASC });
|
|
|
|
}
|
|
|
|
|
|
|
|
// Takes an activeSortSpec and sortRef to flip and returns a new
|
|
|
|
// activeSortSpec with that sortRef flipped (or original spec if sortRef not found).
|
|
|
|
export function flipSort(spec: SortSpec, colSpec: ColSpec): SortSpec {
|
|
|
|
const idx = findColIndex(spec, getColRef(colSpec));
|
|
|
|
if (idx !== NOT_FOUND) {
|
|
|
|
const newSpec = Array.from(spec);
|
|
|
|
newSpec[idx] = flipCol(newSpec[idx]);
|
|
|
|
return newSpec;
|
|
|
|
}
|
|
|
|
return spec;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function setSortDirection(spec: SortSpec, colSpec: ColSpec, dir: Direction): SortSpec {
|
|
|
|
const idx = findColIndex(spec, getColRef(colSpec));
|
|
|
|
if (idx !== NOT_FOUND) {
|
|
|
|
const newSpec = Array.from(spec);
|
|
|
|
newSpec[idx] = setColDirection(newSpec[idx], dir);
|
|
|
|
return newSpec;
|
|
|
|
}
|
|
|
|
return spec;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parses the sortColRefs string, defaulting to an empty array on invalid input.
|
|
|
|
export function parseSortColRefs(sortColRefs: string): SortSpec {
|
|
|
|
try {
|
|
|
|
return JSON.parse(sortColRefs);
|
|
|
|
} catch (err) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Given the current sort spec, moves colSpec to be immediately before nextColSpec. Moves v
|
|
|
|
// to the end of the sort spec if nextColSpec is null.
|
|
|
|
// If the given colSpec or nextColSpec cannot be found, return sortSpec unchanged.
|
|
|
|
// ColSpec are identified only by colRef (order or options don't matter).
|
|
|
|
export function reorderSortRefs(spec: SortSpec, colSpec: ColSpec, nextColSpec: ColSpec | null): SortSpec {
|
|
|
|
const updatedSpec = spec.slice();
|
|
|
|
|
|
|
|
// Remove sortRef from sortSpec.
|
|
|
|
const _idx = findColIndex(updatedSpec, colSpec);
|
|
|
|
if (_idx === NOT_FOUND) {
|
|
|
|
return spec;
|
|
|
|
}
|
|
|
|
updatedSpec.splice(_idx, 1);
|
|
|
|
|
|
|
|
// Add sortRef to before nextSortRef
|
|
|
|
const _nextIdx = nextColSpec ? findColIndex(updatedSpec, nextColSpec) : updatedSpec.length;
|
|
|
|
if (_nextIdx === NOT_FOUND) {
|
|
|
|
return spec;
|
|
|
|
}
|
|
|
|
updatedSpec.splice(_nextIdx, 0, colSpec);
|
|
|
|
|
|
|
|
return updatedSpec;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Helper function for query based sorting, which uses column names instead of columns ids.
|
|
|
|
// Translates expressions like -Pet, to an colRef expression like -1.
|
|
|
|
// NOTE: For column with zero index, it will return a string.
|
|
|
|
export function parseNames(sort: string[], colIdToRef: Map<string, number>): SortSpec {
|
|
|
|
const COL_SPEC_REG = /^(-)?([\w]+)(:.+)?/;
|
|
|
|
return sort.map((colSpec) => {
|
|
|
|
const match = colSpec.match(COL_SPEC_REG);
|
|
|
|
if (!match) {
|
|
|
|
throw new Error(`unknown key ${colSpec}`);
|
|
|
|
}
|
|
|
|
const [, sign, key, options] = match;
|
|
|
|
let colRef = Number(key);
|
|
|
|
if (!isNaN(colRef)) {
|
|
|
|
// This might be valid colRef
|
|
|
|
if (![...colIdToRef.values()].includes(colRef)) {
|
|
|
|
throw new Error(`invalid column id ${key}`);
|
|
|
|
}
|
|
|
|
} else if (!colIdToRef.has(key)) {
|
|
|
|
throw new Error(`unknown key ${key}`);
|
|
|
|
} else {
|
|
|
|
colRef = colIdToRef.get(key)!;
|
|
|
|
}
|
|
|
|
return `${sign || ""}${colRef}${options ?? ""}`;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2024-05-21 16:27:06 +00:00
|
|
|
|
|
|
|
let _virtualIdCounter = 1;
|
|
|
|
const _virtualSymbols = new Map<string, string>();
|
|
|
|
/**
|
|
|
|
* Creates a virtual id for virtual tables. Can remember some generated ids if called with a
|
|
|
|
* name (this feature used only in tests for now).
|
|
|
|
*
|
|
|
|
* The resulting id looks like _vid\d+.
|
|
|
|
*/
|
|
|
|
export function VirtualId(symbol = '') {
|
|
|
|
if (symbol) {
|
|
|
|
if (!_virtualSymbols.has(symbol)) {
|
|
|
|
const generated = `${VirtualId.PREFIX}${_virtualIdCounter++}`;
|
|
|
|
_virtualSymbols.set(symbol, generated);
|
|
|
|
return generated;
|
|
|
|
} else {
|
|
|
|
return _virtualSymbols.get(symbol)!;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
return `${VirtualId.PREFIX}${_virtualIdCounter++}`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
VirtualId.PREFIX = '_vid';
|