Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
27007b4
Add BeforeLazyRelationLoad event and LazyLoadGuard
KalimeroMK Sep 20, 2026
c1b49e7
Cover prevented BeforeLazyRelationLoad event
KalimeroMK Sep 20, 2026
0a27a4f
Apply Rector suggestion in EventDispatcherProvider
KalimeroMK Sep 21, 2026
37fc942
Fix double event dispatch in tests, count in strict mode, document event
KalimeroMK Sep 21, 2026
174a71d
Drop Off mode and only/except from LazyLoadGuard
KalimeroMK Sep 23, 2026
39f3a9d
Move lazy load guard to a separate trait without events
KalimeroMK Sep 23, 2026
c06c21e
Report lazy load starting from the second load of the same relation
KalimeroMK Sep 23, 2026
c0a004d
Merge branch 'master' into lazy-load-guard
KalimeroMK Oct 10, 2026
21b0bf5
Apply Rector suggestion in MagicRelationsTrait
KalimeroMK Oct 11, 2026
4a715b6
Count lazy loads per result set
KalimeroMK Oct 11, 2026
b12bf63
Clarify when a lazy load is reported
KalimeroMK Oct 11, 2026
ab9e220
Report the count and the relation to eager load
KalimeroMK Oct 11, 2026
2a8dd34
Cover MagicPropertiesTrait usage
KalimeroMK Oct 11, 2026
7425406
Document the report and resetting the guard between tests
KalimeroMK Oct 11, 2026
8e97cf1
Throw a friendly exception when the guard reports in strict mode
KalimeroMK Oct 11, 2026
862e620
Count distinct records of a result set and detect the trait used indi…
KalimeroMK Oct 11, 2026
98c15b1
Name the model in the exception solution
KalimeroMK Oct 11, 2026
a891589
Cover re-loaded relations, asArray sets, nested and indirect guards
KalimeroMK Oct 11, 2026
2b0d82f
Document record counting, indirect trait use and resetCounters()
KalimeroMK Oct 11, 2026
64fec4e
Track the records of a result set by id, not in a WeakMap
KalimeroMK Oct 11, 2026
4e3d408
Take the result set and the map once per registered set
KalimeroMK Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## 1.1.1 under development

- no changes in this release.
- Enh #590: Add `LazyLoadGuardTrait` and `LazyLoadGuard` to detect N+1 queries
(@KalimeroMK)

## 1.1.0 May 14, 2026

