Skip to content

Commit cecfc81

Browse files
author
Yaguang Ding
committed
Add withoutDetection() method for suppressing N+1 detection
1 parent 0f2b166 commit cecfc81

File tree

4 files changed

+168
-1
lines changed

4 files changed

+168
-1
lines changed

config/config.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
*/
1414
'threshold' => (int) env('QUERY_DETECTOR_THRESHOLD', 1),
1515

16+
/*
17+
* The depth limit for debug_backtrace(). Higher values provide more
18+
* complete stack traces but use more memory. Set to 0 for unlimited.
19+
*/
20+
'backtrace_limit' => (int) env('QUERY_DETECTOR_BACKTRACE_LIMIT', 50),
21+
1622
/*
1723
* Here you can whitelist model relations.
1824
*

docs/usage.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,31 @@ If you use **Lumen**, you need to copy the config file manually and register the
8989
$app->register(\BeyondCode\QueryDetector\LumenQueryDetectorServiceProvider::class);
9090
```
9191

92+
## Suppressing Detection
93+
94+
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.
95+
96+
```php
97+
app(\BeyondCode\QueryDetector\QueryDetector::class)->withoutDetection(function () {
98+
// N+1 queries here will not be reported
99+
$authors = Author::all();
100+
101+
foreach ($authors as $author) {
102+
$author->posts;
103+
}
104+
});
105+
```
106+
107+
The closure's return value is passed through, so you can use it inline:
108+
109+
```php
110+
$authors = app(\BeyondCode\QueryDetector\QueryDetector::class)->withoutDetection(function () {
111+
return Author::all()->each(fn ($author) => $author->posts);
112+
});
113+
```
114+
115+
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.
116+
117+
## Events
118+
92119
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.)

src/QueryDetector.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class QueryDetector
1919
*/
2020
private $booted = false;
2121

22+
/** @var int */
23+
private $disabledDepth = 0;
24+
2225
private function resetQueries()
2326
{
2427
$this->queries = Collection::make();
@@ -37,7 +40,7 @@ public function boot()
3740
}
3841

3942
DB::listen(function ($query) {
40-
$backtrace = collect(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 50));
43+
$backtrace = collect(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, config('querydetector.backtrace_limit', 50)));
4144

4245
$this->logQuery($query, $backtrace);
4346
});
@@ -61,8 +64,28 @@ public function isEnabled(): bool
6164
return $configEnabled;
6265
}
6366

67+
/**
68+
* @template T
69+
* @param callable(): T $callback
70+
* @return T
71+
*/
72+
public function withoutDetection(callable $callback)
73+
{
74+
$this->disabledDepth++;
75+
76+
try {
77+
return $callback();
78+
} finally {
79+
$this->disabledDepth--;
80+
}
81+
}
82+
6483
public function logQuery($query, Collection $backtrace)
6584
{
85+
if ($this->disabledDepth > 0) {
86+
return;
87+
}
88+
6689
$modelTrace = $backtrace->first(function ($trace) {
6790
return Arr::get($trace, 'object') instanceof Builder;
6891
});

tests/QueryDetectorTest.php

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,115 @@ public function it_empty_queries()
359359
$queries = $queryDetector->getDetectedQueries();
360360
$this->assertCount(0, $queries);
361361
}
362+
363+
/** @test */
364+
public function it_suppresses_n1_detection_with_without_detection()
365+
{
366+
Route::get('/', function () {
367+
app(QueryDetector::class)->withoutDetection(function () {
368+
$authors = Author::all();
369+
370+
foreach ($authors as $author) {
371+
$author->profile;
372+
}
373+
});
374+
});
375+
376+
$this->get('/');
377+
378+
$queries = app(QueryDetector::class)->getDetectedQueries();
379+
380+
$this->assertCount(0, $queries);
381+
}
382+
383+
/** @test */
384+
public function it_resumes_detection_after_without_detection()
385+
{
386+
Route::get('/', function () {
387+
$detector = app(QueryDetector::class);
388+
389+
$detector->withoutDetection(function () {
390+
foreach (Author::all() as $author) {
391+
$author->profile;
392+
}
393+
});
394+
395+
// This should still be detected
396+
foreach (Post::all() as $post) {
397+
$post->comments;
398+
}
399+
});
400+
401+
$this->get('/');
402+
403+
$queries = app(QueryDetector::class)->getDetectedQueries();
404+
405+
$this->assertCount(1, $queries);
406+
$this->assertSame(Post::class, $queries[0]['model']);
407+
$this->assertSame('comments', $queries[0]['relation']);
408+
}
409+
410+
/** @test */
411+
public function it_returns_closure_value_from_without_detection()
412+
{
413+
$result = app(QueryDetector::class)->withoutDetection(function () {
414+
return 'hello';
415+
});
416+
417+
$this->assertSame('hello', $result);
418+
}
419+
420+
/** @test */
421+
public function it_resumes_detection_even_if_closure_throws()
422+
{
423+
$detector = app(QueryDetector::class);
424+
425+
try {
426+
$detector->withoutDetection(function () {
427+
throw new \RuntimeException('test');
428+
});
429+
} catch (\RuntimeException $e) {
430+
// expected
431+
}
432+
433+
Route::get('/', function () {
434+
foreach (Author::all() as $author) {
435+
$author->profile;
436+
}
437+
});
438+
439+
$this->get('/');
440+
441+
$queries = $detector->getDetectedQueries();
442+
443+
$this->assertCount(1, $queries);
444+
}
445+
446+
/** @test */
447+
public function it_supports_nested_without_detection_calls()
448+
{
449+
Route::get('/', function () {
450+
$detector = app(QueryDetector::class);
451+
452+
$detector->withoutDetection(function () use ($detector) {
453+
// Inner nested call
454+
$detector->withoutDetection(function () {
455+
foreach (Author::all() as $author) {
456+
$author->profile;
457+
}
458+
});
459+
460+
// After inner call returns, outer should still be suppressed
461+
foreach (Post::all() as $post) {
462+
$post->comments;
463+
}
464+
});
465+
});
466+
467+
$this->get('/');
468+
469+
$queries = app(QueryDetector::class)->getDetectedQueries();
470+
471+
$this->assertCount(0, $queries);
472+
}
362473
}

0 commit comments

Comments
 (0)