From 27007b49f18b43cbecf503b0b4366c94e19bee1e Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Sun, 20 Sep 2026 23:53:49 +0200 Subject: [PATCH 1/7] Add BeforeLazyRelationLoad event and LazyLoadGuard --- CHANGELOG.md | 3 +- composer-dependency-analyser.php | 2 +- composer.json | 4 +- src/Event/BeforeLazyRelationLoad.php | 24 +++ src/Event/Guard/LazyLoadGuard.php | 100 +++++++++ src/Event/Guard/LazyLoadGuardMode.php | 18 ++ src/Trait/EventsTrait.php | 13 ++ tests/Driver/Mssql/LazyLoadGuardTest.php | 16 ++ tests/Driver/Mysql/LazyLoadGuardTest.php | 16 ++ tests/Driver/Oracle/LazyLoadGuardTest.php | 16 ++ tests/Driver/Pgsql/LazyLoadGuardTest.php | 16 ++ tests/Driver/Sqlite/LazyLoadGuardTest.php | 16 ++ tests/LazyLoadGuardTest.php | 196 ++++++++++++++++++ tests/Stubs/ActiveRecord/OrderEventsModel.php | 12 ++ 14 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 src/Event/BeforeLazyRelationLoad.php create mode 100644 src/Event/Guard/LazyLoadGuard.php create mode 100644 src/Event/Guard/LazyLoadGuardMode.php create mode 100644 tests/Driver/Mssql/LazyLoadGuardTest.php create mode 100644 tests/Driver/Mysql/LazyLoadGuardTest.php create mode 100644 tests/Driver/Oracle/LazyLoadGuardTest.php create mode 100644 tests/Driver/Pgsql/LazyLoadGuardTest.php create mode 100644 tests/Driver/Sqlite/LazyLoadGuardTest.php create mode 100644 tests/LazyLoadGuardTest.php create mode 100644 tests/Stubs/ActiveRecord/OrderEventsModel.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b77be6235..1e3fa4a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 1.1.1 under development -- no changes in this release. +- Enh #590: Add `BeforeLazyRelationLoad` event and `LazyLoadGuard` listener to detect N+1 queries + (@KalimeroMK) ## 1.1.0 May 14, 2026 diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 5ca7d636a..356fb279a 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -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 diff --git a/composer.json b/composer.json index 8430b3d75..514e0c06f 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -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\\Event\\Guard\\LazyLoadGuard logging support" }, "autoload": { "psr-4": { diff --git a/src/Event/BeforeLazyRelationLoad.php b/src/Event/BeforeLazyRelationLoad.php new file mode 100644 index 000000000..01b215692 --- /dev/null +++ b/src/Event/BeforeLazyRelationLoad.php @@ -0,0 +1,24 @@ + count, ...]` + */ + private array $counters = []; + + /** + * @param LazyLoadGuardMode $mode The mode of the guard. + * @param LoggerInterface|null $logger The logger used in the {@see LazyLoadGuardMode::Log} mode. + * @param string[] $only Model classes to guard, all classes are guarded if empty. + * @param string[] $except Model classes to skip. + * + * @psalm-param list $only + * @psalm-param list $except + */ + public function __construct( + private readonly LazyLoadGuardMode $mode = LazyLoadGuardMode::Off, + private readonly ?LoggerInterface $logger = null, + private readonly array $only = [], + private readonly array $except = [], + ) {} + + public function __invoke(BeforeLazyRelationLoad $event): void + { + $modelClass = $event->model::class; + + if ($this->mode === LazyLoadGuardMode::Off || !$this->isGuarded($modelClass)) { + return; + } + + $relation = $modelClass . '::' . $event->relationName; + + if ($this->mode === LazyLoadGuardMode::Strict) { + throw new LogicException("Relation \"$relation\" is lazy loaded."); + } + + $this->counters[$relation] = ($this->counters[$relation] ?? 0) + 1; + + $this->logger?->warning( + "Relation \"$relation\" is lazy loaded.", + [ + 'model' => $modelClass, + 'relation' => $event->relationName, + 'count' => $this->counters[$relation], + 'trace' => (new Exception())->getTraceAsString(), + ], + ); + } + + /** + * Returns the number of detected lazy loads `[model_class::relation_name => count, ...]`. + * + * @return int[] + */ + public function getCounters(): array + { + return $this->counters; + } + + /** + * @psalm-param class-string $modelClass + */ + private function isGuarded(string $modelClass): bool + { + foreach ($this->except as $class) { + if (is_a($modelClass, $class, true)) { + return false; + } + } + + if ($this->only === []) { + return true; + } + + foreach ($this->only as $class) { + if (is_a($modelClass, $class, true)) { + return true; + } + } + + return false; + } +} diff --git a/src/Event/Guard/LazyLoadGuardMode.php b/src/Event/Guard/LazyLoadGuardMode.php new file mode 100644 index 000000000..1c0992750 --- /dev/null +++ b/src/Event/Guard/LazyLoadGuardMode.php @@ -0,0 +1,18 @@ +dispatch(new AfterUpsert($this)); } + + protected function retrieveRelation(string $name): ActiveRecordInterface|array|null + { + $eventDispatcher = EventDispatcherProvider::get(static::class); + $eventDispatcher->dispatch($event = new BeforeLazyRelationLoad($this, $name)); + + if ($event->isDefaultPrevented()) { + return $event->getReturnValue(); + } + + return parent::retrieveRelation($name); + } } diff --git a/tests/Driver/Mssql/LazyLoadGuardTest.php b/tests/Driver/Mssql/LazyLoadGuardTest.php new file mode 100644 index 000000000..59483f134 --- /dev/null +++ b/tests/Driver/Mssql/LazyLoadGuardTest.php @@ -0,0 +1,16 @@ +createConnection(); + } +} diff --git a/tests/Driver/Mysql/LazyLoadGuardTest.php b/tests/Driver/Mysql/LazyLoadGuardTest.php new file mode 100644 index 000000000..2b63d86c6 --- /dev/null +++ b/tests/Driver/Mysql/LazyLoadGuardTest.php @@ -0,0 +1,16 @@ +createConnection(); + } +} diff --git a/tests/Driver/Oracle/LazyLoadGuardTest.php b/tests/Driver/Oracle/LazyLoadGuardTest.php new file mode 100644 index 000000000..c95117cb8 --- /dev/null +++ b/tests/Driver/Oracle/LazyLoadGuardTest.php @@ -0,0 +1,16 @@ +createConnection(); + } +} diff --git a/tests/Driver/Pgsql/LazyLoadGuardTest.php b/tests/Driver/Pgsql/LazyLoadGuardTest.php new file mode 100644 index 000000000..a716d13f9 --- /dev/null +++ b/tests/Driver/Pgsql/LazyLoadGuardTest.php @@ -0,0 +1,16 @@ +createConnection(); + } +} diff --git a/tests/Driver/Sqlite/LazyLoadGuardTest.php b/tests/Driver/Sqlite/LazyLoadGuardTest.php new file mode 100644 index 000000000..a60a7b592 --- /dev/null +++ b/tests/Driver/Sqlite/LazyLoadGuardTest.php @@ -0,0 +1,16 @@ +createConnection(); + } +} diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php new file mode 100644 index 000000000..49edd1462 --- /dev/null +++ b/tests/LazyLoadGuardTest.php @@ -0,0 +1,196 @@ +relationName; + } + }, + ), + ); + + $customer = CustomerEventsModel::query()->findByPk(1); + $customer->getOrders(); + + $this->assertSame(['orders'], $events); + } + + public function testEagerLoadedRelationDoesNotDispatchEvent(): void + { + $events = []; + + EventDispatcherProvider::set( + CustomerEventsModel::class, + new SimpleEventDispatcher( + static function (object $event) use (&$events): void { + if ($event instanceof BeforeLazyRelationLoad) { + $events[] = $event->relationName; + } + }, + ), + ); + + $customers = CustomerEventsModel::query()->with('orders')->all(); + + foreach ($customers as $customer) { + $customer->getOrders(); + } + + $this->assertSame([], $events); + } + + public function testLazyLoadWorksWithoutGuardRegistered(): void + { + $customer = CustomerEventsModel::query()->findByPk(1); + + $this->assertCount(1, $customer->getOrders()); + } + + public function testModeOffDoesNotLogAndDoesNotThrow(): void + { + $logger = new SimpleLogger(); + $guard = new LazyLoadGuard(LazyLoadGuardMode::Off, $logger); + + $this->registerGuard(CustomerEventsModel::class, $guard); + + $customer = CustomerEventsModel::query()->findByPk(1); + + $this->assertCount(1, $customer->getOrders()); + $this->assertSame([], $logger->getMessages()); + $this->assertSame([], $guard->getCounters()); + } + + public function testModeLogWritesWarningWithContext(): void + { + $logger = new SimpleLogger(); + $guard = new LazyLoadGuard(LazyLoadGuardMode::Log, $logger); + + $this->registerGuard(CustomerEventsModel::class, $guard); + + $customer = CustomerEventsModel::query()->findByPk(1); + $customer->getOrders(); + + $messages = $logger->getMessages(); + + $this->assertCount(1, $messages); + $this->assertSame('warning', $messages[0]['level']); + $this->assertStringContainsString('orders', $messages[0]['message']); + + $context = $messages[0]['context']; + + $this->assertSame(CustomerEventsModel::class, $context['model']); + $this->assertSame('orders', $context['relation']); + $this->assertSame(1, $context['count']); + $this->assertIsString($context['trace']); + } + + public function testModeLogCountsEachLazyLoad(): void + { + $guard = new LazyLoadGuard(LazyLoadGuardMode::Log, new SimpleLogger()); + + $this->registerGuard(CustomerEventsModel::class, $guard); + + foreach (CustomerEventsModel::query()->all() as $customer) { + $customer->getOrders(); + } + + $this->assertSame([CustomerEventsModel::class . '::orders' => 3], $guard->getCounters()); + } + + public function testModeStrictThrowsExceptionWithRelationName(): void + { + $this->registerGuard(CustomerEventsModel::class, new LazyLoadGuard(LazyLoadGuardMode::Strict)); + + $customer = CustomerEventsModel::query()->findByPk(1); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Relation "' . CustomerEventsModel::class . '::orders" is lazy loaded.'); + + $customer->getOrders(); + } + + public function testOnlyRestrictsGuardToListedClasses(): void + { + $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict, only: [OrderEventsModel::class]); + + $this->registerGuard(CustomerEventsModel::class, $guard); + $this->registerGuard(OrderEventsModel::class, $guard); + + $customer = CustomerEventsModel::query()->findByPk(1); + + $this->assertCount(1, $customer->getOrders()); + + $order = OrderEventsModel::query()->findByPk(1); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Relation "' . OrderEventsModel::class . '::customer" is lazy loaded.'); + + $order->getCustomer(); + } + + public function testExceptSkipsListedClasses(): void + { + $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict, except: [CustomerEventsModel::class]); + + $this->registerGuard(CustomerEventsModel::class, $guard); + $this->registerGuard(OrderEventsModel::class, $guard); + + $customer = CustomerEventsModel::query()->findByPk(1); + + $this->assertCount(1, $customer->getOrders()); + + $order = OrderEventsModel::query()->findByPk(1); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Relation "' . OrderEventsModel::class . '::customer" is lazy loaded.'); + + $order->getCustomer(); + } + + /** + * @psalm-param class-string $modelClass + */ + private function registerGuard(string $modelClass, LazyLoadGuard $guard): void + { + EventDispatcherProvider::set($modelClass, $this->createDispatcher($guard)); + } + + private function createDispatcher(LazyLoadGuard $guard): EventDispatcherInterface + { + return new SimpleEventDispatcher( + static function (object $event) use ($guard): void { + if ($event instanceof BeforeLazyRelationLoad) { + $guard($event); + } + }, + ); + } +} diff --git a/tests/Stubs/ActiveRecord/OrderEventsModel.php b/tests/Stubs/ActiveRecord/OrderEventsModel.php new file mode 100644 index 000000000..7622fa770 --- /dev/null +++ b/tests/Stubs/ActiveRecord/OrderEventsModel.php @@ -0,0 +1,12 @@ + Date: Mon, 21 Sep 2026 00:02:01 +0200 Subject: [PATCH 2/7] Cover prevented BeforeLazyRelationLoad event --- tests/LazyLoadGuardTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index 49edd1462..d26731cc6 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -67,6 +67,25 @@ static function (object $event) use (&$events): void { $this->assertSame([], $events); } + public function testLazyLoadWithEventPrevention(): void + { + EventDispatcherProvider::set( + CustomerEventsModel::class, + new SimpleEventDispatcher( + static function (object $event): void { + if ($event instanceof BeforeLazyRelationLoad) { + $event->returnValue([]); + $event->preventDefault(); + } + }, + ), + ); + + $customer = CustomerEventsModel::query()->findByPk(1); + + $this->assertSame([], $customer->getOrders()); + } + public function testLazyLoadWorksWithoutGuardRegistered(): void { $customer = CustomerEventsModel::query()->findByPk(1); From 0a27a4f48538ff8df2b085b36f8f32ca58d97f4e Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Mon, 21 Sep 2026 07:03:56 +0200 Subject: [PATCH 3/7] Apply Rector suggestion in EventDispatcherProvider --- src/Event/EventDispatcherProvider.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Event/EventDispatcherProvider.php b/src/Event/EventDispatcherProvider.php index 0b67ddb3b..fd88ae47f 100644 --- a/src/Event/EventDispatcherProvider.php +++ b/src/Event/EventDispatcherProvider.php @@ -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]; } From 37fc942e332faab822dc2b1ec518135b3d0a258d Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Mon, 21 Sep 2026 07:14:00 +0200 Subject: [PATCH 4/7] Fix double event dispatch in tests, count in strict mode, document event --- docs/traits/events.md | 39 +++++++++++++++++++ src/Event/Guard/LazyLoadGuard.php | 4 +- tests/LazyLoadGuardTest.php | 32 +++++++++++---- tests/Stubs/ActiveRecord/OrderEventsModel.php | 12 ------ 4 files changed, 65 insertions(+), 22 deletions(-) delete mode 100644 tests/Stubs/ActiveRecord/OrderEventsModel.php diff --git a/docs/traits/events.md b/docs/traits/events.md index 650d8a0cf..1661a7313 100644 --- a/docs/traits/events.md +++ b/docs/traits/events.md @@ -32,6 +32,7 @@ Insert | [BeforeInsert](../../src/Event/BeforeInsert.php) | [Aft Update | [BeforeUpdate](../../src/Event/BeforeUpdate.php) | [AfterUpdate](../../src/Event/AfterUpdate.php) Upsert | [BeforeUpsert](../../src/Event/BeforeUpsert.php) | [AfterUpsert](../../src/Event/AfterUpsert.php) Delete | [BeforeDelete](../../src/Event/BeforeDelete.php) | [AfterDelete](../../src/Event/AfterDelete.php) +Lazy Relation Load | [BeforeLazyRelationLoad](../../src/Event/BeforeLazyRelationLoad.php) | Each action is called by the corresponding method in the Active Record class, e.g. `insert()`, `update()`, `delete()`. @@ -119,3 +120,41 @@ User::query()->all(); // Only records with `deleted_at` equals to NULL will be r ``` Back to [Extending Functionality With Traits](traits.md). + +## Lazy Load Guard + +[BeforeLazyRelationLoad](../../src/Event/BeforeLazyRelationLoad.php) is dispatched every time a relation is read +without having been eager-loaded by `with()`, which makes N+1 queries observable. + +[LazyLoadGuard](../../src/Event/Guard/LazyLoadGuard.php) is a ready-made listener for it. Unlike the handlers above +it isn't an attribute, so it's wired as a regular listener: + +```php +use Yiisoft\ActiveRecord\Event\BeforeLazyRelationLoad; +use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; +use Yiisoft\ActiveRecord\Event\Guard\LazyLoadGuard; +use Yiisoft\ActiveRecord\Event\Guard\LazyLoadGuardMode; + +EventDispatcherProvider::set(Customer::class, new Dispatcher(new Provider( + (new ListenerCollection())->add( + new LazyLoadGuard(LazyLoadGuardMode::Log, $logger), + BeforeLazyRelationLoad::class, + ), +))); +``` + +| Mode | Behavior | +|----------|-------------------------------------------------------------------------------------------| +| `Off` | Default. Nothing is logged or thrown | +| `Log` | Reports a PSR-3 warning with the relation name, the per-request 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 +`getCounters()`, but nothing is written anywhere. + +Pass `only` or `except` to limit the guard to certain model classes, for example +`new LazyLoadGuard(LazyLoadGuardMode::Strict, except: [Category::class])`. + +> [!IMPORTANT] +> `EventDispatcherProvider::set()` replaces the whole dispatcher for the given class, including the listeners +> built from its attributes. Register the attribute handlers alongside the guard if the model relies on them. diff --git a/src/Event/Guard/LazyLoadGuard.php b/src/Event/Guard/LazyLoadGuard.php index 1e1558bec..b067edb29 100644 --- a/src/Event/Guard/LazyLoadGuard.php +++ b/src/Event/Guard/LazyLoadGuard.php @@ -47,12 +47,12 @@ public function __invoke(BeforeLazyRelationLoad $event): void $relation = $modelClass . '::' . $event->relationName; + $this->counters[$relation] = ($this->counters[$relation] ?? 0) + 1; + if ($this->mode === LazyLoadGuardMode::Strict) { throw new LogicException("Relation \"$relation\" is lazy loaded."); } - $this->counters[$relation] = ($this->counters[$relation] ?? 0) + 1; - $this->logger?->warning( "Relation \"$relation\" is lazy loaded.", [ diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index d26731cc6..1cb33117b 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -11,7 +11,7 @@ use Yiisoft\ActiveRecord\Event\Guard\LazyLoadGuard; use Yiisoft\ActiveRecord\Event\Guard\LazyLoadGuardMode; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerEventsModel; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderEventsModel; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; use Yiisoft\Test\Support\Log\SimpleLogger; @@ -156,21 +156,37 @@ public function testModeStrictThrowsExceptionWithRelationName(): void $customer->getOrders(); } + public function testModeStrictCountsLazyLoadBeforeThrowing(): void + { + $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict); + + $this->registerGuard(CustomerEventsModel::class, $guard); + + $customer = CustomerEventsModel::query()->findByPk(1); + + try { + $customer->getOrders(); + } catch (LogicException) { + } + + $this->assertSame([CustomerEventsModel::class . '::orders' => 1], $guard->getCounters()); + } + public function testOnlyRestrictsGuardToListedClasses(): void { - $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict, only: [OrderEventsModel::class]); + $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict, only: [Order::class]); $this->registerGuard(CustomerEventsModel::class, $guard); - $this->registerGuard(OrderEventsModel::class, $guard); + $this->registerGuard(Order::class, $guard); $customer = CustomerEventsModel::query()->findByPk(1); $this->assertCount(1, $customer->getOrders()); - $order = OrderEventsModel::query()->findByPk(1); + $order = Order::query()->findByPk(1); $this->expectException(LogicException::class); - $this->expectExceptionMessage('Relation "' . OrderEventsModel::class . '::customer" is lazy loaded.'); + $this->expectExceptionMessage('Relation "' . Order::class . '::customer" is lazy loaded.'); $order->getCustomer(); } @@ -180,16 +196,16 @@ public function testExceptSkipsListedClasses(): void $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict, except: [CustomerEventsModel::class]); $this->registerGuard(CustomerEventsModel::class, $guard); - $this->registerGuard(OrderEventsModel::class, $guard); + $this->registerGuard(Order::class, $guard); $customer = CustomerEventsModel::query()->findByPk(1); $this->assertCount(1, $customer->getOrders()); - $order = OrderEventsModel::query()->findByPk(1); + $order = Order::query()->findByPk(1); $this->expectException(LogicException::class); - $this->expectExceptionMessage('Relation "' . OrderEventsModel::class . '::customer" is lazy loaded.'); + $this->expectExceptionMessage('Relation "' . Order::class . '::customer" is lazy loaded.'); $order->getCustomer(); } diff --git a/tests/Stubs/ActiveRecord/OrderEventsModel.php b/tests/Stubs/ActiveRecord/OrderEventsModel.php deleted file mode 100644 index 7622fa770..000000000 --- a/tests/Stubs/ActiveRecord/OrderEventsModel.php +++ /dev/null @@ -1,12 +0,0 @@ - Date: Wed, 23 Sep 2026 09:52:28 +0200 Subject: [PATCH 5/7] Drop Off mode and only/except from LazyLoadGuard --- docs/traits/events.md | 13 +++---- src/Event/Guard/LazyLoadGuard.php | 40 +------------------- src/Event/Guard/LazyLoadGuardMode.php | 2 - tests/LazyLoadGuardTest.php | 53 --------------------------- 4 files changed, 7 insertions(+), 101 deletions(-) diff --git a/docs/traits/events.md b/docs/traits/events.md index 1661a7313..cfa5a601a 100644 --- a/docs/traits/events.md +++ b/docs/traits/events.md @@ -143,17 +143,16 @@ EventDispatcherProvider::set(Customer::class, new Dispatcher(new Provider( ))); ``` -| Mode | Behavior | -|----------|-------------------------------------------------------------------------------------------| -| `Off` | Default. Nothing is logged or thrown | -| `Log` | Reports a PSR-3 warning with the relation name, the per-request count and a stack trace | -| `Strict` | Throws `LogicException` | +| Mode | Behavior | +|----------|---------------------------------------------------------------------------------------------------| +| `Log` | Default. Reports a PSR-3 warning with the relation name, the per-request 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 `getCounters()`, but nothing is written anywhere. -Pass `only` or `except` to limit the guard to certain model classes, for example -`new LazyLoadGuard(LazyLoadGuardMode::Strict, except: [Category::class])`. +The guard is meant for development and testing, e.g. `Strict` in the test suite and `Log` on staging. +To disable it, don't register it. > [!IMPORTANT] > `EventDispatcherProvider::set()` replaces the whole dispatcher for the given class, including the listeners diff --git a/src/Event/Guard/LazyLoadGuard.php b/src/Event/Guard/LazyLoadGuard.php index b067edb29..31237681c 100644 --- a/src/Event/Guard/LazyLoadGuard.php +++ b/src/Event/Guard/LazyLoadGuard.php @@ -9,8 +9,6 @@ use Psr\Log\LoggerInterface; use Yiisoft\ActiveRecord\Event\BeforeLazyRelationLoad; -use function is_a; - /** * Listener of the {@see BeforeLazyRelationLoad} event which detects N+1 queries caused by lazy loading of relations. */ @@ -24,27 +22,15 @@ final class LazyLoadGuard /** * @param LazyLoadGuardMode $mode The mode of the guard. * @param LoggerInterface|null $logger The logger used in the {@see LazyLoadGuardMode::Log} mode. - * @param string[] $only Model classes to guard, all classes are guarded if empty. - * @param string[] $except Model classes to skip. - * - * @psalm-param list $only - * @psalm-param list $except */ public function __construct( - private readonly LazyLoadGuardMode $mode = LazyLoadGuardMode::Off, + private readonly LazyLoadGuardMode $mode = LazyLoadGuardMode::Log, private readonly ?LoggerInterface $logger = null, - private readonly array $only = [], - private readonly array $except = [], ) {} public function __invoke(BeforeLazyRelationLoad $event): void { $modelClass = $event->model::class; - - if ($this->mode === LazyLoadGuardMode::Off || !$this->isGuarded($modelClass)) { - return; - } - $relation = $modelClass . '::' . $event->relationName; $this->counters[$relation] = ($this->counters[$relation] ?? 0) + 1; @@ -73,28 +59,4 @@ public function getCounters(): array { return $this->counters; } - - /** - * @psalm-param class-string $modelClass - */ - private function isGuarded(string $modelClass): bool - { - foreach ($this->except as $class) { - if (is_a($modelClass, $class, true)) { - return false; - } - } - - if ($this->only === []) { - return true; - } - - foreach ($this->only as $class) { - if (is_a($modelClass, $class, true)) { - return true; - } - } - - return false; - } } diff --git a/src/Event/Guard/LazyLoadGuardMode.php b/src/Event/Guard/LazyLoadGuardMode.php index 1c0992750..c70de34b7 100644 --- a/src/Event/Guard/LazyLoadGuardMode.php +++ b/src/Event/Guard/LazyLoadGuardMode.php @@ -9,8 +9,6 @@ */ enum LazyLoadGuardMode { - /** Lazy loading isn't tracked. */ - case Off; /** Every lazy load is reported as a PSR-3 warning. */ case Log; /** Every lazy load throws a `LogicException`. */ diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index 1cb33117b..de42f8466 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -11,7 +11,6 @@ use Yiisoft\ActiveRecord\Event\Guard\LazyLoadGuard; use Yiisoft\ActiveRecord\Event\Guard\LazyLoadGuardMode; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerEventsModel; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; use Yiisoft\Test\Support\Log\SimpleLogger; @@ -93,20 +92,6 @@ public function testLazyLoadWorksWithoutGuardRegistered(): void $this->assertCount(1, $customer->getOrders()); } - public function testModeOffDoesNotLogAndDoesNotThrow(): void - { - $logger = new SimpleLogger(); - $guard = new LazyLoadGuard(LazyLoadGuardMode::Off, $logger); - - $this->registerGuard(CustomerEventsModel::class, $guard); - - $customer = CustomerEventsModel::query()->findByPk(1); - - $this->assertCount(1, $customer->getOrders()); - $this->assertSame([], $logger->getMessages()); - $this->assertSame([], $guard->getCounters()); - } - public function testModeLogWritesWarningWithContext(): void { $logger = new SimpleLogger(); @@ -172,44 +157,6 @@ public function testModeStrictCountsLazyLoadBeforeThrowing(): void $this->assertSame([CustomerEventsModel::class . '::orders' => 1], $guard->getCounters()); } - public function testOnlyRestrictsGuardToListedClasses(): void - { - $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict, only: [Order::class]); - - $this->registerGuard(CustomerEventsModel::class, $guard); - $this->registerGuard(Order::class, $guard); - - $customer = CustomerEventsModel::query()->findByPk(1); - - $this->assertCount(1, $customer->getOrders()); - - $order = Order::query()->findByPk(1); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Relation "' . Order::class . '::customer" is lazy loaded.'); - - $order->getCustomer(); - } - - public function testExceptSkipsListedClasses(): void - { - $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict, except: [CustomerEventsModel::class]); - - $this->registerGuard(CustomerEventsModel::class, $guard); - $this->registerGuard(Order::class, $guard); - - $customer = CustomerEventsModel::query()->findByPk(1); - - $this->assertCount(1, $customer->getOrders()); - - $order = Order::query()->findByPk(1); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Relation "' . Order::class . '::customer" is lazy loaded.'); - - $order->getCustomer(); - } - /** * @psalm-param class-string $modelClass */ From 39f3a9dad79e284a512eeb62a5f460257cac643e Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Wed, 23 Sep 2026 11:21:42 +0200 Subject: [PATCH 6/7] Move lazy load guard to a separate trait without events --- CHANGELOG.md | 2 +- composer.json | 2 +- docs/traits/events.md | 38 ----- docs/traits/lazy-load-guard.md | 40 +++++ docs/traits/traits.md | 1 + src/Event/BeforeLazyRelationLoad.php | 24 --- src/Event/Guard/LazyLoadGuard.php | 62 ------- src/LazyLoadGuard.php | 80 +++++++++ src/{Event/Guard => }/LazyLoadGuardMode.php | 4 +- src/Trait/EventsTrait.php | 13 -- src/Trait/LazyLoadGuardTrait.php | 25 +++ tests/LazyLoadGuardTest.php | 158 +++++++----------- .../CustomerLazyLoadGuardModel.php | 14 ++ 13 files changed, 227 insertions(+), 236 deletions(-) create mode 100644 docs/traits/lazy-load-guard.md delete mode 100644 src/Event/BeforeLazyRelationLoad.php delete mode 100644 src/Event/Guard/LazyLoadGuard.php create mode 100644 src/LazyLoadGuard.php rename src/{Event/Guard => }/LazyLoadGuardMode.php (70%) create mode 100644 src/Trait/LazyLoadGuardTrait.php create mode 100644 tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3fa4a5e..07bbca6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.1.1 under development -- Enh #590: Add `BeforeLazyRelationLoad` event and `LazyLoadGuard` listener to detect N+1 queries +- Enh #590: Add `LazyLoadGuardTrait` and `LazyLoadGuard` to detect N+1 queries (@KalimeroMK) ## 1.1.0 May 14, 2026 diff --git a/composer.json b/composer.json index 514e0c06f..ae19436cf 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,7 @@ "yiisoft/db-oracle": "For Oracle database support", "yiisoft/factory": "For factory support", "yiisoft/event-dispatcher": "For events support", - "psr/log": "For \\Yiisoft\\ActiveRecord\\Event\\Guard\\LazyLoadGuard logging support" + "psr/log": "For \\Yiisoft\\ActiveRecord\\LazyLoadGuard logging support" }, "autoload": { "psr-4": { diff --git a/docs/traits/events.md b/docs/traits/events.md index cfa5a601a..650d8a0cf 100644 --- a/docs/traits/events.md +++ b/docs/traits/events.md @@ -32,7 +32,6 @@ Insert | [BeforeInsert](../../src/Event/BeforeInsert.php) | [Aft Update | [BeforeUpdate](../../src/Event/BeforeUpdate.php) | [AfterUpdate](../../src/Event/AfterUpdate.php) Upsert | [BeforeUpsert](../../src/Event/BeforeUpsert.php) | [AfterUpsert](../../src/Event/AfterUpsert.php) Delete | [BeforeDelete](../../src/Event/BeforeDelete.php) | [AfterDelete](../../src/Event/AfterDelete.php) -Lazy Relation Load | [BeforeLazyRelationLoad](../../src/Event/BeforeLazyRelationLoad.php) | Each action is called by the corresponding method in the Active Record class, e.g. `insert()`, `update()`, `delete()`. @@ -120,40 +119,3 @@ User::query()->all(); // Only records with `deleted_at` equals to NULL will be r ``` Back to [Extending Functionality With Traits](traits.md). - -## Lazy Load Guard - -[BeforeLazyRelationLoad](../../src/Event/BeforeLazyRelationLoad.php) is dispatched every time a relation is read -without having been eager-loaded by `with()`, which makes N+1 queries observable. - -[LazyLoadGuard](../../src/Event/Guard/LazyLoadGuard.php) is a ready-made listener for it. Unlike the handlers above -it isn't an attribute, so it's wired as a regular listener: - -```php -use Yiisoft\ActiveRecord\Event\BeforeLazyRelationLoad; -use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; -use Yiisoft\ActiveRecord\Event\Guard\LazyLoadGuard; -use Yiisoft\ActiveRecord\Event\Guard\LazyLoadGuardMode; - -EventDispatcherProvider::set(Customer::class, new Dispatcher(new Provider( - (new ListenerCollection())->add( - new LazyLoadGuard(LazyLoadGuardMode::Log, $logger), - BeforeLazyRelationLoad::class, - ), -))); -``` - -| Mode | Behavior | -|----------|---------------------------------------------------------------------------------------------------| -| `Log` | Default. Reports a PSR-3 warning with the relation name, the per-request 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 -`getCounters()`, but nothing is written anywhere. - -The guard is meant for development and testing, e.g. `Strict` in the test suite and `Log` on staging. -To disable it, don't register it. - -> [!IMPORTANT] -> `EventDispatcherProvider::set()` replaces the whole dispatcher for the given class, including the listeners -> built from its attributes. Register the attribute handlers alongside the guard if the model relies on them. diff --git a/docs/traits/lazy-load-guard.md b/docs/traits/lazy-load-guard.md new file mode 100644 index 000000000..d9dd691e5 --- /dev/null +++ b/docs/traits/lazy-load-guard.md @@ -0,0 +1,40 @@ +# 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) and reported according to its mode: + +```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 per-request 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 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). diff --git a/docs/traits/traits.md b/docs/traits/traits.md index 0f6b97d34..797cdede1 100644 --- a/docs/traits/traits.md +++ b/docs/traits/traits.md @@ -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 diff --git a/src/Event/BeforeLazyRelationLoad.php b/src/Event/BeforeLazyRelationLoad.php deleted file mode 100644 index 01b215692..000000000 --- a/src/Event/BeforeLazyRelationLoad.php +++ /dev/null @@ -1,24 +0,0 @@ - count, ...]` - */ - private array $counters = []; - - /** - * @param LazyLoadGuardMode $mode The mode of the guard. - * @param LoggerInterface|null $logger The logger used in the {@see LazyLoadGuardMode::Log} mode. - */ - public function __construct( - private readonly LazyLoadGuardMode $mode = LazyLoadGuardMode::Log, - private readonly ?LoggerInterface $logger = null, - ) {} - - public function __invoke(BeforeLazyRelationLoad $event): void - { - $modelClass = $event->model::class; - $relation = $modelClass . '::' . $event->relationName; - - $this->counters[$relation] = ($this->counters[$relation] ?? 0) + 1; - - if ($this->mode === LazyLoadGuardMode::Strict) { - throw new LogicException("Relation \"$relation\" is lazy loaded."); - } - - $this->logger?->warning( - "Relation \"$relation\" is lazy loaded.", - [ - 'model' => $modelClass, - 'relation' => $event->relationName, - 'count' => $this->counters[$relation], - 'trace' => (new Exception())->getTraceAsString(), - ], - ); - } - - /** - * Returns the number of detected lazy loads `[model_class::relation_name => count, ...]`. - * - * @return int[] - */ - public function getCounters(): array - { - return $this->counters; - } -} diff --git a/src/LazyLoadGuard.php b/src/LazyLoadGuard.php new file mode 100644 index 000000000..27ae9f144 --- /dev/null +++ b/src/LazyLoadGuard.php @@ -0,0 +1,80 @@ + 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. + * + * @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::$mode === LazyLoadGuardMode::Strict) { + 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 = []; + } +} diff --git a/src/Event/Guard/LazyLoadGuardMode.php b/src/LazyLoadGuardMode.php similarity index 70% rename from src/Event/Guard/LazyLoadGuardMode.php rename to src/LazyLoadGuardMode.php index c70de34b7..a73982ca6 100644 --- a/src/Event/Guard/LazyLoadGuardMode.php +++ b/src/LazyLoadGuardMode.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Yiisoft\ActiveRecord\Event\Guard; +namespace Yiisoft\ActiveRecord; /** - * Modes of the {@see LazyLoadGuard} listener. + * Modes of the {@see LazyLoadGuard}. */ enum LazyLoadGuardMode { diff --git a/src/Trait/EventsTrait.php b/src/Trait/EventsTrait.php index f9ba328f3..48a86c985 100644 --- a/src/Trait/EventsTrait.php +++ b/src/Trait/EventsTrait.php @@ -16,7 +16,6 @@ use Yiisoft\ActiveRecord\Event\BeforeCreateQuery; use Yiisoft\ActiveRecord\Event\BeforeDelete; use Yiisoft\ActiveRecord\Event\BeforeInsert; -use Yiisoft\ActiveRecord\Event\BeforeLazyRelationLoad; use Yiisoft\ActiveRecord\Event\BeforePopulate; use Yiisoft\ActiveRecord\Event\BeforeSave; use Yiisoft\ActiveRecord\Event\BeforeUpdate; @@ -145,16 +144,4 @@ public function upsert(?array $insertProperties = null, array|bool $updateProper $eventDispatcher->dispatch(new AfterUpsert($this)); } - - protected function retrieveRelation(string $name): ActiveRecordInterface|array|null - { - $eventDispatcher = EventDispatcherProvider::get(static::class); - $eventDispatcher->dispatch($event = new BeforeLazyRelationLoad($this, $name)); - - if ($event->isDefaultPrevented()) { - return $event->getReturnValue(); - } - - return parent::retrieveRelation($name); - } } diff --git a/src/Trait/LazyLoadGuardTrait.php b/src/Trait/LazyLoadGuardTrait.php new file mode 100644 index 000000000..a8a6d0223 --- /dev/null +++ b/src/Trait/LazyLoadGuardTrait.php @@ -0,0 +1,25 @@ +relationName; - } - }, - ), - ); - - $customer = CustomerEventsModel::query()->findByPk(1); - $customer->getOrders(); - - $this->assertSame(['orders'], $events); + LazyLoadGuard::reset(); + EventDispatcherProvider::reset(); } - public function testEagerLoadedRelationDoesNotDispatchEvent(): void + public function testLazyLoadIsCounted(): void { - $events = []; - - EventDispatcherProvider::set( - CustomerEventsModel::class, - new SimpleEventDispatcher( - static function (object $event) use (&$events): void { - if ($event instanceof BeforeLazyRelationLoad) { - $events[] = $event->relationName; - } - }, - ), - ); + foreach (CustomerLazyLoadGuardModel::query()->all() as $customer) { + $customer->getOrders(); + } - $customers = CustomerEventsModel::query()->with('orders')->all(); + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 3], LazyLoadGuard::getCounters()); + } - foreach ($customers as $customer) { + public function testEagerLoadedRelationIsNotCounted(): void + { + foreach (CustomerLazyLoadGuardModel::query()->with('orders')->all() as $customer) { $customer->getOrders(); } - $this->assertSame([], $events); + $this->assertSame([], LazyLoadGuard::getCounters()); } - public function testLazyLoadWithEventPrevention(): void + public function testLoadedRelationIsCountedOnce(): void { - EventDispatcherProvider::set( - CustomerEventsModel::class, - new SimpleEventDispatcher( - static function (object $event): void { - if ($event instanceof BeforeLazyRelationLoad) { - $event->returnValue([]); - $event->preventDefault(); - } - }, - ), - ); - - $customer = CustomerEventsModel::query()->findByPk(1); + $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); + $customer->getOrders(); + $customer->getOrders(); - $this->assertSame([], $customer->getOrders()); + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 1], LazyLoadGuard::getCounters()); } - public function testLazyLoadWorksWithoutGuardRegistered(): void + public function testModeLogWithoutLoggerReturnsRelation(): void { - $customer = CustomerEventsModel::query()->findByPk(1); + $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); $this->assertCount(1, $customer->getOrders()); } @@ -95,11 +64,9 @@ public function testLazyLoadWorksWithoutGuardRegistered(): void public function testModeLogWritesWarningWithContext(): void { $logger = new SimpleLogger(); - $guard = new LazyLoadGuard(LazyLoadGuardMode::Log, $logger); + LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); - $this->registerGuard(CustomerEventsModel::class, $guard); - - $customer = CustomerEventsModel::query()->findByPk(1); + $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); $customer->getOrders(); $messages = $logger->getMessages(); @@ -110,69 +77,70 @@ public function testModeLogWritesWarningWithContext(): void $context = $messages[0]['context']; - $this->assertSame(CustomerEventsModel::class, $context['model']); + $this->assertSame(CustomerLazyLoadGuardModel::class, $context['model']); $this->assertSame('orders', $context['relation']); $this->assertSame(1, $context['count']); $this->assertIsString($context['trace']); } - public function testModeLogCountsEachLazyLoad(): void - { - $guard = new LazyLoadGuard(LazyLoadGuardMode::Log, new SimpleLogger()); - - $this->registerGuard(CustomerEventsModel::class, $guard); - - foreach (CustomerEventsModel::query()->all() as $customer) { - $customer->getOrders(); - } - - $this->assertSame([CustomerEventsModel::class . '::orders' => 3], $guard->getCounters()); - } - public function testModeStrictThrowsExceptionWithRelationName(): void { - $this->registerGuard(CustomerEventsModel::class, new LazyLoadGuard(LazyLoadGuardMode::Strict)); + LazyLoadGuard::set(LazyLoadGuardMode::Strict); - $customer = CustomerEventsModel::query()->findByPk(1); + $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); $this->expectException(LogicException::class); - $this->expectExceptionMessage('Relation "' . CustomerEventsModel::class . '::orders" is lazy loaded.'); + $this->expectExceptionMessage('Relation "' . CustomerLazyLoadGuardModel::class . '::orders" is lazy loaded.'); $customer->getOrders(); } public function testModeStrictCountsLazyLoadBeforeThrowing(): void { - $guard = new LazyLoadGuard(LazyLoadGuardMode::Strict); - - $this->registerGuard(CustomerEventsModel::class, $guard); + LazyLoadGuard::set(LazyLoadGuardMode::Strict); - $customer = CustomerEventsModel::query()->findByPk(1); + $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); try { $customer->getOrders(); } catch (LogicException) { } - $this->assertSame([CustomerEventsModel::class . '::orders' => 1], $guard->getCounters()); + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 1], LazyLoadGuard::getCounters()); } - /** - * @psalm-param class-string $modelClass - */ - private function registerGuard(string $modelClass, LazyLoadGuard $guard): void + public function testWorksWithEventsTrait(): void { - EventDispatcherProvider::set($modelClass, $this->createDispatcher($guard)); + $events = []; + + EventDispatcherProvider::set( + CustomerLazyLoadGuardModel::class, + new SimpleEventDispatcher( + static function (object $event) use (&$events): void { + $events[] = $event::class; + }, + ), + ); + + $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); + $customer->getOrders(); + + $this->assertContains(AfterPopulate::class, $events); + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 1], LazyLoadGuard::getCounters()); } - private function createDispatcher(LazyLoadGuard $guard): EventDispatcherInterface + public function testReset(): void { - return new SimpleEventDispatcher( - static function (object $event) use ($guard): void { - if ($event instanceof BeforeLazyRelationLoad) { - $guard($event); - } - }, - ); + LazyLoadGuard::set(LazyLoadGuardMode::Strict, new SimpleLogger()); + LazyLoadGuard::reset(); + + $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); + $customer->getOrders(); + + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 1], LazyLoadGuard::getCounters()); + + LazyLoadGuard::reset(); + + $this->assertSame([], LazyLoadGuard::getCounters()); } } diff --git a/tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php b/tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php new file mode 100644 index 000000000..a0ff30b1c --- /dev/null +++ b/tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php @@ -0,0 +1,14 @@ + Date: Wed, 23 Sep 2026 15:01:31 +0200 Subject: [PATCH 7/7] Report lazy load starting from the second load of the same relation --- docs/traits/lazy-load-guard.md | 7 ++++-- src/LazyLoadGuard.php | 7 +++++- tests/LazyLoadGuardTest.php | 39 +++++++++++++++++++++++++++------- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/traits/lazy-load-guard.md b/docs/traits/lazy-load-guard.md index d9dd691e5..6a7c67bca 100644 --- a/docs/traits/lazy-load-guard.md +++ b/docs/traits/lazy-load-guard.md @@ -13,7 +13,8 @@ final class Customer extends ActiveRecord } ``` -Every lazy load is registered by [LazyLoadGuard](../../src/LazyLoadGuard.php) and reported according to its mode: +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; @@ -24,13 +25,15 @@ LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); | Mode | Behavior | |----------|---------------------------------------------------------------------------------------------------| -| `Log` | Default. Reports a PSR-3 warning with the relation name, the per-request count and a stack trace | +| `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] diff --git a/src/LazyLoadGuard.php b/src/LazyLoadGuard.php index 27ae9f144..df288b9d0 100644 --- a/src/LazyLoadGuard.php +++ b/src/LazyLoadGuard.php @@ -32,7 +32,8 @@ public static function set(LazyLoadGuardMode $mode, ?LoggerInterface $logger = n } /** - * Registers a lazy load of the relation and reports it according to the mode. + * 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. */ @@ -43,6 +44,10 @@ public static function check(ActiveRecordInterface $model, string $relationName) self::$counters[$relation] = (self::$counters[$relation] ?? 0) + 1; + if (self::$counters[$relation] === 1) { + return; + } + if (self::$mode === LazyLoadGuardMode::Strict) { throw new LogicException("Relation \"$relation\" is lazy loaded."); } diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index 55686a67f..4feee1a4c 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -61,7 +61,7 @@ public function testModeLogWithoutLoggerReturnsRelation(): void $this->assertCount(1, $customer->getOrders()); } - public function testModeLogWritesWarningWithContext(): void + public function testModeLogIgnoresSingleLazyLoad(): void { $logger = new SimpleLogger(); LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); @@ -69,9 +69,21 @@ public function testModeLogWritesWarningWithContext(): void $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); $customer->getOrders(); + $this->assertSame([], $logger->getMessages()); + } + + public function testModeLogWritesWarningWithContext(): void + { + $logger = new SimpleLogger(); + LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); + + foreach (CustomerLazyLoadGuardModel::query()->all() as $customer) { + $customer->getOrders(); + } + $messages = $logger->getMessages(); - $this->assertCount(1, $messages); + $this->assertCount(2, $messages); $this->assertSame('warning', $messages[0]['level']); $this->assertStringContainsString('orders', $messages[0]['message']); @@ -79,34 +91,45 @@ public function testModeLogWritesWarningWithContext(): void $this->assertSame(CustomerLazyLoadGuardModel::class, $context['model']); $this->assertSame('orders', $context['relation']); - $this->assertSame(1, $context['count']); + $this->assertSame(2, $context['count']); $this->assertIsString($context['trace']); } - public function testModeStrictThrowsExceptionWithRelationName(): void + public function testModeStrictIgnoresSingleLazyLoad(): void { LazyLoadGuard::set(LazyLoadGuardMode::Strict); $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); + $this->assertCount(1, $customer->getOrders()); + } + + public function testModeStrictThrowsExceptionWithRelationName(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + $customers = CustomerLazyLoadGuardModel::query()->all(); + $customers[0]->getOrders(); + $this->expectException(LogicException::class); $this->expectExceptionMessage('Relation "' . CustomerLazyLoadGuardModel::class . '::orders" is lazy loaded.'); - $customer->getOrders(); + $customers[1]->getOrders(); } public function testModeStrictCountsLazyLoadBeforeThrowing(): void { LazyLoadGuard::set(LazyLoadGuardMode::Strict); - $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); + $customers = CustomerLazyLoadGuardModel::query()->all(); + $customers[0]->getOrders(); try { - $customer->getOrders(); + $customers[1]->getOrders(); } catch (LogicException) { } - $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 1], LazyLoadGuard::getCounters()); + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 2], LazyLoadGuard::getCounters()); } public function testWorksWithEventsTrait(): void