From 1ecd13e11e32a63393c7974b9935537a3a16e35a Mon Sep 17 00:00:00 2001 From: Yaguang Ding Date: Fri, 13 Feb 2026 09:51:28 +0800 Subject: [PATCH] Add withoutDetection() method for suppressing N+1 detection --- config/config.php | 6 +++ docs/usage.md | 27 ++++++++++++ src/QueryDetector.php | 25 ++++++++++- tests/QueryDetectorTest.php | 83 +++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/config/config.php b/config/config.php index 84f1765..349d028 100644 --- a/config/config.php +++ b/config/config.php @@ -13,6 +13,12 @@ */ 'threshold' => (int) env('QUERY_DETECTOR_THRESHOLD', 1), + /* + * The depth limit for debug_backtrace(). Higher values provide more + * complete stack traces but use more memory. Set to 0 for unlimited. + */ + 'backtrace_limit' => (int) env('QUERY_DETECTOR_BACKTRACE_LIMIT', 50), + /* * Here you can whitelist model relations. * diff --git a/docs/usage.md b/docs/usage.md index be34cfd..72acb7f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -89,4 +89,31 @@ If you use **Lumen**, you need to copy the config file manually and register the $app->register(\BeyondCode\QueryDetector\LumenQueryDetectorServiceProvider::class); ``` +## Suppressing Detection + +You can temporarily disable N+1 detection for a specific block of code using `withoutDetection()`. All queries inside the closure will be ignored by the detector. + +```php +app(\BeyondCode\QueryDetector\QueryDetector::class)->withoutDetection(function () { + // N+1 queries here will not be reported + $authors = Author::all(); + + foreach ($authors as $author) { + $author->posts; + } +}); +``` + +The closure's return value is passed through, so you can use it inline: + +```php +$authors = app(\BeyondCode\QueryDetector\QueryDetector::class)->withoutDetection(function () { + return Author::all()->each(fn ($author) => $author->posts); +}); +``` + +This is useful when you intentionally accept N+1 queries in certain contexts (e.g. admin pages with small datasets, or background jobs where eager loading is impractical). Detection resumes automatically after the closure finishes, even if it throws an exception. + +## Events + If you need additional logic to run when the package detects unoptimized queries, you can listen to the `\BeyondCode\QueryDetector\Events\QueryDetected` event and write a listener to run your own handler. (e.g. send warning to Sentry/Bugsnag, send Slack notification, etc.) diff --git a/src/QueryDetector.php b/src/QueryDetector.php index f48e5d2..79cf5b3 100755 --- a/src/QueryDetector.php +++ b/src/QueryDetector.php @@ -19,6 +19,9 @@ class QueryDetector */ private $booted = false; + /** @var bool */ + private $disabled = false; + private function resetQueries() { $this->queries = Collection::make(); @@ -37,7 +40,7 @@ public function boot() } DB::listen(function ($query) { - $backtrace = collect(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 50)); + $backtrace = collect(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, config('querydetector.backtrace_limit', 50))); $this->logQuery($query, $backtrace); }); @@ -61,8 +64,28 @@ public function isEnabled(): bool return $configEnabled; } + /** + * @template T + * @param callable(): T $callback + * @return T + */ + public function withoutDetection(callable $callback) + { + $this->disabled = true; + + try { + return $callback(); + } finally { + $this->disabled = false; + } + } + public function logQuery($query, Collection $backtrace) { + if ($this->disabled) { + return; + } + $modelTrace = $backtrace->first(function ($trace) { return Arr::get($trace, 'object') instanceof Builder; }); diff --git a/tests/QueryDetectorTest.php b/tests/QueryDetectorTest.php index f2dbe0d..ed382be 100644 --- a/tests/QueryDetectorTest.php +++ b/tests/QueryDetectorTest.php @@ -359,4 +359,87 @@ public function it_empty_queries() $queries = $queryDetector->getDetectedQueries(); $this->assertCount(0, $queries); } + + /** @test */ + public function it_suppresses_n1_detection_with_without_detection() + { + Route::get('/', function () { + app(QueryDetector::class)->withoutDetection(function () { + $authors = Author::all(); + + foreach ($authors as $author) { + $author->profile; + } + }); + }); + + $this->get('/'); + + $queries = app(QueryDetector::class)->getDetectedQueries(); + + $this->assertCount(0, $queries); + } + + /** @test */ + public function it_resumes_detection_after_without_detection() + { + Route::get('/', function () { + $detector = app(QueryDetector::class); + + $detector->withoutDetection(function () { + foreach (Author::all() as $author) { + $author->profile; + } + }); + + // This should still be detected + foreach (Post::all() as $post) { + $post->comments; + } + }); + + $this->get('/'); + + $queries = app(QueryDetector::class)->getDetectedQueries(); + + $this->assertCount(1, $queries); + $this->assertSame(Post::class, $queries[0]['model']); + $this->assertSame('comments', $queries[0]['relation']); + } + + /** @test */ + public function it_returns_closure_value_from_without_detection() + { + $result = app(QueryDetector::class)->withoutDetection(function () { + return 'hello'; + }); + + $this->assertSame('hello', $result); + } + + /** @test */ + public function it_resumes_detection_even_if_closure_throws() + { + $detector = app(QueryDetector::class); + + try { + $detector->withoutDetection(function () { + throw new \RuntimeException('test'); + }); + } catch (\RuntimeException $e) { + // expected + } + + Route::get('/', function () { + foreach (Author::all() as $author) { + $author->profile; + } + }); + + $this->get('/'); + + $queries = $detector->getDetectedQueries(); + + $this->assertCount(1, $queries); + } }