diff --git a/CHANGELOG.md b/CHANGELOG.md index b77be6235..07bbca6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 1.1.1 under development -- no changes in this release. +- Enh #590: Add `LazyLoadGuardTrait` and `LazyLoadGuard` to detect N+1 queries + (@KalimeroMK) ## 1.1.0 May 14, 2026 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..31fe5a592 100644 --- a/composer.json +++ b/composer.json @@ -28,13 +28,15 @@ ], "require": { "php": "8.1 - 8.5", - "yiisoft/db": "^2.0" + "yiisoft/db": "^2.0", + "yiisoft/friendly-exception": "^1.1" }, "require-dev": { "ext-pdo": "*", "bamarni/composer-bin-plugin": "^1.8.3", "friendsofphp/php-cs-fixer": "^3.89.1", "phpunit/phpunit": "^10.5.58", + "psr/log": "^2.0|^3.0", "psr/simple-cache": "^2.0|^3.0", "rector/rector": "^2.2.3", "shipmonk/composer-dependency-analyser": "^1.8", @@ -60,7 +62,8 @@ "yiisoft/db-mssql": "For MSSQL database support", "yiisoft/db-oracle": "For Oracle database support", "yiisoft/factory": "For factory support", - "yiisoft/event-dispatcher": "For events support" + "yiisoft/event-dispatcher": "For events support", + "psr/log": "For \\Yiisoft\\ActiveRecord\\LazyLoadGuard logging support" }, "autoload": { "psr-4": { diff --git a/docs/traits/lazy-load-guard.md b/docs/traits/lazy-load-guard.md new file mode 100644 index 000000000..67d486faf --- /dev/null +++ b/docs/traits/lazy-load-guard.md @@ -0,0 +1,97 @@ +# LazyLoadGuardTrait + +`LazyLoadGuardTrait` allows detecting N+1 queries caused by lazy loading of relations, i.e. reading a relation +that wasn't eager-loaded by `with()`. + +```php +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\Trait\LazyLoadGuardTrait; + +final class Customer extends ActiveRecord +{ + use LazyLoadGuardTrait; +} +``` + +Lazy loads are counted by [LazyLoadGuard](../../src/LazyLoadGuard.php) per result set, and reported according to +the mode starting from the second record of the same result set that reads the relation. One record reading a +relation isn't an N+1 problem, and neither are records that were each fetched by a query of their own, since there +is nothing to eager load across them: + +```php +// Reported: one query for the customers, then one query per customer for the orders. +foreach (Customer::query()->all() as $customer) { + $customer->getOrders(); +} + +// Not reported: each customer came from a query of its own. +Customer::query()->findByPk(1)->getOrders(); +Customer::query()->findByPk(2)->getOrders(); + +// Not reported: one record reading the relation again after it was reset. Records are counted, not reads. +$customer = Customer::query()->findByPk(1); +$customer->getOrders(); +$customer->refresh(); +$customer->getOrders(); +``` + +The model may use the trait through a parent class or through another trait of its own, which is where a +project-wide base trait usually puts it. + +The mode and the logger are set statically: + +```php +use Yiisoft\ActiveRecord\LazyLoadGuard; +use Yiisoft\ActiveRecord\LazyLoadGuardMode; + +LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); +``` + +| Mode | Behavior | +|----------|------------------------------------------------------------------------------------| +| `Log` | Default. Writes a PSR-3 warning; the context carries the model, relation and trace | +| `Strict` | Throws `LazyLoadException` | + +Both modes report the same text, which names the relation, how many records of the result set read it and the +`with()` call that would replace them with one query: + +``` +Relation "App\Model\Customer::orders" is lazy loaded by 3 records of one result set. +Eager load it with "with('orders')" to run one query instead of 3. +``` + +In `Log` mode the logger is optional; when it's omitted the lazy loads are still counted and readable through +`LazyLoadGuard::getCounters()`, but nothing is written anywhere. `getCounters()` returns every lazy load per +relation, including the ones that weren't reported because they belong to result sets of their own. + +The counters live until they're cleared. In long-running workers call `LazyLoadGuard::resetCounters()` after +each request, so that the result sets of one request aren't counted against the next; it keeps the mode and the +logger, so they're configured once: + +```php +LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); + +while ($request = $queue->next()) { + $handler->handle($request); + LazyLoadGuard::resetCounters(); +} +``` + +In a test suite call `LazyLoadGuard::reset()` between tests, which clears the counters and restores the mode and +the logger to their defaults as well, so that the setup of one test doesn't leak into the next: + +```php +protected function setUp(): void +{ + LazyLoadGuard::reset(); + LazyLoadGuard::set(LazyLoadGuardMode::Strict); +} +``` + +The guard is meant for development and testing, e.g. `Strict` in the test suite and `Log` on staging. + +> [!NOTE] +> The trait overrides `retrieveRelation()` method. If another trait of the model overrides it too, +> resolve the conflict using `insteadof` and `as` operators. + +Back to [Extending Functionality With Traits](traits.md). 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/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/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]; } diff --git a/src/Internal/LazyLoadResultSet.php b/src/Internal/LazyLoadResultSet.php new file mode 100644 index 000000000..9fd82f6d4 --- /dev/null +++ b/src/Internal/LazyLoadResultSet.php @@ -0,0 +1,44 @@ + [record_id => true], ...]`. + * + * The records of a result set are known when it's registered, while all of them are alive, so an id of a + * freed record is never reused within the same result set. + * + * @psalm-var array> + */ + private array $records = []; + + /** + * Registers a record that lazy loaded the relation and returns the number of records of this result set that + * have loaded it. A record that loads the relation again, after {@see ActiveRecordInterface::resetRelation()} + * or {@see ActiveRecordInterface::refresh()} dropped it, is counted once. + */ + public function registerLazyLoad(ActiveRecordInterface $record, string $relation): int + { + $this->records[$relation][spl_object_id($record)] = true; + + return count($this->records[$relation]); + } +} diff --git a/src/LazyLoadException.php b/src/LazyLoadException.php new file mode 100644 index 000000000..e0d5601fb --- /dev/null +++ b/src/LazyLoadException.php @@ -0,0 +1,46 @@ +modelClass}::query()->with('$this->relationName')->all(); + ``` + + When the lazy loads are intentional, switch the guard to `LazyLoadGuardMode::Log` to keep reporting + them without throwing. + SOLUTION; + } +} diff --git a/src/LazyLoadGuard.php b/src/LazyLoadGuard.php new file mode 100644 index 000000000..e952fdfe1 --- /dev/null +++ b/src/LazyLoadGuard.php @@ -0,0 +1,192 @@ + count, ...]` + */ + private static array $counters = []; + + /** + * @var WeakMap|null The result set each model was populated from, keyed by model. + * + * @psalm-var WeakMap|null + */ + private static ?WeakMap $resultSets = null; + + /** + * @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 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 + { + self::$mode = $mode; + self::$logger = $logger; + } + + /** + * 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. + * + * @see ActiveQuery::populate() + */ + public static function registerResultSet(array $models): void + { + $resultSets = null; + $resultSet = null; + + foreach ($models as $model) { + if (!$model instanceof ActiveRecordInterface || !self::isGuarded($model::class)) { + continue; + } + + $resultSets ??= self::resultSets(); + $resultSet ??= new LazyLoadResultSet(); + $resultSets[$model] = $resultSet; + } + } + + /** + * 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 LazyLoadException 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; + + $resultSets = self::resultSets(); + $resultSet = $resultSets[$model] ?? null; + + if ($resultSet === null) { + /** A record populated outside a result set is a result set of its own. */ + $resultSet = new LazyLoadResultSet(); + $resultSets[$model] = $resultSet; + } + + $count = $resultSet->registerLazyLoad($model, $relation); + + if ($count === 1) { + 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 LazyLoadException($message, $modelClass, $relationName); + } + + self::$logger?->warning( + $message, + [ + 'model' => $modelClass, + 'relation' => $relationName, + 'count' => $count, + 'trace' => (new Exception())->getTraceAsString(), + ], + ); + } + + /** + * 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[] + */ + public static function getCounters(): array + { + return self::$counters; + } + + /** + * 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::resetCounters(); + } + + /** + * @psalm-return WeakMap + */ + private static function resultSets(): WeakMap + { + /** @psalm-var WeakMap $resultSets */ + $resultSets = self::$resultSets ?? new WeakMap(); + + return self::$resultSets = $resultSets; + } + + /** + * @param class-string $modelClass + */ + private static function isGuarded(string $modelClass): bool + { + return self::$guardedClasses[$modelClass] ??= self::usesGuardTrait($modelClass); + } + + /** + * 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; + } + } + + $parent = get_parent_class($class); + + return $parent !== false && self::usesGuardTrait($parent); + } +} diff --git a/src/LazyLoadGuardMode.php b/src/LazyLoadGuardMode.php new file mode 100644 index 000000000..14384df60 --- /dev/null +++ b/src/LazyLoadGuardMode.php @@ -0,0 +1,16 @@ +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 "' 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..5f73fc8c2 --- /dev/null +++ b/tests/LazyLoadGuardTest.php @@ -0,0 +1,318 @@ +all() as $customer) { + $customer->getOrders(); + } + + $this->assertSame([CustomerLazyLoadGuardModel::class . '::orders' => 3], LazyLoadGuard::getCounters()); + } + + public function testEagerLoadedRelationIsNotCounted(): void + { + foreach (CustomerLazyLoadGuardModel::query()->with('orders')->all() as $customer) { + $customer->getOrders(); + } + + $this->assertSame([], LazyLoadGuard::getCounters()); + } + + public function testLoadedRelationIsCountedOnce(): void + { + $customer = CustomerLazyLoadGuardModel::query()->findByPk(1); + $customer->getOrders(); + $customer->getOrders(); + + $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); + + $this->assertCount(1, $customer->getOrders()); + } + + public function testModeLogIgnoresSingleLazyLoad(): void + { + $logger = new SimpleLogger(); + LazyLoadGuard::set(LazyLoadGuardMode::Log, $logger); + + $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(2, $messages); + $this->assertSame('warning', $messages[0]['level']); + $this->assertStringContainsString('orders', $messages[0]['message']); + + $context = $messages[0]['context']; + + $this->assertSame(CustomerLazyLoadGuardModel::class, $context['model']); + $this->assertSame('orders', $context['relation']); + $this->assertSame(2, $context['count']); + $this->assertIsString($context['trace']); + } + + 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(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.', + ); + + $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); + + $customers = CustomerLazyLoadGuardModel::query()->all(); + $customers[0]->getOrders(); + + try { + $customers[1]->getOrders(); + } catch (LazyLoadException) { + } + + $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(LazyLoadException::class); + + $customers[1]->orders; + } + + public function testWorksWithEventsTrait(): void + { + $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()); + } + + 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()); + 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..9ff5f804c --- /dev/null +++ b/tests/Stubs/ActiveRecord/CustomerLazyLoadGuardModel.php @@ -0,0 +1,34 @@ + $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 @@ +