Недавно в рабочем проекте потребовалось реализовать универсальную сортировку, которая подходила бы для большинства моделей. Если с обычной сортировкой по полю всё просто - её, подробно описанную, вы найдёте в документации к фреймворку, то для случаев сортировки, когда требуемое поле находится НЕ в основной таблице, а в одной из связанных, информации оказалось исключительно мало и она была весьма разрозненной.

После завершения задачи решил создать этот более‑менее «всеохватывающий» гайд.

Я считаю, что демонстрация на примере лучше общего описания, поэтому далее будет использоваться следующая структура из таблиц:

Набросок структуры бд
Набросок структуры бд

В качестве "дано" имеем:

1. Модель Documents с relation-ами:

  1. owner() - MorpTo (полиморфное отношение);

  2. location() - BelongsTo

  3. responsible() - BelongsTo

2. Модель Employee c relation-ом individual() - BelongsTo

3. Модели Location, Client, ClientAsset, Individual

Случай 1: Отсортировать документы по названию Локации

Наиболее простой из возможных. "В вакууме" чаще всего решается следующим образом

// для типа BelongsTo
$documents = Document::query()->orderBy(Location::select('name')
    ->whereColumn('locations.id', 'documents.location_id')
);

// для типа HasOne/Many
$documents = Document::::query()->orderBy(Location::select('name')
    ->whereColumn('locations.document_id', 'documents.id')
);

Более сложный, но, как показалось мне, более универсальный cпособ, так как все параметры для метода join можно получить из объектов моделей методами Laravel (таким образом его можно будет сделать универсальным для всех моделей)

