diff --git a/src/Ray.php b/src/Ray.php index 7f4ac17..b161c69 100644 --- a/src/Ray.php +++ b/src/Ray.php @@ -26,14 +26,19 @@ use Spatie\LaravelRay\Payloads\ResponsePayload; use Spatie\LaravelRay\Payloads\ViewPayload; use Spatie\LaravelRay\Watchers\CacheWatcher; +use Spatie\LaravelRay\Watchers\ConditionalQueryWatcher; +use Spatie\LaravelRay\Watchers\DeleteQueryWatcher; use Spatie\LaravelRay\Watchers\DuplicateQueryWatcher; use Spatie\LaravelRay\Watchers\EventWatcher; use Spatie\LaravelRay\Watchers\ExceptionWatcher; use Spatie\LaravelRay\Watchers\HttpClientWatcher; +use Spatie\LaravelRay\Watchers\InsertQueryWatcher; use Spatie\LaravelRay\Watchers\JobWatcher; use Spatie\LaravelRay\Watchers\QueryWatcher; use Spatie\LaravelRay\Watchers\RequestWatcher; +use Spatie\LaravelRay\Watchers\SelectQueryWatcher; use Spatie\LaravelRay\Watchers\SlowQueryWatcher; +use Spatie\LaravelRay\Watchers\UpdateQueryWatcher; use Spatie\LaravelRay\Watchers\ViewWatcher; use Spatie\LaravelRay\Watchers\Watcher; use Spatie\Ray\Client; @@ -433,6 +438,76 @@ public function stopShowingDuplicateQueries(): self return $this; } + public function showConditionalQueries(Closure $condition, $callable = null, $name = 'default') + { + $watcher = ConditionalQueryWatcher::buildWatcherForName($condition, $name); + + return $this->handleWatcherCallable($watcher, $callable); + } + + public function stopShowingConditionalQueries($name = 'default'): self + { + app(ConditionalQueryWatcher::abstractName($name))->disable(); + + return $this; + } + + public function showUpdateQueries($callable = null) + { + $watcher = app(UpdateQueryWatcher::class); + + return $this->handleWatcherCallable($watcher, $callable); + } + + public function stopShowingUpdateQueries(): self + { + app(UpdateQueryWatcher::class)->disable(); + + return $this; + } + + public function showDeleteQueries($callable = null) + { + $watcher = app(DeleteQueryWatcher::class); + + return $this->handleWatcherCallable($watcher, $callable); + } + + public function stopShowingDeleteQueries(): self + { + app(DeleteQueryWatcher::class)->disable(); + + return $this; + } + + public function showInsertQueries($callable = null) + { + $watcher = app(InsertQueryWatcher::class); + + return $this->handleWatcherCallable($watcher, $callable); + } + + public function stopShowingInsertQueries(): self + { + app(InsertQueryWatcher::class)->disable(); + + return $this; + } + + public function showSelectQueries($callable = null) + { + $watcher = app(SelectQueryWatcher::class); + + return $this->handleWatcherCallable($watcher, $callable); + } + + public function stopShowingSelectQueries(): self + { + app(SelectQueryWatcher::class)->disable(); + + return $this; + } + /** * @param null $callable * diff --git a/src/RayServiceProvider.php b/src/RayServiceProvider.php index 489185c..5db6d62 100644 --- a/src/RayServiceProvider.php +++ b/src/RayServiceProvider.php @@ -18,17 +18,21 @@ use Spatie\LaravelRay\Payloads\QueryPayload; use Spatie\LaravelRay\Watchers\ApplicationLogWatcher; use Spatie\LaravelRay\Watchers\CacheWatcher; +use Spatie\LaravelRay\Watchers\DeleteQueryWatcher; use Spatie\LaravelRay\Watchers\DeprecatedNoticeWatcher; use Spatie\LaravelRay\Watchers\DumpWatcher; use Spatie\LaravelRay\Watchers\DuplicateQueryWatcher; use Spatie\LaravelRay\Watchers\EventWatcher; use Spatie\LaravelRay\Watchers\ExceptionWatcher; use Spatie\LaravelRay\Watchers\HttpClientWatcher; +use Spatie\LaravelRay\Watchers\InsertQueryWatcher; use Spatie\LaravelRay\Watchers\JobWatcher; use Spatie\LaravelRay\Watchers\LoggedMailWatcher; use Spatie\LaravelRay\Watchers\QueryWatcher; use Spatie\LaravelRay\Watchers\RequestWatcher; +use Spatie\LaravelRay\Watchers\SelectQueryWatcher; use Spatie\LaravelRay\Watchers\SlowQueryWatcher; +use Spatie\LaravelRay\Watchers\UpdateQueryWatcher; use Spatie\LaravelRay\Watchers\ViewWatcher; use Spatie\Ray\Client; use Spatie\Ray\PayloadFactory; @@ -79,6 +83,10 @@ protected function registerSettings(): self 'send_queries_to_ray' => env('SEND_QUERIES_TO_RAY', false), 'send_duplicate_queries_to_ray' => env('SEND_DUPLICATE_QUERIES_TO_RAY', false), 'send_slow_queries_to_ray' => env('SEND_SLOW_QUERIES_TO_RAY', false), + 'send_update_queries_to_ray' => env('SEND_UPDATE_QUERIES_TO_RAY', false), + 'send_insert_queries_to_ray' => env('SEND_INSERT_QUERIES_TO_RAY', false), + 'send_delete_queries_to_ray' => env('SEND_DELETE_QUERIES_TO_RAY', false), + 'send_select_queries_to_ray' => env('SEND_SELECT_QUERIES_TO_RAY', false), 'send_requests_to_ray' => env('SEND_REQUESTS_TO_RAY', false), 'send_http_client_requests_to_ray' => env('SEND_HTTP_CLIENT_REQUESTS_TO_RAY', false), 'send_views_to_ray' => env('SEND_VIEWS_TO_RAY', false), @@ -142,6 +150,10 @@ protected function registerWatchers(): self QueryWatcher::class, DuplicateQueryWatcher::class, SlowQueryWatcher::class, + InsertQueryWatcher::class, + SelectQueryWatcher::class, + UpdateQueryWatcher::class, + DeleteQueryWatcher::class, ViewWatcher::class, CacheWatcher::class, RequestWatcher::class, @@ -169,6 +181,10 @@ protected function bootWatchers(): self QueryWatcher::class, DuplicateQueryWatcher::class, SlowQueryWatcher::class, + InsertQueryWatcher::class, + SelectQueryWatcher::class, + UpdateQueryWatcher::class, + DeleteQueryWatcher::class, ViewWatcher::class, CacheWatcher::class, RequestWatcher::class, diff --git a/src/Watchers/ConditionalQueryWatcher.php b/src/Watchers/ConditionalQueryWatcher.php new file mode 100644 index 0000000..cdc698a --- /dev/null +++ b/src/Watchers/ConditionalQueryWatcher.php @@ -0,0 +1,63 @@ +setConditionalCallback($condition); + + return app()->instance(static::abstractName($name), $watcher); + } + + public static function abstractName(string $name) + { + return static::class.':'.$name; + } + + public function setConditionalCallback($conditionalCallback) + { + $this->conditionalCallback = $conditionalCallback; + + $this->listen(); + } + + public function register(): void + { + throw new BadMethodCallException('ConditionalQueryWatcher cannot be registered. Only its child classes.'); + } + + public function listen(): void + { + Event::listen(QueryExecuted::class, function (QueryExecuted $query) { + if (! $this->enabled()) { + return; + } + + if (! $this->conditionalCallback) { + return; + } + + $ray = app(Ray::class); + + if (($this->conditionalCallback)($query)) { + $payload = new ExecutedQueryPayload($query); + + $ray->sendRequest($payload); + } + + optional($this->rayProxy)->applyCalledMethods($ray); + }); + } +} diff --git a/src/Watchers/DeleteQueryWatcher.php b/src/Watchers/DeleteQueryWatcher.php new file mode 100644 index 0000000..000cbdb --- /dev/null +++ b/src/Watchers/DeleteQueryWatcher.php @@ -0,0 +1,21 @@ +enabled = $settings->send_delete_queries_to_ray ?? false; + + $this->setConditionalCallback(function (QueryExecuted $query) { + return Str::startsWith(strtolower($query->sql), 'delete'); + }); + } +} diff --git a/src/Watchers/InsertQueryWatcher.php b/src/Watchers/InsertQueryWatcher.php new file mode 100644 index 0000000..ac581d1 --- /dev/null +++ b/src/Watchers/InsertQueryWatcher.php @@ -0,0 +1,21 @@ +enabled = $settings->send_insert_queries_to_ray ?? false; + + $this->setConditionalCallback(function (QueryExecuted $query) { + return Str::startsWith(strtolower($query->sql), 'insert'); + }); + } +} diff --git a/src/Watchers/SelectQueryWatcher.php b/src/Watchers/SelectQueryWatcher.php new file mode 100644 index 0000000..2794c45 --- /dev/null +++ b/src/Watchers/SelectQueryWatcher.php @@ -0,0 +1,21 @@ +enabled = $settings->send_select_queries_to_ray ?? false; + + $this->setConditionalCallback(function (QueryExecuted $query) { + return Str::startsWith(strtolower($query->sql), 'select'); + }); + } +} diff --git a/src/Watchers/SlowQueryWatcher.php b/src/Watchers/SlowQueryWatcher.php index ce0f880..a5727d6 100644 --- a/src/Watchers/SlowQueryWatcher.php +++ b/src/Watchers/SlowQueryWatcher.php @@ -3,12 +3,9 @@ namespace Spatie\LaravelRay\Watchers; use Illuminate\Database\Events\QueryExecuted; -use Illuminate\Support\Facades\Event; -use Spatie\LaravelRay\Payloads\ExecutedQueryPayload; -use Spatie\LaravelRay\Ray; use Spatie\Ray\Settings\Settings; -class SlowQueryWatcher extends QueryWatcher +class SlowQueryWatcher extends ConditionalQueryWatcher { protected $minimumTimeInMs = 500; @@ -19,20 +16,8 @@ public function register(): void $this->enabled = $settings->send_slow_queries_to_ray ?? false; $this->minimumTimeInMs = $settings->slow_query_threshold_in_ms ?? $this->minimumTimeInMs; - Event::listen(QueryExecuted::class, function (QueryExecuted $query) { - if (! $this->enabled()) { - return; - } - - $ray = app(Ray::class); - - if ($query->time >= $this->minimumTimeInMs) { - $payload = new ExecutedQueryPayload($query); - - $ray->sendRequest($payload); - } - - optional($this->rayProxy)->applyCalledMethods($ray); + $this->setConditionalCallback(function (QueryExecuted $query) { + return $query->time >= $this->minimumTimeInMs; }); } diff --git a/src/Watchers/UpdateQueryWatcher.php b/src/Watchers/UpdateQueryWatcher.php new file mode 100644 index 0000000..f884434 --- /dev/null +++ b/src/Watchers/UpdateQueryWatcher.php @@ -0,0 +1,21 @@ +enabled = $settings->send_update_queries_to_ray ?? false; + + $this->setConditionalCallback(function (QueryExecuted $query) { + return Str::startsWith(strtolower($query->sql), 'update'); + }); + } +} diff --git a/stub/ray.php b/stub/ray.php index ce7a33b..e45f69a 100644 --- a/stub/ray.php +++ b/stub/ray.php @@ -50,6 +50,26 @@ */ 'slow_query_threshold_in_ms' => env('RAY_SLOW_QUERY_THRESHOLD_IN_MS', 500), + /* + * When enabled, all update queries will automatically be sent to Ray. + */ + 'send_update_queries_to_ray' => env('SEND_UPDATE_QUERIES_TO_RAY', false), + + /* + * When enabled, all insert queries will automatically be sent to Ray. + */ + 'send_insert_queries_to_ray' => env('SEND_INSERT_QUERIES_TO_RAY', false), + + /* + * When enabled, all delete queries will automatically be sent to Ray. + */ + 'send_delete_queries_to_ray' => env('SEND_DELETE_QUERIES_TO_RAY', false), + + /* + * When enabled, all select queries will automatically be sent to Ray. + */ + 'send_select_queries_to_ray' => env('SEND_SELECT_QUERIES_TO_RAY', false), + /* * When enabled, all requests made to this app will automatically be sent to Ray. */ diff --git a/tests/TestCase.php b/tests/TestCase.php index 34eafd9..d4f258a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,13 @@ namespace Spatie\LaravelRay\Tests; +use Illuminate\Database\Events\QueryExecuted; +use Illuminate\Database\Query\Builder; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\View; +use Illuminate\Support\Str; use Orchestra\Testbench\TestCase as Orchestra; use Spatie\LaravelRay\Ray; use Spatie\LaravelRay\RayServiceProvider; @@ -70,4 +74,13 @@ protected function useRealUuid() return Ray::create($this->client); }); } + + protected function assertSqlContains($queryContent, $needle): void + { + $sql = method_exists(Builder::class, 'toRawSql') + ? $queryContent['sql'] + : Str::replaceArray('?', $queryContent['bindings'], $queryContent['sql']); + + $this->assertStringContainsString($needle, $sql); + } } diff --git a/tests/Unit/ConditionalQueryTest.php b/tests/Unit/ConditionalQueryTest.php new file mode 100644 index 0000000..06f6a8b --- /dev/null +++ b/tests/Unit/ConditionalQueryTest.php @@ -0,0 +1,138 @@ +showUpdateQueries(function (): User { + $user = User::query()->create(['email' => 'john@example.com']); + $user->update(['email' => 'joan@example.com']); + + return $user; + }); + + expect($this->client->sentRequests())->toHaveCount(1); + expect($user)->toBeInstanceOf(User::class); + + $this->assertSame('joan@example.com', $user->email); +}); + +it('can show only type queries', function (string $rayShowMethod, string $rayStopMethod, string $sqlCommand) { + ray()->$rayShowMethod(); + + // Run all query types + $user = User::query()->firstOrCreate(['email' => 'john@example.com']); + $user->update(['email' => 'joan@example.com']); + $user->delete(); + + expect($this->client->sentPayloads())->toHaveCount(1); + + // Assert the one we want is picked up. + $payload = $this->client->sentPayloads(); + $this->assertSqlContains(Arr::get($payload, '0.content'), $sqlCommand); + + ray()->$rayStopMethod(); + + // Assert that watcher has stopped. + $user = User::query()->firstOrCreate(['email' => 'sam@example.com']); + $user->update(['email' => 'sarah@example.com']); + $user->delete(); + + expect($this->client->sentPayloads())->toHaveCount(1); +})->with([ + 'update' => ['showUpdateQueries', 'stopShowingUpdateQueries', 'update'], + 'delete' => ['showDeleteQueries', 'stopShowingDeleteQueries', 'delete'], + 'insert' => ['showInsertQueries', 'stopShowingInsertQueries', 'insert'], + 'select' => ['showSelectQueries', 'stopShowingSelectQueries', 'select'], +]); + +it('can take a custom condition and only return those queries', function () { + ray()->showConditionalQueries(function (QueryExecuted $query) { + return Arr::first($query->bindings, fn ($binding) => Str::contains($binding, 'joan')); + }); + + User::query()->create(['email' => 'joan@example.com']); + User::query()->create(['email' => 'john@example.com']); + + expect($this->client->sentPayloads())->toHaveCount(1); + + $payload = $this->client->sentPayloads(); + $this->assertSqlContains(Arr::get($payload, '0.content'), 'joan@example.com'); + + ray()->stopShowingConditionalQueries(); + + User::query()->create(['email' => 'joanne@example.com']); + + expect($this->client->sentPayloads())->toHaveCount(1); +}); + +it('can handle multiple conditional query watchers', function () { + $john = ray()->showConditionalQueries( + function (QueryExecuted $query) { + return Arr::first($query->bindings, fn ($binding) => Str::contains($binding, 'joan')); + }, + function (): User { + ray()->showConditionalQueries( + function (QueryExecuted $query) { + return Arr::first($query->bindings, fn ($binding) => Str::contains($binding, 'john')); + }, + null, + 'look for john' + ); + + User::query()->create(['email' => 'joan@example.com']); + User::query()->create(['email' => 'joe@example.com']); + + return User::query()->create(['email' => 'john@example.com']); + }, + 'look for joan' + ); + + // Assert that john was handed back + $this->assertSame('john@example.com', $john->email); + + // Assert that ray only received what we wanted + expect($this->client->sentPayloads())->toHaveCount(2); + + $payload = $this->client->sentPayloads(); + + // Assert that ray received the correct order + $this->assertSqlContains(Arr::get($payload, '0.content'), 'joan@example.com'); + $this->assertSqlContains(Arr::get($payload, '1.content'), 'john@example.com'); + + // Looking for joan has been disabled so this should not be sent + $joan = User::query()->where('email', 'joan@example.com')->first(); + expect($this->client->sentPayloads())->toHaveCount(2); + + // Looking for john is still enabled so this should be sent + $john->update(['email' => 'john@adifferentdomain.com']); + expect($this->client->sentPayloads())->toHaveCount(3); + + ray()->stopShowingConditionalQueries('look for john'); + + // Looking for john has been disabled so this should not be sent + $joan->update(['email' => 'iamjohnnow@example.com']); + expect($this->client->sentPayloads())->toHaveCount(3); +}); + +it('can start watching from config only', function () { + app(Settings::class)->send_select_queries_to_ray = true; + + // Refresh the watcher and register again to pick up settings change + $this->app->singleton(SelectQueryWatcher::class); + app(SelectQueryWatcher::class)->register(); + + // Run all query types + $user = User::query()->firstOrCreate(['email' => 'john@example.com']); + $user->update(['email' => 'joan@example.com']); + $user->delete(); + + expect($this->client->sentPayloads())->toHaveCount(1); + + $payload = $this->client->sentPayloads(); + $this->assertSqlContains(Arr::get($payload, '0.content'), 'select'); +});