Expand Down
2 changes: 1 addition & 1 deletion composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// consumers who don't `use` the trait don't need the package. See the "suggest" section
// in composer.json.
->ignoreErrorsOnPackages(
['yiisoft/arrays', 'yiisoft/event-dispatcher', 'yiisoft/factory'],
['psr/log', 'yiisoft/arrays', 'yiisoft/event-dispatcher', 'yiisoft/factory'],
[ErrorType::DEV_DEPENDENCY_IN_PROD],
)
// psr/event-dispatcher is the PSR interface backing the optional yiisoft/event-dispatcher
Expand Down
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
],
"require": {
"php": "8.1 - 8.5",
"yiisoft/db": "^2.0"
"yiisoft/db": "^2.0",
"yiisoft/friendly-exception": "^1.1"
},
"require-dev": {
"ext-pdo": "*",
"bamarni/composer-bin-plugin": "^1.8.3",
"friendsofphp/php-cs-fixer": "^3.89.1",
"phpunit/phpunit": "^10.5.58",
"psr/log": "^2.0|^3.0",
"psr/simple-cache": "^2.0|^3.0",
"rector/rector": "^2.2.3",
"shipmonk/composer-dependency-analyser": "^1.8",
Expand All @@ -60,7 +62,8 @@
"yiisoft/db-mssql": "For MSSQL database support",
"yiisoft/db-oracle": "For Oracle database support",
"yiisoft/factory": "For factory support",
"yiisoft/event-dispatcher": "For events support"
"yiisoft/event-dispatcher": "For events support",
"psr/log": "For \\Yiisoft\\ActiveRecord\\LazyLoadGuard logging support"
},
"autoload": {
"psr-4": {
Expand Down
97 changes: 97 additions & 0 deletions docs/traits/lazy-load-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# LazyLoadGuardTrait

`LazyLoadGuardTrait` allows detecting N+1 queries caused by lazy loading of relations, i.e. reading a relation
that wasn't eager-loaded by `with()`.

```php
use Yiisoft\ActiveRecord\ActiveRecord;
use Yiisoft\ActiveRecord\Trait\LazyLoadGuardTrait;

final class Customer extends ActiveRecord
{
use LazyLoadGuardTrait;
}
```

Lazy loads are counted by [LazyLoadGuard](../../src/LazyLoadGuard.php) per result set, and reported according to
the mode starting from the second record of the same result set that reads the relation. One record reading a
relation isn't an N+1 problem, and neither are records that were each fetched by a query of their own, since there
is nothing to eager load across them:

```php
// Reported: one query for the customers, then one query per customer for the orders.
foreach (Customer::query()->all() as $customer) {
$customer->getOrders();
}

// Not reported: each customer came from a query of its own.
Customer::query()->findByPk(1)->getOrders();
Customer::query()->findByPk(2)->getOrders();

// Not reported: one record reading the relation again after it was reset. Records are counted, not reads.
$customer = Customer::query()->findByPk(1);
$customer->getOrders();
$customer->refresh();
$customer->getOrders();
```

The model may use the trait through a parent class or through another trait of its own, which is where a
project-wide base trait usually puts it.

The mode and the logger are set statically:

```php
use Yiisoft\ActiveRecord\LazyLoadGuard;
use Yiisoft\ActiveRecord\LazyLoadGuardMode;

LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger);
```

| Mode | Behavior |
|----------|------------------------------------------------------------------------------------|
| `Log` | Default. Writes a PSR-3 warning; the context carries the model, relation and trace |
| `Strict` | Throws `LazyLoadException` |

Both modes report the same text, which names the relation, how many records of the result set read it and the
`with()` call that would replace them with one query:

```
Relation "App\Model\Customer::orders" is lazy loaded by 3 records of one result set.
Eager load it with "with('orders')" to run one query instead of 3.
```

In `Log` mode the logger is optional; when it's omitted the lazy loads are still counted and readable through
`LazyLoadGuard::getCounters()`, but nothing is written anywhere. `getCounters()` returns every lazy load per
relation, including the ones that weren't reported because they belong to result sets of their own.

The counters live until they're cleared. In long-running workers call `LazyLoadGuard::resetCounters()` after
each request, so that the result sets of one request aren't counted against the next; it keeps the mode and the
logger, so they're configured once:

```php
LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger);

while ($request = $queue->next()) {
$handler->handle($request);
LazyLoadGuard::resetCounters();
}
```

In a test suite call `LazyLoadGuard::reset()` between tests, which clears the counters and restores the mode and
the logger to their defaults as well, so that the setup of one test doesn't leak into the next:

```php
protected function setUp(): void
{
LazyLoadGuard::reset();
LazyLoadGuard::set(LazyLoadGuardMode::Strict);
}
```

The guard is meant for development and testing, e.g. `Strict` in the test suite and `Log` on staging.

> [!NOTE]
> The trait overrides `retrieveRelation()` method. If another trait of the model overrides it too,
> resolve the conflict using `insteadof` and `as` operators.

Back to [Extending Functionality With Traits](traits.md).
1 change: 1 addition & 0 deletions docs/traits/traits.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and should be used based on your specific needs.
- [CustomTableNameTrait](custom-table-name.md) allows using a custom table name for a model;
- [EventsTrait](events.md) allows using events and handlers for a model;
- [FactoryTrait](factory.md) allows creating models and relations using [yiisoft/factory](https://github.com/yiisoft/factory);
- [LazyLoadGuardTrait](lazy-load-guard.md) allows detecting N+1 queries caused by lazy loading of relations;
- [MagicPropertiesTrait](magic-properties.md) stores properties in a private property and provides magic getters
and setters for accessing the model properties and relations;
- [MagicRelationsTrait](magic-relations.md) allows using methods with prefix `get` and suffix `Query` to define
Expand Down
4 changes: 4 additions & 0 deletions src/ActiveQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ public function populate(array $rows): array

$models = $this->createModels($rows);

if (count($models) > 1) {
LazyLoadGuard::registerResultSet($models);
}
Comment on lines +324 to +326

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is concerning that this code will always execute, even when N+1 query detection is not needed.

Why is it needed if you always create new LazyLoadResultSet() there https://github.com/yiisoft/active-record/pull/591/changes#diff-d6bdeea9079931e23a1a13d2c4a1974b57a13a8f0ec17e84fea80aa6c33be4dfR93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, I measured it to be sure: registering 10,000 unguarded records takes 0.93 ms against 45.71 ms of hydrating them, so it's ~2% in the worst case I could build. If that's still too much, the cheapest fix is an explicit switch checked first thing here. @samdark had me drop the Off mode in 174a71d, so I'd rather not bring one back on my own — what do you two prefer, always on and cheap, or a switch again?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that's not consuming anything if not registered, right? So not an issue in production and very little resource consumption in dev/staging. That's acceptable to me.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

registering 10,000 unguarded records takes 0.93 ms against 45.71 ms

For debugging / profiling it is okay

Given this https://github.com/yiisoft/active-record/pull/591/changes#r4244295174, it's better to remove this in production.


if (!empty($this->with)) {
$this->findWith($this->with, $models);
}
Expand Down
4 changes: 1 addition & 3 deletions src/Event/EventDispatcherProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ final class EventDispatcherProvider
*/
public static function get(string $targetClass): EventDispatcherInterface
{
if (!isset(self::$dispatchers[$targetClass])) {
self::$dispatchers[$targetClass] = new Dispatcher(new Provider(self::getListenersFromAttributes($targetClass)));
}
self::$dispatchers[$targetClass] ??= new Dispatcher(new Provider(self::getListenersFromAttributes($targetClass)));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related?


return self::$dispatchers[$targetClass];
}
Expand Down
44 changes: 44 additions & 0 deletions src/Internal/LazyLoadResultSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord\Internal;

use Yiisoft\ActiveRecord\ActiveRecordInterface;
use Yiisoft\ActiveRecord\LazyLoadGuard;

use function count;
use function spl_object_id;

/**
* Counts the records of one result set that lazy loaded a relation. The counts are freed together with the result
* set they belong to, which the records are the only reference to.
*
* @internal
*
* @see LazyLoadGuard
*/
final class LazyLoadResultSet
{
/**
* @var array[] The ids of the records that lazy loaded the relation `[relation => [record_id => true], ...]`.
*
* The records of a result set are known when it's registered, while all of them are alive, so an id of a
* freed record is never reused within the same result set.
*
* @psalm-var array<string, array<int, true>>
*/
private array $records = [];

/**
* Registers a record that lazy loaded the relation and returns the number of records of this result set that
* have loaded it. A record that loads the relation again, after {@see ActiveRecordInterface::resetRelation()}
* or {@see ActiveRecordInterface::refresh()} dropped it, is counted once.
*/
public function registerLazyLoad(ActiveRecordInterface $record, string $relation): int
{
$this->records[$relation][spl_object_id($record)] = true;

return count($this->records[$relation]);
}
}
46 changes: 46 additions & 0 deletions src/LazyLoadException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord;

use LogicException;
use Yiisoft\FriendlyException\FriendlyExceptionInterface;

/**
* Represents an exception thrown by {@see LazyLoadGuard} in the {@see LazyLoadGuardMode::Strict} mode when several
* records of one result set lazy load the same relation.
*/
final class LazyLoadException extends LogicException implements FriendlyExceptionInterface
{
/**
* @param class-string $modelClass
*/
public function __construct(
string $message,
private readonly string $modelClass,
private readonly string $relationName,
) {
parent::__construct($message);
}

public function getName(): string
{
return 'Relation is lazy loaded by several records of one result set';
}

public function getSolution(): ?string
{
return <<<SOLUTION
Every record runs a query of its own to load the relation. Eager load it with the query that reads
the records, so that the relation is read with a single query:

```php
\$records = \\{$this->modelClass}::query()->with('$this->relationName')->all();
```

When the lazy loads are intentional, switch the guard to `LazyLoadGuardMode::Log` to keep reporting
them without throwing.
SOLUTION;
}
}
Loading
Loading