Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"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 +61,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
43 changes: 43 additions & 0 deletions docs/traits/lazy-load-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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;
}
```

Every lazy load is registered by [LazyLoadGuard](../../src/LazyLoadGuard.php). Starting from the second lazy load
of the same relation it's reported according to the mode, since loading a relation once isn't an N+1 problem:

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

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

| Mode | Behavior |
|----------|---------------------------------------------------------------------------------------------------|
| `Log` | Default. Reports a PSR-3 warning with the relation name, the lazy load count and a stack trace |
| `Strict` | Throws `LogicException` |

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. `LazyLoadGuard::reset()` restores the defaults
and clears the counters.

The counters live until `LazyLoadGuard::reset()` is called. In long-running workers call it after each request.

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: 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)));

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

declare(strict_types=1);

namespace Yiisoft\ActiveRecord;

use Exception;
use LogicException;
use Psr\Log\LoggerInterface;
use Yiisoft\ActiveRecord\Trait\LazyLoadGuardTrait;

/**
* Detects N+1 queries caused by lazy loading of relations in models using {@see LazyLoadGuardTrait}.
*/
final class LazyLoadGuard
{
private static LazyLoadGuardMode $mode = LazyLoadGuardMode::Log;
private static ?LoggerInterface $logger = null;

/**
* @var int[] Number of detected lazy loads `[model_class::relation_name => count, ...]`
*/
private static array $counters = [];

/**
* Sets the mode of the guard and the logger used in the {@see LazyLoadGuardMode::Log} mode.
*/
public static function set(LazyLoadGuardMode $mode, ?LoggerInterface $logger = null): void
{
self::$mode = $mode;
self::$logger = $logger;
}

/**
* Registers a lazy load of the relation and reports it according to the mode, starting from the second lazy load
* of the same relation, since loading a relation once isn't an N+1 problem.
*
* @throws LogicException In the {@see LazyLoadGuardMode::Strict} mode.
*/
public static function check(ActiveRecordInterface $model, string $relationName): void
{
$modelClass = $model::class;
$relation = $modelClass . '::' . $relationName;

self::$counters[$relation] = (self::$counters[$relation] ?? 0) + 1;

if (self::$counters[$relation] === 1) {
return;
}

if (self::$mode === LazyLoadGuardMode::Strict) {
Comment thread
Tigrov marked this conversation as resolved.
throw new LogicException("Relation \"$relation\" is lazy loaded.");
}

self::$logger?->warning(
"Relation \"$relation\" is lazy loaded.",
[
'model' => $modelClass,
'relation' => $relationName,
'count' => self::$counters[$relation],
'trace' => (new Exception())->getTraceAsString(),
],
);
}

/**
* Returns the number of detected lazy loads `[model_class::relation_name => count, ...]`.
*
* @return int[]
*/
public static function getCounters(): array
{
return self::$counters;
}

/**
* Resets the mode, the logger and the counters to their defaults.
*/
public static function reset(): void
{
self::$mode = LazyLoadGuardMode::Log;
self::$logger = null;
self::$counters = [];
}
}
16 changes: 16 additions & 0 deletions src/LazyLoadGuardMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord;

/**
* Modes of the {@see LazyLoadGuard}.
*/
enum LazyLoadGuardMode
{
/** Every lazy load is reported as a PSR-3 warning. */
case Log;
/** Every lazy load throws a `LogicException`. */
case Strict;
}
25 changes: 25 additions & 0 deletions src/Trait/LazyLoadGuardTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord\Trait;

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

/**
* Trait to detect N+1 queries caused by lazy loading of relations.
*
* @see LazyLoadGuard
* @see AbstractActiveRecord::retrieveRelation()
*/
trait LazyLoadGuardTrait
{
protected function retrieveRelation(string $name): ActiveRecordInterface|array|null
{
LazyLoadGuard::check($this, $name);

return parent::retrieveRelation($name);
}
}
16 changes: 16 additions & 0 deletions tests/Driver/Mssql/LazyLoadGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord\Tests\Driver\Mssql;

use Yiisoft\ActiveRecord\Tests\Support\MssqlHelper;
use Yiisoft\Db\Connection\ConnectionInterface;

final class LazyLoadGuardTest extends \Yiisoft\ActiveRecord\Tests\LazyLoadGuardTest
{
protected static function createConnection(): ConnectionInterface
{
return (new MssqlHelper())->createConnection();
}
}
16 changes: 16 additions & 0 deletions tests/Driver/Mysql/LazyLoadGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord\Tests\Driver\Mysql;

use Yiisoft\ActiveRecord\Tests\Support\MysqlHelper;
use Yiisoft\Db\Connection\ConnectionInterface;

final class LazyLoadGuardTest extends \Yiisoft\ActiveRecord\Tests\LazyLoadGuardTest
{
protected static function createConnection(): ConnectionInterface
{
return (new MysqlHelper())->createConnection();
}
}
16 changes: 16 additions & 0 deletions tests/Driver/Oracle/LazyLoadGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord\Tests\Driver\Oracle;

use Yiisoft\ActiveRecord\Tests\Support\OracleHelper;
use Yiisoft\Db\Connection\ConnectionInterface;

final class LazyLoadGuardTest extends \Yiisoft\ActiveRecord\Tests\LazyLoadGuardTest
{
protected static function createConnection(): ConnectionInterface
{
return (new OracleHelper())->createConnection();
}
}
16 changes: 16 additions & 0 deletions tests/Driver/Pgsql/LazyLoadGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord\Tests\Driver\Pgsql;

use Yiisoft\ActiveRecord\Tests\Support\PgsqlHelper;
use Yiisoft\Db\Connection\ConnectionInterface;

final class LazyLoadGuardTest extends \Yiisoft\ActiveRecord\Tests\LazyLoadGuardTest
{
protected static function createConnection(): ConnectionInterface
{
return (new PgsqlHelper())->createConnection();
}
}
16 changes: 16 additions & 0 deletions tests/Driver/Sqlite/LazyLoadGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ActiveRecord\Tests\Driver\Sqlite;

use Yiisoft\ActiveRecord\Tests\Support\SqliteHelper;
use Yiisoft\Db\Connection\ConnectionInterface;

final class LazyLoadGuardTest extends \Yiisoft\ActiveRecord\Tests\LazyLoadGuardTest
{
protected static function createConnection(): ConnectionInterface
{
return (new SqliteHelper())->createConnection();
}
}
Loading
Loading