<? phpukraine СПІВБЕСІДИ
Пошук по платформі
YII · JUNIOR ЧАСТО ПИТАЮТЬ

Як працюють ActiveRecord і Query Builder у Yii2 і де межа між ними?

ActiveRecord у Yii2 — це надбудова над Query Builder: `ActiveQuery` успадковує `yii\db\Query`, будує той самий SQL, але замість масивів повертає обʼєкти моделей із валідацією, подіями, поведінками й звʼязками; Query Builder беруть там, де обʼєкти не потрібні — агрегати, звіти, масові операції.

Чим `Post::find()` відрізняється від `(new Query())->from('post')`?
Сторінка зі списком статей робить 40 запитів замість двох — де шукати причину?
Звіт по мільйону рядків падає з Allowed memory size exhausted, хоча запит один. Чому?
Ви написали `Post::updateAll()` — чому в таблиці не оновився `updated_at`?
Yii2 ActiveRecord Query Builder ActiveQuery N+1

У Yii2 це не два конкуруючі інструменти, а один із двома режимами. Post::find() повертає yii\db\ActiveQuery, який успадковує yii\db\Query — той самий Query Builder із select(), where(), join(), limit(). Різниця настає на останньому кроці: Query::all() віддає масив асоціативних масивів прямо з драйвера, а ActiveQuery::all() проганяє кожен рядок через populateRecord(), створює обʼєкт моделі, запамʼятовує oldAttributes для відстеження змін і викликає afterFind(). Тому «AR повільніший за Query Builder» — неточне формулювання: SQL однаковий, платите ви за гідратацію та за обʼєкти в памʼяті. Це видно й зі зворотного боку: Post::find()->asArray()->all() — це вже майже чистий Query Builder, хоча запит починався з моделі.

Що AR дає натомість — це поведінка навколо рядка. save() спершу викликає validate() за правилами моделі, потім beforeSave, пише лише змінені атрибути (getDirtyAttributes()), піднімає afterSave, і всі підписані поведінки — TimestampBehavior, BlameableBehavior, ваші власні — спрацьовують саме тут. Звʼязки описуються методами getAuthor() через hasOne()/hasMany(), і звернення $post->author виконує запит на місці. Саме звідси береться N+1: двадцять постів у циклі — це двадцять один запит. Ліки не в індексі на author_id, а в with('author'), який робить один додатковий SELECT * FROM user WHERE id IN (...) і розкладає результат по моделях уже в PHP.

Тут же живе найчастіша помилка новачка: with() не додає JOIN, тому фільтрувати чи сортувати по колонці приєднаної таблиці після нього не можна — SQL впаде на Unknown column. Для умови на полі звʼязку потрібен joinWith(), який будує справжній JOIN. Але й він має пастку: для hasMany JOIN розмножує рядки, і limit(20) після joinWith('comments') обмежить рядки JOIN, а не пости. Коли треба і фільтр по звʼязку, і чесний ліміт — фільтрують через joinWith(), а дані звʼязку довантажують окремим with().

Межа проходить по обсягу й меті. Якщо ви редагуєте одну-дві сутності, показуєте сторінку списку на 20–50 рядків, покладаєтесь на валідацію й події — це AR, і читабельність важливіша за мікросекунди. Якщо результат — це агрегат, звіт, експорт або масова зміна статусів, обʼєкти не потрібні: (new Query()) з groupBy()/having() поверне готові масиви, а updateAll() закриє 200 000 рядків одним UPDATE. Ціна updateAll() — вона ж і причина його швидкості: жодних validate(), подій і поведінок, тому updated_at доведеться додати в масив значень руками.

Про безпеку варто сказати без міфів. Значення в where(['status' => $status]), ['>', 'created_at', $time], ['like', 'title', $q] завжди передаються параметрами — інʼєкція через них неможлива. А от імена колонок у select(), orderBy(), groupBy() та вирази в new Expression() вставляються в SQL текстом, тож поле й напрямок сортування з запиту користувача звіряють з білим списком. І окремо: вхідний id перед findOne() приводьте до (int) — у 2.0.15.1 закрили саме той сценарій, коли масив із запиту перетворювався на довільну умову вибірки.

