Yii2 ActiveRecord шпаргалка по составлению запросов

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

Однако, если что-то останется непонятным, то полную документацию по работе с ActiveRecord в Yii2 всегда можно найти по ссылке:

https://github.com/yiisoft/yii2/blob/master/docs/guide-ru/db-active-record.md

Это ссылка на документацию, которая постоянно обновляется и дополняется.

Извлечение данных

// возвращает покупателя с идентификатором 123
// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::find()
    ->where(['id' => 123])
    ->one();

// возвращает всех активных покупателей, сортируя их по идентификаторам
// SELECT * FROM `customer` WHERE `status` = 1 ORDER BY `id`
$customers = Customer::find()
    ->where(['status' => Customer::STATUS_ACTIVE])
    ->orderBy('id')
    ->all();

// возвращает количество активных покупателей
// SELECT COUNT(*) FROM `customer` WHERE `status` = 1
$count = Customer::find()
    ->where(['status' => Customer::STATUS_ACTIVE])
    ->count();

// возвращает всех покупателей массивом, индексированным их идентификаторами
// SELECT * FROM `customer`
$customers = Customer::find()
    ->indexBy('id')
    ->all();

Шорткаты для методов, которые сразу возвращают данные:

// возвращает покупателя с идентификатором 123
// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::findOne(123);

// возвращает покупателей с идентификаторами 100, 101, 123 и 124
// SELECT * FROM `customer` WHERE `id` IN (100, 101, 123, 124)
$customers = Customer::findAll([100, 101, 123, 124]);

// возвращает активного покупателя с идентификатором 123
// SELECT * FROM `customer` WHERE `id` = 123 AND `status` = 1
$customer = Customer::findOne([
    'id' => 123,
    'status' => Customer::STATUS_ACTIVE,
]);

// возвращает всех неактивных покупателей
// SELECT * FROM `customer` WHERE `status` = 0
$customers = Customer::findAll([
    'status' => Customer::STATUS_INACTIVE,
]);

Note: Ни метод [[yii\db\ActiveRecord::findOne()]], ни [[yii\db\ActiveQuery::one()]] не добавляет условие LIMIT 1 к генерируемым SQL-запросам. Если ваш запрос может вернуть много строк данных, вы должны вызвать метод limit(1) явно в целях улучшения производительности, например: Customer::find()->limit(1)->one().

Пример на голом SQL

// возвращает всех неактивных покупателей
$sql = 'SELECT * FROM customer WHERE status=:status';
$customers = Customer::findBySql($sql, [
    ':status' => Customer::STATUS_INACTIVE
])->all();

Можно получить данные в виде массива:

// возвращает всех покупателей
// каждый покупатель будет представлен в виде ассоциативного массива
$customers = Customer::find()
    ->asArray()
    ->all();

Пример пакетной выборки для снижения потребления ресурсов:

// получить 10 покупателей одновременно
foreach (Customer::find()->batch(10) as $customers) {
    // $customers - это массив, в котором находится 10 
    // или меньше объектов класса Customer
}

// получить одновременно десять покупателей и перебрать их одного за другим
foreach (Customer::find()->each(10) as $customer) {
    // $customer - это объект класса Customer
}

// пакетная выборка с жадной загрузкой
foreach (Customer::find()->with('orders')->each() as $customer) {
    // $customer - это объекта класса Customer
}

Обновление счетчика. Метод save() здесь не особо пригоден из-за параллельных запросов.

$post = Post::findOne(100);

// UPDATE `post` SET `view_count` = `view_count` + 1 WHERE `id` = 100
$post->updateCounters(['view_count' => 1]);

ActiveRecord не получает автоматически данные по умолчанию из таблиц. Но их можно загрузить:

$customer = new Customer();
$customer->loadDefaultValues();
// $customer->xyz получит значение по умолчанию, 
// которое было указано при объявлении столбца "xyz"

Обновление данных

Обновление нескольких строк в таблице:

// UPDATE `customer` SET `status` = 1 WHERE `email` LIKE `%@example.com%`
Customer::updateAll(
    ['status' => Customer::STATUS_ACTIVE], 
    ['like', 'email', '@example.com']
);

Обновление счетчиков:

// UPDATE `customer` SET `age` = `age` + 1
Customer::updateAllCounters(['age' => 1]);

Удаление данных

$customer = Customer::findOne(123);
$customer->delete();

Удаление сразу многих записей. Осторожно: немного напортачишь с условиями и удалятся все данные.

Customer::deleteAll(['status' => Customer::STATUS_INACTIVE]);

Работа с транзакциями

$customer = Customer::findOne(123);

Customer::getDb()->transaction(function($db) use ($customer) {
    $customer->id = 200;
    $customer->save();
    // ...другие операции с базой данных...
});

