Использую Active Record в Yii2, и получаю список в такой последовательности (сортировка по ASCII, ASCIIbetical order):
Pacific 12°-28° 1200w 80v
Pacific 14°-35° 1200w 80v
Pacific 23°-50° 1200w 80v
Pacific 5.5°-13° 1200w 80v
Однако нормальный пользователь (не программист!) ожидает, что последний из элементов будет в начале. Естественная сортировка (natural order, сортировка по алфавиту):
Pacific 5.5°-13° 1200w 80v
Pacific 12°-28° 1200w 80v
Pacific 14°-35° 1200w 80v
Pacific 23°-50° 1200w 80v
В PHP возможность использовать естественную сортировку есть по умолчанию (что, кстати, преимущество языка по сравнению с другими):
функция natsort()
функция sort() с флагом SORT_NATURAL
функция strnatcmp()
Но вот использование фреймворка Yii2, похоже, сводит это преимущество на нет. Я получаю тот самый "ASCIIbetical order", который нужно как-то пересортировывать. Дополнительная засада зарыта в виджете GridView, который если и получит отсортированный список, то при сортировке по клику на ярлык колонки перестроит список опять же как в первом примере.
Есть ли возможность "минимальной кровью" подружить Yii2 и естественную сортировку?
P.S. В указанной выше статье есть чудный фрагмент:
Я не могу понять одну тупую вещь. Боже мой, люди. Вы программисты.
Почти у всех высшее образование, но никто из вас не знает, что черт
подери значит "по алфавиту". Вам должно быть стыдно. Если вы
используете для сортировки по алфавиту дефолтный алгоритм языка
программирования —
скорее всего, это сортировка по ASCII (по хорошей причине), —
то идите к ближайшему зеркалу и дайте себе
несколько пощечин, а потом вернитесь на рабочие места и исправьте
юнит-тесты, которые не ловят эту проблему.
Ответ
Неделю я воевал с PHP и MySQL, выясняя нюансы работы с сортировкой. Грабли лежат на каждом шагу. Например,
natcasesort() - учитывает регистр, но спотыкается о цифры:
Рабочая станция визуализации/файл-сервер системы
рабочая станция художника по свету
Тубус Pacific 12°-28°
Тубус Pacific 14°-35°
Тубус Pacific 23°-50°
Тубус Pacific 5.5°-13° (элемент списка не на месте)
sort($lines, SORT_NATURAL | SORT_FLAG_CASE) - упорядочивает смесь цифр и букв, но спотыкается о регистр, если есть кириллица:
Рабочая станция визуализации/файл-сервер системы
Тубус Pacific 5.5°-13°
Тубус Pacific 12°-28°
Тубус Pacific 14°-35°
Тубус Pacific 23°-50°
рабочая станция художника по свету (элемент не на месте)
Важный теоретический момент: MySQL не умеет делать естественную сортировку, а Yii2 получает данные в том порядке, в каком из отдает база данных. Поэтому вместо сортировки по буквенно-цифровому полю стоит использовать сортировку по полю вспомогательному. О реализации этой идеи - ниже. Не буду рассказывать о всех деталях, расскажу о ключевых.
Создаем вспомогательное поле (ниже - sequence). В нем будем хранить целые числа (int). Элементы списка пронумерованы сотнями начиная со ста. Это позволит нам добавлять новые элементы не пересчитывая всю последовательность. Со ста начинаем, чтобы можно было добавлять элементы в начало списка и не заморачиваться с отрицательными числами. Аналогичный рецепт есть у Александра Макарова в блоге, только он предлагает хранить последовательность в виде decimal. Этот момент совершенно непринципиален, выбирайте то, что больше нравится.
Теперь нам нужно просчитать последовательность для имеющегося списка. Я создал класс CyrrilicHandler, в котором есть несколько методов, связанных с этой задачей:
public function upperletters()
{
return [
'А','Б','В','Г','Д','Е','Ё','Ж','З','И','Й','К','Л','М','Н','О','П',
'Р','С','Т','У','Ф','Х','Ц','Ч','Ш','Щ','Ъ','Ы','Ь','Э','Ю','Я',
];
}
public function lowerletters()
{
return [
'а','б','в','г','д','е','ё','ж','з','и','й','к','л','м','н','о','п',
'р','с','т','у','ф','х','ц','ч','ш','щ','ъ','ы','ь','э','ю','я',
];
}
// Приводим строку к нижнему регистру
public function strtolower($string)
{
return str_replace(self::upperletters(), self::lowerletters(), $string);
}
// Приводим строку к верхнему регистру
public function strtoupper($string)
{
return str_replace(self::lowerletters(), self::upperletters(), $string);
}
// Естественная регистронезависимая сортировка (базовая латиница + кириллица)
public function sort($array = [])
{
$complexArray = [];
foreach ($array as $value) {
$complexArray[] = [
'value' => $value,
'lowercase' => self::strtolower($value),
];
}
ArrayHelper::multisort($complexArray, ['lowercase'], [SORT_ASC], SORT_NATURAL | SORT_FLAG_CASE);
return ArrayHelper::getColumn($complexArray, 'value');
}
// Естественная регистронезависимая сортировка с возможностью задать первый
// ключ возвращаемого массива и интервал между элементами
// Первый элемент - 100, далее - с интервалом 100
public function sortAndSetInitialAndInterval($array = [])
{
$raw = self::sort($array);
$res = [];
$key = 100; // начинаем последовательность со ста
foreach ($raw as $value) {
$res[$key] = $value;
$key += 100; // интервал - 100
}
return $res;
}
Обновляем имеющиеся данные:
$lines = Line::find()
->select('line')
->orderBy('line')
->column();
// Естественная регистронезависимая сортировка
$lines = CyrrilicHandler::sortAndSetInitialAndInterval($lines);
$table = Line::tableName();
foreach ($lines as $sequence => $line):
$query = "UPDATE {$table}
SET sequence = :sequence
WHERE line = :line";
Yii::$app->db->createCommand($query)
->bindValue(':sequence', $sequence)
->bindValue(':line', $line)
->execute();
endforeach; // $lines as $sequence => $line
Проделывать такую операцию каждый раз не хочется, поэтому я создал метод newPosition(), который возвращает номер в существующей последовательности для заданной строки. Вот этот метод и методы, связанные с ним (CyrrilicHandler):
// Поиск позиции нового элемента в последовательности (чтобы не обновлять все записи
// последовательности)
public function newPosition($string)
{
// В зависимости от первого символа строки (цифра или нет) получаем разные блоки данных
$block = self::isInitialSymbolNumber($string) ? self::findEntriesStartedWithNumber() :
self::findEntriesStartedWithSameSymbol($string);
if (sizeof($block)) {
// Если переданная строка уже есть в БД, возвращаем ее позицию
if (array_search($string, $block) !== false)
return array_search($string, $block);
list($before, $after) = self::analyseBlockWithString($block, $string);
} else {
list($before, $after) = self::analyseBordersWithString($string);
}
return $before + floor(($after - $before)/2);
}
// Проверяем первый символ (цифра или нет)
private function isInitialSymbolNumber($string) {
return is_numeric(mb_substr($string, 0, 1, 'UTF-8'));
}
private function findEntriesStartedWithNumber()
{
return Line::find()
->select('line')
->where("line REGEXP '^[0-9]'")
->orderBy('sequence')
->indexBy('sequence')
->column();
}
private function findEntriesStartedWithSameSymbol($string)
{
return Line::find()
->select('line')
->where(['like', 'line', mb_substr($string, 0, 1, 'UTF-8') . '%', false])
->orderBy('sequence')
->indexBy('sequence')
->column();
}
private function analyseBlockWithString($block, $string)
{
// Добавляем в блок строку и сортируем, исходный блок остается неизменным
$sorted = self::sort(array_merge($block, ['x' => $string]));
// Находим номер нового элемента в отсортированной последовательности
$eid = array_search($string, $sorted);
$before = isset($sorted[$eid-1]) ? array_search($sorted[$eid-1], $block) : false;
$after = isset($sorted[$eid+1]) ? array_search($sorted[$eid+1], $block) : false;
if ($before && $after)
return [$before, $after];
// Если строка на границе - нужны доп. данные из БД
if (!$before)
$before = self::findBeforeByAfter($after) ?: 0;
if (!$after)
$after = self::findAfterByBefore($before) ?: array_pop(array_keys($block)) + 100;
return [$before, $after];
}
private function analyseBordersWithString($string)
{
$before = self::findPreviousEntryNumber($string) ?: 0;
$after = self::findAfterByBefore($before) ?: false;
// Если не нашлось ничего ни до ни после (первая запись в БД)
if ($before === 0 && !$after)
$after = 200;
if ($before !== 0 && $after)
return [$before, $after];
// Добавляемая строка попадает в конец списка, т.к. интервал 100, то суммируем двойной интервал
// (позже разделится на 2)
return [$before, $before + 200];
}
private function findPreviousEntryNumber($string)
{
return self::isInitialSymbolNumber($string) ? self::findPreviousStartedWithNumber() :
self::findPreviousStartedWithNotNumber($string);
}
private function findPreviousStartedWithNumber()
{
return Line::find()
->select('sequence')
->where(['<', 'line', 0])
->orderBy('sequence DESC')
->limit(1)
->scalar();
}
private function findPreviousStartedWithNotNumber($string)
{
return Line::find()
->select('sequence')
->where(['<', 'line', $string])
->orderBy('sequence DESC')
->limit(1)
->scalar();
}
// Находим следующий номер за указанным
private function findAfterByBefore($number)
{
return Line::find()
->select('sequence')
->where(['>', 'sequence', $number])
->orderBy('sequence ASC')
->limit(1)
->scalar();
}
// Находим номер, предыдущий указанному
private function findBeforeByAfter($number)
{
return Line::find()
->select('sequence')
->where(['<', 'sequence', $number])
->orderBy('sequence DESC')
->limit(1)
->scalar();
}
Чтобы результат стал виден пользователям, указываем поле, по которому будем сортировать данные в параметрах сортировки провайдера данных:
[
'defaultOrder' => [
'sequence' => SORT_ASC,
],
'attributes' => [
'line' => [
'asc' => ['sequence' => SORT_ASC],
'desc' => ['sequence' => SORT_DESC],
],
],
];
Буду рад замечаниям и уточнениям.