// для типа BelongsTo
$documents = Document::query()
  ->leftJoin(
    'locations'
    'id'
    '=',
    'documents.location_id'
  ->orderBy(DB::raw("ISNULL('locations.name'), 'locations.name'"), $order);

// для типа HasOne/Many
$documents = Document::query()
  ->leftJoin(
    'locations'
    'documents.id'
    '=',
    'locations.document_id'
  ->orderBy(DB::raw("ISNULL('locations.name'), 'locations.name'"), $order);

Обратите внимание, на два момента:

  1. Используется именно leftJoin, т.к. он выбирает все записи из исходной таблицы. При любом другом типе присоединения, если Документ, предположим, не имеет связанной Локации (location_id = null), то запись не будет включена в итоговый результат запроса.

  2. Конструкция DB::raw(...) в качестве параметра для сортировки позволит вам разместить все результаты с location_id = null в конце списка результатов. По умолчанию они помещаются в начало.

    Вышеуказанное действует и для остальных случаев, поэтому заострять внимание на этих моментах я больше не буду.

Случай 2: Отсортировать по имени Ответственного

Данный пример похож на предыдущий, однако осложняется тем, что требуемое поле находится не в самой связанной модели, а ещё «глубже».

Решается данная проблема добавлением «подзапроса» с присоединением таблицы individuals к employees перед присоединением employees к documents

$subquery = Employee::query()
  ->selectRaw(
    '`employees`.*,
    `individuals`.`name` AS name')
  ->leftJoin('individuals', function (JoinClause $join) {
    $join->on('individuals.id', '=', 'employees.individual_id')
      ->whereRaw('`individuals`.`id` = employees.individual_id');
  });

Обратите внимение на выражение selectRaw(...) оформляться оно должно именно таким образом. Нельзя "выбрать всё" через указание просто '*' - в таком случае при попытке присоединить этот подзапрос к запросу "старше" вы получите ошибку "колонка id уже существует".
Выражение 'individuals.name AS name' позволяет при присоединении создать в таблице employees колонку name, что позволит вам выполнить сортировку по полю employee.name, аналогично примеру для случая 1.

Далее присоединяем полученную "слиянием" таблицу к таблице документов

Document::query()
->leftJoinSub($subquery, 'employees', function (JoinClause $join) {
  $join->on('documents.responsible_id', '=', 'employees.id')
    $join->on('documents.owner_id', '=', 'employees.id');
});

Для типов отношений HasOne/Many параметры join-на изменятся точно таким же образом, как показано в примере для случая 1.

В теории такой подход сработает и для случаев с «более глубокой вложенностью», однако мной на практике это не проверялось.

Случай 3: Отсортировать документы по имени владельца

Данный случай осложнён тем, что отношение является полиморфным. Следовательно придётся "динамически" определять из какой таблицы присоединять нужное поле.
Также дополнительной сложностью является то, что в таблице clients нет поля name в чистом виде - оно является комбинацией двух других полей.

В данном случае основная сложность заключается в написании SELECT-а для присоединения.

Document::query()
  ->selectRaw('
    documents.*,
    CASE 
      WHEN clients.id IS NOT NULL then concat(clients.lastname, ' ', clients.firstname)
      WHEN excess_stocks.id IS NOT NULL then clients_assets.name
      ELSE NULL
    END AS owner
  ')
  ->leftJoin('clients', function (JoinClause $join) {
      $join->on('documents.owner_id', '=', 'clients.id')
        ->on('owner_type', '=', 'App\\Models\\Client'),
  })
  ->leftJoin('clients_assets', function (JoinClause $join) {
      $join->on('documents.owner_id', '=', 'clients_assets.id')
        ->on('owner_type', '=', 'App\\Models\\ClientAsset'),
  })

В данном случае в качестве "поля" по которому производится фильтрация используется название отношения - owner.

Обратите внимание, что для получения нужного поля в случае таблицы cliesnt используется выражение, соединяющее сразу несколько полей в одно.

Итоги, замечания и мысли.

Мною были рассмотрены три примеры основных случаев сортировки по релейшенам.
Более сложные варианты могут быть решены комбинацией этих методов (в реальном проекте вторая связь из случая 2 также была полиморфной).

В примерах было "максимум конкретики", на практике же это обычно не очень хорошо, методы должны быть максимально универсальными.

Для этого вместо конкретных имён таблиц, отношений, ключей и т.п. могут быть использованы методы Eloquent, позволяющие их получить.

Вот немного «лайфкаков» на эту тему:

//создаём "абстрактный" экземпляр модели
$modelClass = Document::class;
$model = App::make($modelClass);

//получаем таблицу
$model->getTable();

//получаем связанную таблицу, имея название отношения
$field = 'location' // см Случай 1

if ($modelObject>isRelation($field)) {
  $relatedModel = $model->$field()->getRelated();

  // узнаем какого типа relation
  $type = (new ReflectionClass($modelClass))->getMethod($field)->getReturnType();
}

//собираем join для типа BelongsTo
$relation = $field;

$modelClass::query()->leftJoin(
  $model->getTable(),
  $relatedModel->getQualifiedKeyName(),
  '=',
  $model->getTable().'.'.$model->$relation()->getForeignKeyName());


Написано всё это было на уровне "junior+". Вполне возможно, что некоторые вещи сделаны неоптимально, принимаю критику и дополнения.

Комментарии (4)


  1. modestguy
    30.10.2023 11:39
    +1

    Блин....х.з. Есть у нас один проект на Yii, который просто изобилует "универсальными" подходами. В том числе и такие конструкции имеются. Мой совет - лучше такого избегать. Это моё мнение. Вообще много зависит от того, насколько большая кодовая база.... Но если проект будет огромным и будет постоянно расти - то такие конструкции в будущем приведут к проблемам (от банального "сложно прочитать код", до "лучше не использовать рефлексию, где можем без неё обойтись" и оптимизации узких мест). В общем весьма сомнительное решение. Это конечно здорово, вы понимаете всякие технические штучки(рефлексию, dry) - но, увеличит сложность (поддержки, разработки и т.д.). Моя рекомендация: не надо так делать в большом проекте. Лучше имхо будет повторительство, но прозрачное.


    1. ValerianMemsk Автор
      30.10.2023 11:39

      Благодарю за замечание. Я учту.


  1. latwatburd
    30.10.2023 11:39

    https://github.com/tylernathanreed/laravel-relation-joins

    Вот ещё интересный пакет, можете оценить.


    1. ValerianMemsk Автор
      30.10.2023 11:39

      Спасибо. Удивительно, за несколько дней разбирательств в материале ни разу не встречал ссылку на эту либу.