// или по-другому

$transaction = Customer::getDb()->beginTransaction();
try {
    $customer->id = 200;
    $customer->save();
    // ...другие операции с базой данных...
    $transaction->commit();
} catch(\Exception $e) {
    $transaction->rollBack();
    throw $e;
} catch(\Throwable $e) {
    $transaction->rollBack();
    throw $e;
}

Так можно указать в ActiveRecord, какие именно операции требуют транзакционного выполнения:

class Customer extends ActiveRecord
{
    public function transactions()
    {
        return [
            'admin' => self::OP_INSERT,
            'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE,
            // вышеприведённая строка эквивалентна следующей:
            // 'api' => self::OP_ALL,
        ];
    }
}

Связи

class Customer extends ActiveRecord
{
    public function getOrders()
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id']);
    }
}

class Order extends ActiveRecord
{
    public function getCustomer()
    {
        return $this->hasOne(Customer::className(), ['id' => 'customer_id']);
    }
}

Доступ к связанным данным:

// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::findOne(123);

// SELECT * FROM `order` WHERE `customer_id` = 123
// $orders - это массив объектов Order
$orders = $customer->orders;

Можно получить и объект запроса:

$customer->orders; // массив объектов `Order`
$customer->getOrders(); // объект ActiveQuery

Вот как можно построить запрос с условиями:

$customer = Customer::findOne(123);

// SELECT * FROM `order` WHERE `customer_id` = 123 AND `subtotal` > 200 ORDER BY `id`
$orders = $customer->getOrders()
    ->where(['>', 'subtotal', 200])
    ->orderBy('id')
    ->all();

Связи также могут быть и динамическими:

class Customer extends ActiveRecord
{
    public function getBigOrders($threshold = 100)
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id'])
            ->where('subtotal > :threshold', [':threshold' => $threshold])
            ->orderBy('id');
    }
}

Теперь можно делать такие вещи:

// SELECT * FROM `order` WHERE `customer_id` = 123 AND `subtotal` > 200 ORDER BY `id`
$orders = $customer->getBigOrders(200)->all();

// SELECT * FROM `order` WHERE `customer_id` = 123 AND `subtotal` > 100 ORDER BY `id`
$orders = $customer->bigOrders;

Объявление связи многие ко многим.
С указанием названия связующей таблицы:

class Order extends ActiveRecord
{
    public function getItems()
    {
        return $this->hasMany(Item::className(), ['id' => 'item_id'])
            ->viaTable('order_item', ['order_id' => 'id']);
    }
}

С указанием связи этой таблицы:

class Order extends ActiveRecord
{
    public function getOrderItems()
    {
        return $this->hasMany(OrderItem::className(), ['order_id' => 'id']);
    }

    public function getItems()
    {
        return $this->hasMany(Item::className(), ['id' => 'item_id'])
            ->via('orderItems');
    }
}

Отложенная и жадная загрузка (with)

Отложенная:

// SELECT * FROM `customer` LIMIT 100
$customers = Customer::find()->limit(100)->all();

foreach ($customers as $customer) {
    // SELECT * FROM `order` WHERE `customer_id` = ...
    $orders = $customer->orders;
}

Тут запрос на извлечение товаров будет выполнен 100 раз.

Жадная:

// SELECT * FROM `customer` LIMIT 100;
// SELECT * FROM `orders` WHERE `customer_id` IN (...)
$customers = Customer::find()
    ->with('orders')
    ->limit(100)
    ->all();

foreach ($customers as $customer) {
    // SQL-запрос не выполняется
    $orders = $customer->orders;
}

Тут сначала извлекутся клиенты, а следующим запросом список заказов. Итого 2 запроса.

Разные способы выполнение жадной загрузки:

// жадная загрузка "orders" и "country" одновременно
$customers = Customer::find()->with('orders', 'country')->all();
// аналог с использованием синтаксиса массива
$customers = Customer::find()->with(['orders', 'country'])->all();
// SQL-запрос не выполняется
$orders= $customers[0]->orders;
// SQL-запрос не выполняется
$country = $customers[0]->country;

// жадная загрузка связи "orders" и вложенной связи "orders.items"
$customers = Customer::find()->with('orders.items')->all();
// доступ к деталям первого заказа первого покупателя 
// SQL-запрос не выполняется
$items = $customers[0]->orders[0]->items;

При жадной загрузке можно настроить запрос при помощи анонимной функции:

// найти покупателей и получить их вместе с их странами и активными заказами
// SELECT * FROM `customer`
// SELECT * FROM `country` WHERE `id` IN (...)
// SELECT * FROM `order` WHERE `customer_id` IN (...) AND `status` = 1
$customers = Customer::find()->with([
    'country',
    'orders' => function ($query) {
        $query->andWhere(['status' => Order::STATUS_ACTIVE]);
    },
])->all();