use app\models\Post;
use yii\db\Query;

// 1. Ліниве завантаження: 1 запит на пости + по одному на кожного автора (N+1)
foreach (Post::find()->limit(20)->all() as $post) {
    echo $post->author->name;          // окремий SELECT при першому зверненні
}

// 2. with() — рівно два запити: пости, далі SELECT * FROM user WHERE id IN (...)
$posts = Post::find()
    ->with('author')
    ->where(['status' => Post::STATUS_PUBLISHED])
    ->orderBy(['created_at' => SORT_DESC])
    ->limit(20)
    ->all();                           // масив обʼєктів Post

// 3. joinWith() потрібен, коли умова стоїть на полі звʼязку
$active = Post::find()
    ->joinWith('author')               // додає JOIN user ON user.id = post.author_id
    ->where(['user.is_active' => 1])   // з with() тут була б помилка Unknown column
    ->all();

// 4. Звіт: обʼєкти не потрібні, Query повертає масиви
$stats = (new Query())
    ->select(['author_id', 'total' => 'COUNT(*)'])
    ->from('{{%post}}')                // {{%...}} підставляє tablePrefix
    ->where(['status' => Post::STATUS_PUBLISHED])
    ->andWhere(['>=', 'created_at', $from])  // значення йде параметром, не текстом
    ->groupBy('author_id')
    ->having(['>', 'COUNT(*)', 5])
    ->all();

// 5. Масове оновлення повз модель: без validate(), beforeSave() і TimestampBehavior
Post::updateAll(['status' => Post::STATUS_ARCHIVED], ['<', 'created_at', $cutoff]);
Що `ActiveQuery` розширює `yii\db\Query`: це не два незалежні інструменти, а один запит із різною гідратацією результату.
Що `$post->author` — ліниве завантаження й окремий SELECT на кожну модель, а `with('author')` дає один додатковий запит з `WHERE id IN (...)`.
Різницю між `with()` і `joinWith()`: перший не вміє фільтрувати й сортувати по полю звʼязку, другий додає JOIN і робить умову `user.is_active` можливою.
Що `save()` тягне за собою `validate()`, події `beforeSave`/`afterSave` і поведінки на кшталт `TimestampBehavior`, а `updateAll()` іде повз усе це одним UPDATE.
Що ціна AR — гідратація: обʼєкт зберігає атрибути й `oldAttributes` для відстеження змін, тому на десятках тисяч рядків беруть `asArray()`, `Query` або `batch()`.
Вважати, що Query Builder «швидший», бо генерує кращий SQL: SQL той самий, різниця лише в тому, що робиться з рядками після вибірки.
Лікувати N+1 індексом на зовнішній ключ: індекс пришвидшує кожен із 40 запитів, але їх лишається 40.
Ставити `with('author')` і фільтрувати `->where(['user.is_active' => 1])` — SQL впаде з Unknown column, бо JOIN немає; тут потрібен `joinWith()`.
Робити `foreach` по `->all()` на сотнях тисяч рядків замість `batch()` або `each()` і дивуватись вичерпаній памʼяті.
Підставляти назву колонки чи напрямок сортування з `$_GET` у `orderBy()`: значення в `where()` біндяться параметрами, а імена колонок — ні.
ПОРАДА

Скажіть одним реченням: «ActiveQuery успадковує Query, тому SQL однаковий — вибір між ними це вибір, чи потрібні мені обʼєкти з подіями й звʼязками, чи достатньо масиву». Далі покажіть на прикладі: список статей — AR з `with()`, звіт із `GROUP BY` — `Query`.

оновлено 5 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

yii\db\ActiveQuery розширює yii\db\Query, тому запит будується тими самими методами; AR лише перетворює рядки на моделі з подіями, поведінками й звʼязками, і саме за це платить памʼяттю.