Skip to content

Commit 4a63d20

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

4 files changed

Lines changed: 175 additions & 1 deletion

File tree

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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ return [
3333
*/
3434
'threshold' => (int) env('QUERY_DETECTOR_THRESHOLD', 1),
3535

36+
/*
37+
* The depth limit for debug_backtrace(). Higher values provide more
38+
* complete stack traces but use more memory. Set to 0 for unlimited.
39+
*/
40+
'backtrace_limit' => (int) env('QUERY_DETECTOR_BACKTRACE_LIMIT', 50),
41+
3642
/*
3743
* Here you can whitelist model relations.
3844
*
@@ -89,4 +95,31 @@ If you use **Lumen**, you need to copy the config file manually and register the
8995
$app->register(\BeyondCode\QueryDetector\LumenQueryDetectorServiceProvider::class);
9096
```
9197

98+
## Suppressing Detection
99+
100+
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.
101+
102+
```php
103+
app(\BeyondCode\QueryDetector\QueryDetector::class)->withoutDetection(function () {
104+
// N+1 queries here will not be reported
105+
$authors = Author::all();
106+
107+
foreach ($authors as $author) {
108+
$author->posts;
109+
}
110+
});
111+
```
112+
113+
The closure's return value is passed through, so you can use it inline:
114+
115+
```php
116+
$authors = app(\BeyondCode\QueryDetector\QueryDetector::class)->withoutDetection(function () {
117+
return Author::all()->each(fn ($author) => $author->posts);
118+
});
119+
```
120+
121+
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.
122+
123+
## Events
124+
92125
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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,116 @@ 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+
Route::get('/', function () {
424+
$detector = app(QueryDetector::class);
425+
426+
try {
427+
$detector->withoutDetection(function () {
428+
throw new \RuntimeException('test');
429+
});
430+
} catch (\RuntimeException $e) {
431+
// expected
432+
}
433+
434+
// Detection should be active again
435+
foreach (Author::all() as $author) {
436+
$author->profile;
437+
}
438+
});
439+
440+
$this->get('/');
441+
442+
$queries = app(QueryDetector::class)->getDetectedQueries();
443+
444+
$this->assertCount(1, $queries);
445+
}
446+
447+
/** @test */
448+
public function it_supports_nested_without_detection_calls()
449+
{
450+
Route::get('/', function () {
451+
$detector = app(QueryDetector::class);
452+
453+
$detector->withoutDetection(function () use ($detector) {
454+
// Inner nested call
455+
$detector->withoutDetection(function () {
456+
foreach (Author::all() as $author) {
457+
$author->profile;
458+
}
459+
});
460+
461+
// After inner call returns, outer should still be suppressed
462+
foreach (Post::all() as $post) {
463+
$post->comments;
464+
}
465+
});
466+
});
467+
468+
$this->get('/');
469+
470+
$queries = app(QueryDetector::class)->getDetectedQueries();
471+
472+
$this->assertCount(0, $queries);
473+
}
362474
}

0 commit comments

Comments
 (0)