Если используется select то для жадной загрузки нужно перечислять поля участвующие в связи:

$orders = Order::find()
    ->select(['id', 'amount'])
    ->with('customer')
    ->all();
// $orders[0]->customer всегда равно null. 

// Для исправления проблемы вы должны сделать следующее:
$orders = Order::find()
    ->select(['id', 'amount', 'customer_id'])
    ->with('customer')
    ->all();

Использование JOIN со связями (with, joinWith)

Иногда нужно ссылаться ссылаться на столбцы связанных таблиц.
Так можно получить клиентов, имеющих активные заказы:

// SELECT `customer`.* FROM `customer`
// LEFT JOIN `order` ON `order`.`customer_id` = `customer`.`id`
// WHERE `order`.`status` = 1
// 
// SELECT * FROM `order` WHERE `customer_id` IN (...)
$customers = Customer::find()
    ->select('customer.*')
    ->leftJoin('order', '`order`.`customer_id` = `customer`.`id`')
    ->where(['order.status' => Order::STATUS_ACTIVE])
    ->with('orders')
    ->all();

Но если у нас есть связь orders, то нет необходимости указывать ее вручную, а можно сделать так:

$customers = Customer::find()
    ->joinWith('orders')
    ->where(['order.status' => Order::STATUS_ACTIVE])
    ->all();

В обоих случаях получается один и тот же sql запрос.

Во втором параметре joinWith можно передать тип join. По умолчанию используется LEFT.

Если не нужно жадно загружать данные, вторым параметром в joinWith нужно передать false.

joinWith можно смешивать с with. Например:

$customers = Customer::find()->joinWith([
    'orders' => function ($query) {
        $query->andWhere(['>', 'subtotal', 100]);
    },
])->with('country')->all();

Для join запроса, можно указать условие, которое будет добавлено в on:

// SELECT `customer`.* FROM `customer`
// LEFT JOIN `order` ON `order`.`customer_id` = `customer`.`id` AND `order`.`status` = 1 
// 
// SELECT * FROM `order` WHERE `customer_id` IN (...)
$customers = Customer::find()->joinWith([
    'orders' => function ($query) {
        $query->onCondition(['order.status' => Order::STATUS_ACTIVE]);
    },
])->all();

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

При использовании JOIN нужно явно решать конфликты имен. Например:

$query->joinWith([
  'orders' => function ($q) {
      $q->from(['o' => Order::tableName()]);
  },
])

Более простой способ задать псевдоним

$query->joinWith(['orders o'])->orderBy('o.id');

Этот синтаксис работает для простых связей. Если же необходимо использовать связующую таблицу, например $query->joinWith(['orders.product']), то вызовы joinWith вкладываются друг в друга:

$query->joinWith(['orders o' => function($q) {
      $q->joinWith('product p');
  }])
  ->where('o.amount > 100');

Есть способ задать обратную связь:

class Customer extends ActiveRecord
{
    public function getOrders()
    {
        return $this->hasMany(Order::className(), [
            'customer_id' => 'id'
        ])->inverseOf('customer');
    }
}

Иначе при определенных условиях будут генерироваться дополнительные запросы.

// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::findOne(123);

// SELECT * FROM `order` WHERE `customer_id` = 123
$order = $customer->orders[0];

// SELECT * FROM `customer` WHERE `id` = 123
$customer2 = $order->customer;

// выведет "not the same"
echo $customer2 === $customer ? 'same' : 'not the same';

Сохранение связанных данных.

Вместо:

$order->customer_id = $customer->id;
$order->save();

Можно писать:

$order->link('customer', $customer);

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

У ->link есть противоположная операция unlink:

$customer = Customer::find()->with('orders')->where(['id' => 123])->one();
$customer->unlink('orders', $customer->orders[0]);

По умолчанию метод [[yii\db\ActiveRecord::unlink()|unlink()]] задаст вторичному ключу (или ключам), который определяет существующую связь, значение null. Однако вы можете запросить удаление строки таблицы, которая содержит значение вторичного ключа, передав значение true в параметре $delete для этого метода.

Класс Query можно переопределять, для более тонкой настройки.

namespace app\models;

use yii\db\ActiveRecord;
use yii\db\ActiveQuery;

class Comment extends ActiveRecord
{
    public static function find()
    {
        return new CommentQuery(get_called_class());
    }
}

class CommentQuery extends ActiveQuery
{
    // ...
}

Например в своем классе запросов, можно объявить вспомогательные методы:

class CommentQuery extends ActiveQuery
{
    public function active($state = true)
    {
        return $this->andWhere(['active' => $state]);
    }
}