Есть ли способ автоматического добавления имени таблицы в методы запросов Eloquent?

Я разрабатываю приложение на Laravel 5.5 и сталкиваюсь с проблемой с конкретной областью запросов. У меня есть следующая структура таблицы (некоторые поля опущены):

orders
---------
id
parent_id
status

parent_id ссылается на id из той же таблицы. У меня есть эта область запроса для фильтрации записей, у которых нет детей:

public function scopeNoChildren(Builder $query): Builder
{
    return $query->select('orders.*')
        ->leftJoin('orders AS children', function ($join) {
            $join->on('orders.id', '=', 'children.parent_id')
                ->where('children.status', self::STATUS_COMPLETED);
        })
        ->where('children.id', null);
}

Этот объем отлично работает при использовании в одиночку. Однако, если я попытаюсь объединить его с любым другим условием, он выдает исключение SQL:

Order::where('status', Order::STATUS_COMPLETED)
    ->noChildren()
    ->get();

Приводит к следующему:

SQLSTATE [23000]: Нарушение ограничения целостности: 1052 Столбец "статус" в том месте, где предложение неоднозначно

Я нашел два способа избежать этой ошибки:

Решение # 1: префикс всех других условий с именем таблицы

Выполнение чего-то подобного:

Order::where('orders.status', Order::STATUS_COMPLETED)
    ->noChildren()
    ->get();

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

Решение №2: используйте подзапрос

Я могу хранить двусмысленные столбцы в подзапросе. Тем не менее, в этом случае и по мере роста таблицы производительность ухудшится.

Это стратегия, которую я использую. Потому что это не требует каких-либо изменений в других областях и условиях. По крайней мере, не так, как я применяю это сейчас.

public function scopeNoChildren(Builder $query): Builder
{
    $subQueryChildren = self::select('id', 'parent_id')
        ->completed();
    $sqlChildren = DB::raw(sprintf(
        '(%s) AS children',
        $subQueryChildren->toSql()
    ));

    return $query->select('orders.*')
        ->leftJoin($sqlChildren, function ($join) use ($subQueryChildren) {
            $join->on('orders.id', '=', 'children.parent_id')
                ->addBinding($subQueryChildren->getBindings());
         })->where('children.id', null);
}

Идеальное решение

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

Вот почему я спрашиваю: есть ли способ, чтобы имя таблицы автоматически добавлялось к методам запросов Eloquent?

Ответ 1

Я бы использовал отношения:

public function children()
{
    return $this->hasMany(self::class, 'parent_id')
        ->where('status', self::STATUS_COMPLETED);
}

Order::where('status', Order::STATUS_COMPLETED)
    ->whereDoesntHave('children')
    ->get();

Выполняет следующий запрос:

select *
from 'orders'
where 'status' = ?
  and not exists
    (select *
     from 'orders' as 'laravel_reserved_0'
     where 'orders'.'id' = 'laravel_reserved_0'.'parent_id'
       and 'status' = ?)

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

Я не думаю, что производительность будет актуальной проблемой, если у вас нет миллионов строк (я полагаю, что нет). Если производительность подзапроса будет проблемой в будущем, вы можете вернуться к решению JOIN. До тех пор я хотел бы сосредоточиться на читаемости и гибкости кода.

Способ повторного использования отношения (как указывает OP):

public function children()
{
    return $this->hasMany(self::class, 'parent_id');
}

Order::where('status', Order::STATUS_COMPLETED)
    ->whereDoesntHave('children', function ($query) {
        $query->where('status', self::STATUS_COMPLETED);
    })->get();

Или способ с двумя отношениями:

public function completedChildren()
{
    return $this->children()
        ->where('status', self::STATUS_COMPLETED);
}

Order::where('status', Order::STATUS_COMPLETED)
    ->whereDoesntHave('completedChildren')
    ->get();

Ответ 2

Вы должны создать SomeDatabaseBuilder расширяющий исходный Illuminate\Database\Query\Builder и SomeEloquentBuilder расширяющий Illuminate\Database\Eloquent\Builder и, наконец, BaseModel расширяющий Illuminate\Database\Eloquent\Model и перезаписывая эти методы:

/**
 * @return SomeDatabaseBuilder
 */
protected function newBaseQueryBuilder()
{
    $connection = $this->getConnection();

    return new SomeDatabaseBuilder(
        $connection, $connection->getQueryGrammar(), $connection->getPostProcessor()
    );
}

/**
 * @param \Illuminate\Database\Query\Builder $query
 * @return SameEloquentBulder
 */
public function newEloquentBuilder($query)
{
    return new SameEloquentBulder($query);
}

Затем, в SomeDatabaseBuilder и SameEloquentBulder, измените методы для классификации столбцов по умолчанию (или сделайте это необязательным).

Ответ 3

В MySQL есть два хороших способа найти листовые узлы (строки) в списке смежности. Один из них - метод LEFT-JOIN-WHERE-NULL (antijoin), который вы сделали. Другой - подзапрос NOT EXISTS. Оба метода должны иметь сопоставимую производительность (теоретически они делают то же самое). Однако решение подзапроса не приведет к появлению новых столбцов.

return $query->select('orders.*')
    ->whereRaw("not exists (
        select *
        from orders as children
        where children.parent_id = orders.id
          and children.status = ?
    )", [self::STATUS_COMPLETED]);