3,315 bytes · the Python implementation · view raw
from functools import cmp_to_key
from typing import Any, List, Mapping, Sequence
from .collections_sort_by_types import SortDirection
# The type ranks that give mixed values a total order. Absent sorts last in# both directions, so it is ranked above every present value and then excluded# from the direction flip below.
RANK_BOOL = 0
RANK_NUMBER = 1
RANK_STRING = 2
RANK_ABSENT = 3def _rank_of(value: Any, key: str) -> int:
# Absent and null are the same thing: a document that omits a field and one# that nulls it mean the same to every reader.if value isNone:
return RANK_ABSENT
# bool before int: in Python True is an int, and ranking it as a number# would order it against 0 and 1 instead of with the other booleans.if isinstance(value, bool):
return RANK_BOOL
if isinstance(value, (int, float)):
return RANK_NUMBER
if isinstance(value, str):
return RANK_STRING
raise TypeError('cannot sort by the list or map at "%s"' % (key,))
def _compare_scalars(rank: int, a: Any, b: Any) -> int:
if rank == RANK_STRING:
# Python compares strings by code point, which is the order this# capability pins; TypeScript spells the same comparison out by hand# because JavaScript would otherwise compare UTF-16 code units.return -1if a < b else (1if a > b else0)
if rank == RANK_NUMBER:
return -1if a < b else (1if a > b else0)
return0if a == b else (1if a else -1)
def sort_by(
records: Sequence[Mapping[str, Any]],
key: str,
direction: SortDirection = "asc",
) -> List[Mapping[str, Any]]:
"""Stably sort ``records`` by ``key``, ascending or descending. Ties keep their input order in both directions, which is what lets a user sort by one column and then another and get the multi-column sort they expect rather than a reshuffle. """if isinstance(records, (str, bytes)) ornot isinstance(records, (list, tuple)):
raise TypeError("sort_by needs a list of records")
ifnot isinstance(key, str) or key == "":
raise TypeError("sort_by needs a non-empty key name")
if direction notin ("asc", "desc"):
raise ValueError('direction must be "asc" or "desc", received "%s"' % (direction,))
# Rank every record up front. Raising mid-comparison would make the error# depend on which comparisons this interpreter's sort happened to perform.
values = [record.get(key) if isinstance(record, dict) elseNonefor record in records]
ranks = [_rank_of(value, key) for value in values]
sign = -1if direction == "desc"else1
indexed = list(range(len(records)))
def compare(i: int, j: int) -> int:
ri, rj = ranks[i], ranks[j]
# Absent values sink to the bottom whichever way the sort runs: nobody# wants the rows they know nothing about at the top of a descending table.if ri == RANK_ABSENT or rj == RANK_ABSENT:
if ri == rj:
return0return1if ri == RANK_ABSENT else -1if ri != rj:
base = -1if ri < rj else1else:
base = _compare_scalars(ri, values[i], values[j])
return base * sign
indexed.sort(key=cmp_to_key(compare))
return [records[i] for i in indexed]