Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
27 changes: 27 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
25 changes: 24 additions & 1 deletion src/QueryDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class QueryDetector
*/
private $booted = false;

/** @var bool */
private $disabled = false;

private function resetQueries()
{
$this->queries = Collection::make();
Expand All @@ -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);
});
Expand All @@ -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;
});
Expand Down
83 changes: 83 additions & 0 deletions tests/QueryDetectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}