From 27007b49f18b43cbecf503b0b4366c94e19bee1e Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Sun, 20 Sep 2026 23:53:49 +0200 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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 From 21b0bf5f2e71bcd0059d10a4540e260a1e587dc8 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Sun, 11 Oct 2026 12:01:18 +0200 Subject: [PATCH 08/20] Apply Rector suggestion in MagicRelationsTrait --- src/Trait/MagicRelationsTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Trait/MagicRelationsTrait.php b/src/Trait/MagicRelationsTrait.php index ad227b2b2..d12992d27 100644 --- a/src/Trait/MagicRelationsTrait.php +++ b/src/Trait/MagicRelationsTrait.php @@ -59,7 +59,7 @@ public function relationQuery(string $name): ActiveQueryInterface $type === null || !is_a('\\' . $type->getName(), ActiveQueryInterface::class, true) ) { - $typeName = $type === null ? 'mixed' : $type->getName(); + $typeName = $type?->getName() ?? 'mixed'; throw new InvalidArgumentException( 'Relation query method "' . static::class . '::' . $getter . '()" should return type "' From 4a715b6d143c06c5a6afefb279c8994544a1748f Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Sun, 11 Oct 2026 12:38:41 +0200 Subject: [PATCH 09/20] Count lazy loads per result set --- src/ActiveQuery.php | 4 ++ src/LazyLoadGuard.php | 94 ++++++++++++++++++++++++++++++++++--- tests/LazyLoadGuardTest.php | 21 +++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/ActiveQuery.php b/src/ActiveQuery.php index b18b94507..0b1b2cad3 100644 --- a/src/ActiveQuery.php +++ b/src/ActiveQuery.php @@ -321,6 +321,10 @@ public function populate(array $rows): array $models = $this->createModels($rows); + if (count($models) > 1) { + LazyLoadGuard::registerResultSet($models); + } + if (!empty($this->with)) { $this->findWith($this->with, $models); } diff --git a/src/LazyLoadGuard.php b/src/LazyLoadGuard.php index df288b9d0..d3c84718c 100644 --- a/src/LazyLoadGuard.php +++ b/src/LazyLoadGuard.php @@ -7,8 +7,13 @@ use Exception; use LogicException; use Psr\Log\LoggerInterface; +use WeakMap; use Yiisoft\ActiveRecord\Trait\LazyLoadGuardTrait; +use function class_uses; +use function get_parent_class; +use function reset; + /** * Detects N+1 queries caused by lazy loading of relations in models using {@see LazyLoadGuardTrait}. */ @@ -18,10 +23,27 @@ final class LazyLoadGuard private static ?LoggerInterface $logger = null; /** - * @var int[] Number of detected lazy loads `[model_class::relation_name => count, ...]` + * @var int[] Number of registered lazy loads `[model_class::relation_name => count, ...]` */ private static array $counters = []; + /** + * @var WeakMap|null The result set each model was populated from, keyed by model. + */ + private static ?WeakMap $resultSets = null; + + /** + * @var int[] Number of lazy loads within one result set `[result_set_id model_class::relation_name => count]` + */ + private static array $resultSetCounters = []; + + private static int $lastResultSetId = 0; + + /** + * @var bool[] Whether a model class uses {@see LazyLoadGuardTrait} `[model_class => bool, ...]` + */ + private static array $guardedClasses = []; + /** * Sets the mode of the guard and the logger used in the {@see LazyLoadGuardMode::Log} mode. */ @@ -32,8 +54,35 @@ public static function set(LazyLoadGuardMode $mode, ?LoggerInterface $logger = n } /** - * 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. + * Groups the models of one result set, so that the guard can tell a relation loaded for several records of + * the same result set, which is an N+1 query, from the same relation loaded for records that were each + * fetched by a query of their own, which has nothing to eager load across. + * + * @param ActiveRecordInterface[]|array[] $models The models populated from one result set. + * + * @see ActiveQuery::populate() + */ + public static function registerResultSet(array $models): void + { + $firstModel = reset($models); + + if (!$firstModel instanceof ActiveRecordInterface || !self::isGuarded($firstModel::class)) { + return; + } + + self::$resultSets ??= new WeakMap(); + $resultSetId = ++self::$lastResultSetId; + + foreach ($models as $model) { + if ($model instanceof ActiveRecordInterface) { + self::$resultSets[$model] = $resultSetId; + } + } + } + + /** + * Registers a lazy load of the relation and reports it according to the mode, starting from the second record + * of the same result set that loads the relation, since one record loading a relation isn't an N+1 problem. * * @throws LogicException In the {@see LazyLoadGuardMode::Strict} mode. */ @@ -44,7 +93,18 @@ public static function check(ActiveRecordInterface $model, string $relationName) self::$counters[$relation] = (self::$counters[$relation] ?? 0) + 1; - if (self::$counters[$relation] === 1) { + $resultSets = self::$resultSets ??= new WeakMap(); + + /** A record populated outside a result set is a result set of its own. */ + $resultSets[$model] ??= ++self::$lastResultSetId; + + /** @var int $resultSetId */ + $resultSetId = $resultSets[$model]; + + $counter = $resultSetId . ' ' . $relation; + $count = self::$resultSetCounters[$counter] = (self::$resultSetCounters[$counter] ?? 0) + 1; + + if ($count === 1) { return; } @@ -57,14 +117,15 @@ public static function check(ActiveRecordInterface $model, string $relationName) [ 'model' => $modelClass, 'relation' => $relationName, - 'count' => self::$counters[$relation], + 'count' => $count, 'trace' => (new Exception())->getTraceAsString(), ], ); } /** - * Returns the number of detected lazy loads `[model_class::relation_name => count, ...]`. + * Returns the number of registered lazy loads `[model_class::relation_name => count, ...]`, including the ones + * that weren't reported because they belong to result sets of their own. * * @return int[] */ @@ -81,5 +142,26 @@ public static function reset(): void self::$mode = LazyLoadGuardMode::Log; self::$logger = null; self::$counters = []; + self::$resultSets = null; + self::$resultSetCounters = []; + self::$lastResultSetId = 0; + } + + /** + * @param class-string $modelClass + */ + private static function isGuarded(string $modelClass): bool + { + if (isset(self::$guardedClasses[$modelClass])) { + return self::$guardedClasses[$modelClass]; + } + + $traits = []; + + for ($class = $modelClass; $class !== false; $class = get_parent_class($class)) { + $traits += class_uses($class) ?: []; + } + + return self::$guardedClasses[$modelClass] = isset($traits[LazyLoadGuardTrait::class]); } } diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index 4feee1a4c..331658eb1 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -54,6 +54,27 @@ public function testLoadedRelationIsCountedOnce(): void $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 1], LazyLoadGuard::getCounters()); } + public function testLazyLoadsOfSeparateResultSetsAreNotReported(): void + { + $logger = new SimpleLogger(); + LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); + + CustomerLazyLoadGuardModel::query()->findByPk(1)->getOrders(); + CustomerLazyLoadGuardModel::query()->findByPk(2)->getOrders(); + + $this->assertSame([], $logger->getMessages()); + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 2], LazyLoadGuard::getCounters()); + } + + public function testModeStrictIgnoresLazyLoadsOfSeparateResultSets(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + CustomerLazyLoadGuardModel::query()->findByPk(1)->getOrders(); + + $this->assertIsArray(CustomerLazyLoadGuardModel::query()->findByPk(2)->getOrders()); + } + public function testModeLogWithoutLoggerReturnsRelation(): void { $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); From b12bf6353ec3334422d6c2199413de2a00e772dc Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Sun, 11 Oct 2026 12:38:41 +0200 Subject: [PATCH 10/20] Clarify when a lazy load is reported --- docs/traits/lazy-load-guard.md | 32 ++++++++++++++++++++++++-------- src/LazyLoadGuardMode.php | 4 ++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/traits/lazy-load-guard.md b/docs/traits/lazy-load-guard.md index 6a7c67bca..ab20685b3 100644 --- a/docs/traits/lazy-load-guard.md +++ b/docs/traits/lazy-load-guard.md @@ -13,8 +13,23 @@ final class Customer extends ActiveRecord } ``` -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: +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(); +``` + +The mode and the logger are set statically: ```php use Yiisoft\ActiveRecord\LazyLoadGuard; @@ -23,14 +38,15 @@ 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` | +| Mode | Behavior | +|----------|------------------------------------------------------------------------------------------------------| +| `Log` | Default. Reports a PSR-3 warning with the relation name, the count within the result set and a 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. +`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. +`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. diff --git a/src/LazyLoadGuardMode.php b/src/LazyLoadGuardMode.php index a73982ca6..527534ec0 100644 --- a/src/LazyLoadGuardMode.php +++ b/src/LazyLoadGuardMode.php @@ -9,8 +9,8 @@ */ enum LazyLoadGuardMode { - /** Every lazy load is reported as a PSR-3 warning. */ + /** A reported lazy load is written as a PSR-3 warning. */ case Log; - /** Every lazy load throws a `LogicException`. */ + /** A reported lazy load throws a `LogicException`. */ case Strict; } From ab9e220380fff82ad3218080a3ca48b7ee388b4f Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Sun, 11 Oct 2026 13:02:49 +0200 Subject: [PATCH 11/20] Report the count and the relation to eager load --- src/LazyLoadGuard.php | 11 ++++++++--- tests/LazyLoadGuardTest.php | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/LazyLoadGuard.php b/src/LazyLoadGuard.php index d3c84718c..83533f08d 100644 --- a/src/LazyLoadGuard.php +++ b/src/LazyLoadGuard.php @@ -45,7 +45,9 @@ final class LazyLoadGuard private static array $guardedClasses = []; /** - * Sets the mode of the guard and the logger used in the {@see LazyLoadGuardMode::Log} mode. + * Sets the mode of the guard and the logger to report to. The logger is only used in the + * {@see LazyLoadGuardMode::Log} mode, since {@see LazyLoadGuardMode::Strict} carries the same information in + * the exception it throws. */ public static function set(LazyLoadGuardMode $mode, ?LoggerInterface $logger = null): void { @@ -108,12 +110,15 @@ public static function check(ActiveRecordInterface $model, string $relationName) return; } + $message = "Relation \"$relation\" is lazy loaded by $count records of one result set." + . " Eager load it with \"with('$relationName')\" to run one query instead of $count."; + if (self::$mode === LazyLoadGuardMode::Strict) { - throw new LogicException("Relation \"$relation\" is lazy loaded."); + throw new LogicException($message); } self::$logger?->warning( - "Relation \"$relation\" is lazy loaded.", + $message, [ 'model' => $modelClass, 'relation' => $relationName, diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index 331658eb1..60df28954 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -133,7 +133,10 @@ public function testModeStrictThrowsExceptionWithRelationName(): void $customers[0]->getOrders(); $this->expectException(LogicException::class); - $this->expectExceptionMessage('Relation "' . CustomerLazyLoadGuardModel::class . '::orders" is lazy loaded.'); + $this->expectExceptionMessage( + 'Relation "' . CustomerLazyLoadGuardModel::class . '::orders" is lazy loaded by 2 records of one result' + . ' set. Eager load it with "with(\'orders\')" to run one query instead of 2.', + ); $customers[1]->getOrders(); } From 2a8dd34a5b6f436abeb0465c275ec9b50c6eabf6 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Sun, 11 Oct 2026 13:02:49 +0200 Subject: [PATCH 12/20] Cover MagicPropertiesTrait usage --- tests/LazyLoadGuardTest.php | 22 +++++++++++++++++++ .../CustomerLazyLoadGuardModel.php | 12 ++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/Stubs/MagicActiveRecord/CustomerLazyLoadGuardModel.php diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index 60df28954..e756acf9a 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -10,6 +10,7 @@ use Yiisoft\ActiveRecord\LazyLoadGuard; use Yiisoft\ActiveRecord\LazyLoadGuardMode; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerLazyLoadGuardModel; +use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\CustomerLazyLoadGuardModel as MagicCustomerLazyLoadGuardModel; use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; use Yiisoft\Test\Support\Log\SimpleLogger; @@ -156,6 +157,27 @@ public function testModeStrictCountsLazyLoadBeforeThrowing(): void $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 2], LazyLoadGuard::getCounters()); } + public function testWorksWithMagicPropertiesTrait(): void + { + foreach (MagicCustomerLazyLoadGuardModel::query()->all() as $customer) { + $customer->orders; + } + + $this->assertSame([MagicCustomerLazyLoadGuardModel::class . '::orders' => 3], LazyLoadGuard::getCounters()); + } + + public function testModeStrictThrowsForMagicPropertyRelation(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + $customers = MagicCustomerLazyLoadGuardModel::query()->all(); + $customers[0]->orders; + + $this->expectException(LogicException::class); + + $customers[1]->orders; + } + public function testWorksWithEventsTrait(): void { $events = []; diff --git a/tests/Stubs/MagicActiveRecord/CustomerLazyLoadGuardModel.php b/tests/Stubs/MagicActiveRecord/CustomerLazyLoadGuardModel.php new file mode 100644 index 000000000..153c58d5f --- /dev/null +++ b/tests/Stubs/MagicActiveRecord/CustomerLazyLoadGuardModel.php @@ -0,0 +1,12 @@ + Date: Sun, 11 Oct 2026 13:02:49 +0200 Subject: [PATCH 13/20] Document the report and resetting the guard between tests --- docs/traits/lazy-load-guard.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/traits/lazy-load-guard.md b/docs/traits/lazy-load-guard.md index ab20685b3..18638a584 100644 --- a/docs/traits/lazy-load-guard.md +++ b/docs/traits/lazy-load-guard.md @@ -38,17 +38,36 @@ use Yiisoft\ActiveRecord\LazyLoadGuardMode; LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); ``` -| Mode | Behavior | -|----------|------------------------------------------------------------------------------------------------------| -| `Log` | Default. Reports a PSR-3 warning with the relation name, the count within the result set and a trace | -| `Strict` | Throws `LogicException` | +| Mode | Behavior | +|----------|-----------------------------------------------------------------------------------| +| `Log` | Default. Writes a PSR-3 warning; the context carries the model, relation and trace | +| `Strict` | Throws `LogicException` | + +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. -`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 counters live until `LazyLoadGuard::reset()` is called. In long-running workers call it after each request, +and in a test suite between tests, so that result sets of one test aren't counted against the next: + +```php +protected function setUp(): void +{ + LazyLoadGuard::reset(); + LazyLoadGuard::set(LazyLoadGuardMode::Strict); +} +``` + +`reset()` clears the counters and restores the mode and the logger to their defaults, so set them again +afterwards, as above. The guard is meant for development and testing, e.g. `Strict` in the test suite and `Log` on staging. From 8e97cf11ffc489ea9d09b474573601c2f3537222 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Sun, 11 Oct 2026 20:15:11 +0200 Subject: [PATCH 14/20] Throw a friendly exception when the guard reports in strict mode --- composer.json | 3 ++- docs/traits/lazy-load-guard.md | 6 ++--- src/LazyLoadException.php | 40 ++++++++++++++++++++++++++++++++++ src/LazyLoadGuard.php | 5 ++--- tests/LazyLoadGuardTest.php | 24 ++++++++++++++++---- 5 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 src/LazyLoadException.php diff --git a/composer.json b/composer.json index ae19436cf..31fe5a592 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ ], "require": { "php": "8.1 - 8.5", - "yiisoft/db": "^2.0" + "yiisoft/db": "^2.0", + "yiisoft/friendly-exception": "^1.1" }, "require-dev": { "ext-pdo": "*", diff --git a/docs/traits/lazy-load-guard.md b/docs/traits/lazy-load-guard.md index 18638a584..6695709f7 100644 --- a/docs/traits/lazy-load-guard.md +++ b/docs/traits/lazy-load-guard.md @@ -38,10 +38,10 @@ use Yiisoft\ActiveRecord\LazyLoadGuardMode; LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); ``` -| Mode | Behavior | -|----------|-----------------------------------------------------------------------------------| +| Mode | Behavior | +|----------|------------------------------------------------------------------------------------| | `Log` | Default. Writes a PSR-3 warning; the context carries the model, relation and trace | -| `Strict` | Throws `LogicException` | +| `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: diff --git a/src/LazyLoadException.php b/src/LazyLoadException.php new file mode 100644 index 000000000..be9628791 --- /dev/null +++ b/src/LazyLoadException.php @@ -0,0 +1,40 @@ +with('$this->relationName')->all(); + ``` + + When the lazy loads are intentional, switch the guard to `LazyLoadGuardMode::Log` to keep reporting + them without throwing, or to `LazyLoadGuardMode::Disabled` to stop reporting them. + SOLUTION; + } +} diff --git a/src/LazyLoadGuard.php b/src/LazyLoadGuard.php index 83533f08d..2c448ce82 100644 --- a/src/LazyLoadGuard.php +++ b/src/LazyLoadGuard.php @@ -5,7 +5,6 @@ namespace Yiisoft\ActiveRecord; use Exception; -use LogicException; use Psr\Log\LoggerInterface; use WeakMap; use Yiisoft\ActiveRecord\Trait\LazyLoadGuardTrait; @@ -86,7 +85,7 @@ public static function registerResultSet(array $models): void * Registers a lazy load of the relation and reports it according to the mode, starting from the second record * of the same result set that loads the relation, since one record loading a relation isn't an N+1 problem. * - * @throws LogicException In the {@see LazyLoadGuardMode::Strict} mode. + * @throws LazyLoadException In the {@see LazyLoadGuardMode::Strict} mode. */ public static function check(ActiveRecordInterface $model, string $relationName): void { @@ -114,7 +113,7 @@ public static function check(ActiveRecordInterface $model, string $relationName) . " Eager load it with \"with('$relationName')\" to run one query instead of $count."; if (self::$mode === LazyLoadGuardMode::Strict) { - throw new LogicException($message); + throw new LazyLoadException($message, $relationName); } self::$logger?->warning( diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index e756acf9a..e9e48f57f 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -4,9 +4,9 @@ namespace Yiisoft\ActiveRecord\Tests; -use LogicException; use Yiisoft\ActiveRecord\Event\AfterPopulate; use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; +use Yiisoft\ActiveRecord\LazyLoadException; use Yiisoft\ActiveRecord\LazyLoadGuard; use Yiisoft\ActiveRecord\LazyLoadGuardMode; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerLazyLoadGuardModel; @@ -133,7 +133,7 @@ public function testModeStrictThrowsExceptionWithRelationName(): void $customers = CustomerLazyLoadGuardModel::query()->all(); $customers[0]->getOrders(); - $this->expectException(LogicException::class); + $this->expectException(LazyLoadException::class); $this->expectExceptionMessage( 'Relation "' . CustomerLazyLoadGuardModel::class . '::orders" is lazy loaded by 2 records of one result' . ' set. Eager load it with "with(\'orders\')" to run one query instead of 2.', @@ -142,6 +142,22 @@ public function testModeStrictThrowsExceptionWithRelationName(): void $customers[1]->getOrders(); } + public function testTheThrownExceptionSuggestsEagerLoadingTheRelation(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + $customers = CustomerLazyLoadGuardModel::query()->all(); + $customers[0]->getOrders(); + + try { + $customers[1]->getOrders(); + $this->fail('The guard did not report the lazy load.'); + } catch (LazyLoadException $e) { + $this->assertSame('Relation is lazy loaded by several records of one result set', $e->getName()); + $this->assertStringContainsString("with('orders')", (string) $e->getSolution()); + } + } + public function testModeStrictCountsLazyLoadBeforeThrowing(): void { LazyLoadGuard::set(LazyLoadGuardMode::Strict); @@ -151,7 +167,7 @@ public function testModeStrictCountsLazyLoadBeforeThrowing(): void try { $customers[1]->getOrders(); - } catch (LogicException) { + } catch (LazyLoadException) { } $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 2], LazyLoadGuard::getCounters()); @@ -173,7 +189,7 @@ public function testModeStrictThrowsForMagicPropertyRelation(): void $customers = MagicCustomerLazyLoadGuardModel::query()->all(); $customers[0]->orders; - $this->expectException(LogicException::class); + $this->expectException(LazyLoadException::class); $customers[1]->orders; } From 862e6207885ed083508733bee5eefc9847da8998 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Mon, 12 Oct 2026 00:08:24 +0200 Subject: [PATCH 15/20] Count distinct records of a result set and detect the trait used indirectly A record re-reading a relation after refresh() or resetRelation() was counted as another record of the result set, and the per-result-set counters were never freed. Both are gone with the counts held by a result set object in the WeakMap. class_uses() only returns the traits a class uses directly, so a model using the guard trait through a trait of its own was never guarded, and only the first model of a result set was checked. reset() dropped the mode and the logger too, which made it unusable between the requests of a worker; resetCounters() keeps them. --- src/Internal/LazyLoadResultSet.php | 49 ++++++++++++++ src/LazyLoadGuard.php | 103 +++++++++++++++++------------ 2 files changed, 110 insertions(+), 42 deletions(-) create mode 100644 src/Internal/LazyLoadResultSet.php diff --git a/src/Internal/LazyLoadResultSet.php b/src/Internal/LazyLoadResultSet.php new file mode 100644 index 000000000..1de96b8af --- /dev/null +++ b/src/Internal/LazyLoadResultSet.php @@ -0,0 +1,49 @@ + WeakMap, ...]`. + * + * @psalm-var array> + */ + 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 + { + $records = $this->records[$relation] ?? null; + + if ($records === null) { + /** @psalm-var WeakMap $records */ + $records = new WeakMap(); + $this->records[$relation] = $records; + } + + $records[$record] = true; + + return count($records); + } +} diff --git a/src/LazyLoadGuard.php b/src/LazyLoadGuard.php index 2c448ce82..772188ea0 100644 --- a/src/LazyLoadGuard.php +++ b/src/LazyLoadGuard.php @@ -7,11 +7,11 @@ use Exception; use Psr\Log\LoggerInterface; use WeakMap; +use Yiisoft\ActiveRecord\Internal\LazyLoadResultSet; use Yiisoft\ActiveRecord\Trait\LazyLoadGuardTrait; use function class_uses; use function get_parent_class; -use function reset; /** * Detects N+1 queries caused by lazy loading of relations in models using {@see LazyLoadGuardTrait}. @@ -28,16 +28,11 @@ final class LazyLoadGuard /** * @var WeakMap|null The result set each model was populated from, keyed by model. + * + * @psalm-var WeakMap|null */ private static ?WeakMap $resultSets = null; - /** - * @var int[] Number of lazy loads within one result set `[result_set_id model_class::relation_name => count]` - */ - private static array $resultSetCounters = []; - - private static int $lastResultSetId = 0; - /** * @var bool[] Whether a model class uses {@see LazyLoadGuardTrait} `[model_class => bool, ...]` */ @@ -55,8 +50,8 @@ public static function set(LazyLoadGuardMode $mode, ?LoggerInterface $logger = n } /** - * Groups the models of one result set, so that the guard can tell a relation loaded for several records of - * the same result set, which is an N+1 query, from the same relation loaded for records that were each + * Groups the models of one result set, so that the guard can tell a relation loaded by several records of + * the same result set, which is an N+1 query, from the same relation loaded by records that were each * fetched by a query of their own, which has nothing to eager load across. * * @param ActiveRecordInterface[]|array[] $models The models populated from one result set. @@ -65,19 +60,15 @@ public static function set(LazyLoadGuardMode $mode, ?LoggerInterface $logger = n */ public static function registerResultSet(array $models): void { - $firstModel = reset($models); - - if (!$firstModel instanceof ActiveRecordInterface || !self::isGuarded($firstModel::class)) { - return; - } - - self::$resultSets ??= new WeakMap(); - $resultSetId = ++self::$lastResultSetId; + $resultSet = null; foreach ($models as $model) { - if ($model instanceof ActiveRecordInterface) { - self::$resultSets[$model] = $resultSetId; + if (!$model instanceof ActiveRecordInterface || !self::isGuarded($model::class)) { + continue; } + + $resultSet ??= new LazyLoadResultSet(); + self::resultSets()[$model] = $resultSet; } } @@ -94,16 +85,16 @@ public static function check(ActiveRecordInterface $model, string $relationName) self::$counters[$relation] = (self::$counters[$relation] ?? 0) + 1; - $resultSets = self::$resultSets ??= new WeakMap(); + $resultSets = self::resultSets(); + $resultSet = $resultSets[$model] ?? null; - /** A record populated outside a result set is a result set of its own. */ - $resultSets[$model] ??= ++self::$lastResultSetId; - - /** @var int $resultSetId */ - $resultSetId = $resultSets[$model]; + if ($resultSet === null) { + /** A record populated outside a result set is a result set of its own. */ + $resultSet = new LazyLoadResultSet(); + $resultSets[$model] = $resultSet; + } - $counter = $resultSetId . ' ' . $relation; - $count = self::$resultSetCounters[$counter] = (self::$resultSetCounters[$counter] ?? 0) + 1; + $count = $resultSet->registerLazyLoad($model, $relation); if ($count === 1) { return; @@ -113,7 +104,7 @@ public static function check(ActiveRecordInterface $model, string $relationName) . " Eager load it with \"with('$relationName')\" to run one query instead of $count."; if (self::$mode === LazyLoadGuardMode::Strict) { - throw new LazyLoadException($message, $relationName); + throw new LazyLoadException($message, $modelClass, $relationName); } self::$logger?->warning( @@ -139,16 +130,35 @@ public static function getCounters(): array } /** - * Resets the mode, the logger and the counters to their defaults. + * Clears the counters and the registered result sets, keeping the mode and the logger. Call it between the + * requests of a long-running worker, so that the result sets of one request aren't counted against the next. + */ + public static function resetCounters(): void + { + self::$counters = []; + self::$resultSets = null; + } + + /** + * Resets the counters, the mode and the logger to their defaults. */ public static function reset(): void { self::$mode = LazyLoadGuardMode::Log; self::$logger = null; - self::$counters = []; - self::$resultSets = null; - self::$resultSetCounters = []; - self::$lastResultSetId = 0; + + self::resetCounters(); + } + + /** + * @psalm-return WeakMap + */ + private static function resultSets(): WeakMap + { + /** @psalm-var WeakMap $resultSets */ + $resultSets = self::$resultSets ?? new WeakMap(); + + return self::$resultSets = $resultSets; } /** @@ -156,16 +166,25 @@ public static function reset(): void */ private static function isGuarded(string $modelClass): bool { - if (isset(self::$guardedClasses[$modelClass])) { - return self::$guardedClasses[$modelClass]; - } - - $traits = []; + return self::$guardedClasses[$modelClass] ??= self::usesGuardTrait($modelClass); + } - for ($class = $modelClass; $class !== false; $class = get_parent_class($class)) { - $traits += class_uses($class) ?: []; + /** + * Whether the class, one of its parents or one of the traits they use, directly or through another trait, + * uses {@see LazyLoadGuardTrait}. {@see class_uses()} only returns the traits used directly by the class. + * + * @param class-string $class + */ + private static function usesGuardTrait(string $class): bool + { + foreach (class_uses($class) ?: [] as $trait) { + if ($trait === LazyLoadGuardTrait::class || self::usesGuardTrait($trait)) { + return true; + } } - return self::$guardedClasses[$modelClass] = isset($traits[LazyLoadGuardTrait::class]); + $parent = get_parent_class($class); + + return $parent !== false && self::usesGuardTrait($parent); } } From 98c15b196893857af13dfe9886250314b306270d Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Mon, 12 Oct 2026 00:08:51 +0200 Subject: [PATCH 16/20] Name the model in the exception solution The suggested with() call named Customer whatever the model was, and offered a mode the enum doesn't have. --- src/LazyLoadException.php | 14 ++++++++++---- src/LazyLoadGuardMode.php | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/LazyLoadException.php b/src/LazyLoadException.php index be9628791..e0d5601fb 100644 --- a/src/LazyLoadException.php +++ b/src/LazyLoadException.php @@ -13,8 +13,14 @@ */ final class LazyLoadException extends LogicException implements FriendlyExceptionInterface { - public function __construct(string $message, private readonly string $relationName) - { + /** + * @param class-string $modelClass + */ + public function __construct( + string $message, + private readonly string $modelClass, + private readonly string $relationName, + ) { parent::__construct($message); } @@ -30,11 +36,11 @@ public function getSolution(): ?string the records, so that the relation is read with a single query: ```php - \$customers = Customer::query()->with('$this->relationName')->all(); + \$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, or to `LazyLoadGuardMode::Disabled` to stop reporting them. + them without throwing. SOLUTION; } } diff --git a/src/LazyLoadGuardMode.php b/src/LazyLoadGuardMode.php index 527534ec0..14384df60 100644 --- a/src/LazyLoadGuardMode.php +++ b/src/LazyLoadGuardMode.php @@ -11,6 +11,6 @@ enum LazyLoadGuardMode { /** A reported lazy load is written as a PSR-3 warning. */ case Log; - /** A reported lazy load throws a `LogicException`. */ + /** A reported lazy load throws a {@see LazyLoadException}. */ case Strict; } From a891589d4e52290eafca95e7fdd09c8e8ff013f1 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Mon, 12 Oct 2026 00:08:51 +0200 Subject: [PATCH 17/20] Cover re-loaded relations, asArray sets, nested and indirect guards --- tests/LazyLoadGuardTest.php | 87 +++++++++++++++++++ .../CustomerLazyLoadGuardModel.php | 20 +++++ .../ActiveRecord/OrderLazyLoadGuardModel.php | 12 +++ tests/Stubs/Trait/ModelLazyLoadGuardTrait.php | 15 ++++ 4 files changed, 134 insertions(+) create mode 100644 tests/Stubs/ActiveRecord/OrderLazyLoadGuardModel.php create mode 100644 tests/Stubs/Trait/ModelLazyLoadGuardTrait.php diff --git a/tests/LazyLoadGuardTest.php b/tests/LazyLoadGuardTest.php index e9e48f57f..5f73fc8c2 100644 --- a/tests/LazyLoadGuardTest.php +++ b/tests/LazyLoadGuardTest.php @@ -10,6 +10,7 @@ use Yiisoft\ActiveRecord\LazyLoadGuard; use Yiisoft\ActiveRecord\LazyLoadGuardMode; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerLazyLoadGuardModel; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderLazyLoadGuardModel; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\CustomerLazyLoadGuardModel as MagicCustomerLazyLoadGuardModel; use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; use Yiisoft\Test\Support\Log\SimpleLogger; @@ -214,6 +215,92 @@ static function (object $event) use (&$events): void { $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 1], LazyLoadGuard::getCounters()); } + public function testRecordReloadingRelationAfterRefreshIsNotReported(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + $customers = CustomerLazyLoadGuardModel::query()->all(); + $customers[0]->getOrders(); + $customers[0]->refresh(); + + $this->assertCount(1, $customers[0]->getOrders()); + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 2], LazyLoadGuard::getCounters()); + } + + public function testRecordReloadingRelationAfterResetRelationIsNotReported(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + $customers = CustomerLazyLoadGuardModel::query()->all(); + $customers[0]->getOrders(); + $customers[0]->resetRelation('orders'); + $customers[0]->getOrders(); + + $this->expectException(LazyLoadException::class); + $this->expectExceptionMessage('is lazy loaded by 2 records of one result set'); + + $customers[1]->getOrders(); + } + + public function testAsArrayResultSetIsNotRegistered(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + $customers = CustomerLazyLoadGuardModel::query()->asArray()->all(); + + $this->assertCount(3, $customers); + $this->assertSame([], LazyLoadGuard::getCounters()); + } + + public function testNestedLazyLoadOfEagerLoadedRecordsIsReported(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + $orders = []; + + foreach (CustomerLazyLoadGuardModel::query()->with('guardedOrders')->all() as $customer) { + foreach ($customer->getGuardedOrders() as $order) { + $orders[] = $order; + } + } + + $orders[0]->getItems(); + + $this->expectException(LazyLoadException::class); + $this->expectExceptionMessage(OrderLazyLoadGuardModel::class . '::items'); + + $orders[1]->getItems(); + } + + public function testGuardTraitUsedByAnotherTraitIsDetected(): void + { + LazyLoadGuard::set(LazyLoadGuardMode::Strict); + + $orders = OrderLazyLoadGuardModel::query()->all(); + $orders[0]->getItems(); + + $this->expectException(LazyLoadException::class); + + $orders[1]->getItems(); + } + + public function testResetCountersKeepsModeAndLogger(): void + { + $logger = new SimpleLogger(); + LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); + + CustomerLazyLoadGuardModel::query()->findByPk(1)->getOrders(); + LazyLoadGuard::resetCounters(); + + $this->assertSame([], LazyLoadGuard::getCounters()); + + foreach (CustomerLazyLoadGuardModel::query()->all() as $customer) { + $customer->getOrders(); + } + + $this->assertCount(2, $logger->getMessages()); + } + public function testReset(): void { LazyLoadGuard::set(LazyLoadGuardMode::Strict, new SimpleLogger()); diff --git a/tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php b/tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php index a0ff30b1c..9ff5f804c 100644 --- a/tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php +++ b/tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php @@ -4,6 +4,8 @@ namespace Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveQuery; +use Yiisoft\ActiveRecord\ActiveQueryInterface; use Yiisoft\ActiveRecord\Trait\EventsTrait; use Yiisoft\ActiveRecord\Trait\LazyLoadGuardTrait; @@ -11,4 +13,22 @@ final class CustomerLazyLoadGuardModel extends Customer { use EventsTrait; use LazyLoadGuardTrait; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'guardedOrders' => $this->getGuardedOrdersQuery(), + default => parent::relationQuery($name), + }; + } + + public function getGuardedOrders(): array + { + return $this->relation('guardedOrders'); + } + + public function getGuardedOrdersQuery(): ActiveQuery + { + return $this->hasMany(OrderLazyLoadGuardModel::class, ['customer_id' => 'id'])->orderBy('[[id]]'); + } } diff --git a/tests/Stubs/ActiveRecord/OrderLazyLoadGuardModel.php b/tests/Stubs/ActiveRecord/OrderLazyLoadGuardModel.php new file mode 100644 index 000000000..8f61e81b7 --- /dev/null +++ b/tests/Stubs/ActiveRecord/OrderLazyLoadGuardModel.php @@ -0,0 +1,12 @@ + Date: Mon, 12 Oct 2026 00:08:51 +0200 Subject: [PATCH 18/20] Document record counting, indirect trait use and resetCounters() --- docs/traits/lazy-load-guard.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/traits/lazy-load-guard.md b/docs/traits/lazy-load-guard.md index 6695709f7..67d486faf 100644 --- a/docs/traits/lazy-load-guard.md +++ b/docs/traits/lazy-load-guard.md @@ -27,8 +27,17 @@ foreach (Customer::query()->all() as $customer) { // 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 @@ -55,8 +64,21 @@ In `Log` mode the logger is optional; when it's omitted the lazy loads are still `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 `LazyLoadGuard::reset()` is called. In long-running workers call it after each request, -and in a test suite between tests, so that result sets of one test aren't counted against the next: +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 @@ -66,9 +88,6 @@ protected function setUp(): void } ``` -`reset()` clears the counters and restores the mode and the logger to their defaults, so set them again -afterwards, as above. - The guard is meant for development and testing, e.g. `Strict` in the test suite and `Log` on staging. > [!NOTE] From 64fec4e552cf41336a7b5a53a21e31e7c75115c1 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Mon, 12 Oct 2026 00:13:11 +0200 Subject: [PATCH 19/20] Track the records of a result set by id, not in a WeakMap A WeakMap held as the value of a WeakMap entry keyed by the same record segfaults PHP when the records are freed. The result set only outlives its own records, so their ids are enough to count them. --- src/Internal/LazyLoadResultSet.php | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/Internal/LazyLoadResultSet.php b/src/Internal/LazyLoadResultSet.php index 1de96b8af..9fd82f6d4 100644 --- a/src/Internal/LazyLoadResultSet.php +++ b/src/Internal/LazyLoadResultSet.php @@ -4,15 +4,15 @@ namespace Yiisoft\ActiveRecord\Internal; -use WeakMap; 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 records are held weakly, so that the - * counts are freed together with the result set they belong to. + * 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 * @@ -21,9 +21,12 @@ final class LazyLoadResultSet { /** - * @var WeakMap[] The records that lazy loaded the relation, by relation `[relation => WeakMap, ...]`. + * @var array[] The ids of the records that lazy loaded the relation `[relation => [record_id => true], ...]`. * - * @psalm-var array> + * 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> */ private array $records = []; @@ -34,16 +37,8 @@ final class LazyLoadResultSet */ public function registerLazyLoad(ActiveRecordInterface $record, string $relation): int { - $records = $this->records[$relation] ?? null; - - if ($records === null) { - /** @psalm-var WeakMap $records */ - $records = new WeakMap(); - $this->records[$relation] = $records; - } - - $records[$record] = true; + $this->records[$relation][spl_object_id($record)] = true; - return count($records); + return count($this->records[$relation]); } } From 4e3d408f12506d369a318112ddc2520f1bfaaa91 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Mon, 12 Oct 2026 09:17:09 +0200 Subject: [PATCH 20/20] Take the result set and the map once per registered set --- src/LazyLoadGuard.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/LazyLoadGuard.php b/src/LazyLoadGuard.php index 772188ea0..e952fdfe1 100644 --- a/src/LazyLoadGuard.php +++ b/src/LazyLoadGuard.php @@ -60,6 +60,7 @@ public static function set(LazyLoadGuardMode $mode, ?LoggerInterface $logger = n */ public static function registerResultSet(array $models): void { + $resultSets = null; $resultSet = null; foreach ($models as $model) { @@ -67,8 +68,9 @@ public static function registerResultSet(array $models): void continue; } + $resultSets ??= self::resultSets(); $resultSet ??= new LazyLoadResultSet(); - self::resultSets()[$model] = $resultSet; + $resultSets[$model] = $resultSet; } }