diff --git a/documentation/components/bridges/symfony-postgresql-bundle.md b/documentation/components/bridges/symfony-postgresql-bundle.md index 6f6987ec45..b126a48b26 100644 --- a/documentation/components/bridges/symfony-postgresql-bundle.md +++ b/documentation/components/bridges/symfony-postgresql-bundle.md @@ -135,9 +135,22 @@ flow_postgresql: profiler: enabled: ~ include_parameters: true # show bound query parameters in the panel + max_queries: 1000 # retained queries; oldest dropped first + max_retained_parameters: 100 # queries binding more than this keep no parameters + max_query_length: 1000 # retained statements longer than this are truncated migrations: true # show the Flow Migrations panel (requires migrations enabled) ``` +The log is bounded: at most `max_queries` entries are retained and the panel notes when older ones were +dropped. Because bound parameters dominate memory, a query binding more than `max_retained_parameters` +values keeps none of them — such queries show "parameters omitted" and cannot be explained from the panel. +The all-or-nothing rule is deliberate, and the reason this option is not named like the per-connection +`telemetry.max_parameters` (which keeps the first N): the panel re-binds the retained parameters to run +EXPLAIN, and a partial list is not a valid query. Statements themselves are bounded by +`max_query_length` — a batch `INSERT` with a thousand `VALUES` tuples is over 100 KB of SQL, so retaining +`max_queries` of them uncut would cost hundreds of megabytes. Truncated statements are marked in the panel +and cannot be explained from it. + Recording is dev-only and adds nothing in production: when the profiler is disabled — or WebProfilerBundle is absent in `enabled: ~` mode — no connection is decorated. A single connection can opt out while the profiler is on: @@ -150,6 +163,11 @@ flow_postgresql: profiler: false # do not record queries in selected connection (default: true) ``` +Recording follows `kernel.debug`, like DoctrineBundle's `dbal.profiling`. With `enabled: ~` the panel is +wired only when WebProfilerBundle is registered **and** the kernel runs in debug mode, so +`bin/console --no-debug` — which compiles a separate container — decorates no connection and records +nothing. `enabled: true` forces recording on regardless of debug mode. + When `migrations` are enabled, a separate **Flow Migrations** panel reports the migrations connection's executed, pending and unavailable migrations (with execution time) — like the Doctrine Migrations bundle's panel. It queries the database on every profiled request; set `profiler.migrations: false` diff --git a/documentation/upgrading.md b/documentation/upgrading.md index 065d601808..60eb72d493 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -112,6 +112,21 @@ rank()->over(window()->partitionBy(ref('dept'))->orderBy(ref('salary')->desc())) Implementations must no longer sort; `$window->partition()` and `$window->frame()` are already ordered. +### 7) `flow-php/postgresql` - query recording moved to `flow-php/symfony-postgresql-bundle` + +| Before | After | +|--------------------------------------------------|----------------------------------------------------------------------| +| `Flow\PostgreSql\Client\Debug\RecordingClient` | `Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\ProfilerClient` | +| `Flow\PostgreSql\Client\Debug\QueryLog` | `Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\QueryRecorder` | +| `Flow\PostgreSql\Client\Debug\QueryLogOptions` | `Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\QueryRecorderOptions` | +| `Flow\PostgreSql\Client\Debug\RecordedQuery` | `Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\RecordedQuery` | +| `QueryLogOptions::$maxParameters` | `QueryRecorderOptions::$maxRetainedParameters` | +| `QueryLogOptions::maxParameters()` | `QueryRecorderOptions::maxRetainedParameters()` | +| service `flow.postgresql.profiler.query_log` | `flow.postgresql.profiler.query_recorder` | +| config `flow_postgresql.profiler.max_parameters` | `flow_postgresql.profiler.max_retained_parameters` | + +Applies to `flow-php/postgresql` users only through the bundle; `Client\Telemetry` is unchanged. + --- ## Upgrading from 0.41.x to 0.42.x diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php index 92f22414c6..1bda96c44f 100644 --- a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php @@ -13,6 +13,7 @@ use Flow\Bridge\Symfony\PostgreSqlBundle\DependencyInjection\Compiler\CommandLocatorPass; use Flow\Bridge\Symfony\PostgreSqlBundle\Generator\TwigMigrationGenerator; use Flow\Bridge\Symfony\PostgreSqlBundle\Messenger\FlowPostgreSqlTransportFactory; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\ProfilerClient; use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\ProfilerController; use Flow\Bridge\Symfony\PostgreSqlBundle\Repository\FilesystemMigrationRepository; use Flow\Bridge\Symfony\PostgreSQLCache\CacheCatalogProvider; @@ -25,7 +26,6 @@ use Flow\PostgreSql\Client\Client; use Flow\PostgreSql\Client\ConnectionParameters; use Flow\PostgreSql\Client\Context; -use Flow\PostgreSql\Client\Debug\RecordingClient; use Flow\PostgreSql\Client\DsnParser; use Flow\PostgreSql\Client\Infrastructure\PgSql\PgSqlClient; use Flow\PostgreSql\Client\Telemetry\PostgreSqlTelemetryConfig; @@ -477,7 +477,7 @@ public function configure(DefinitionConfigurator $definition): void ->addDefaultsIfNotSet() ->children() ->variableNode('enabled') - ->info('null (default) = auto-enable iff WebProfilerBundle is registered; true/false to force') + ->info('null (default) = auto-enable iff WebProfilerBundle is registered and the kernel runs in debug mode (so bin/console --no-debug records nothing); true/false to force') ->defaultNull() ->validate() ->ifTrue(static fn (mixed $v): bool => $v !== null && !is_bool($v)) @@ -488,6 +488,21 @@ public function configure(DefinitionConfigurator $definition): void ->info('Show bound query parameters in the panel') ->defaultTrue() ->end() + ->integerNode('max_queries') + ->info('Maximum number of recorded queries retained for the panel; oldest are dropped first') + ->defaultValue(1000) + ->min(1) + ->end() + ->integerNode('max_query_length') + ->info('Retained statements longer than this are truncated; truncated statements cannot be explained from the panel') + ->defaultValue(1000) + ->min(1) + ->end() + ->integerNode('max_retained_parameters') + ->info('Queries binding more parameters than this keep none of them, to bound profiler memory. Unlike connections..telemetry.max_parameters, which keeps the first N, this is all-or-nothing: the panel re-binds the retained list to run EXPLAIN, and a partial list is not a valid query.') + ->defaultValue(100) + ->min(0) + ->end() ->booleanNode('migrations') ->info('Show the Flow Migrations panel (executed/pending/unavailable status). Queries the database on every profiled request; set false to disable. Requires migrations to be enabled.') ->defaultTrue() @@ -498,7 +513,7 @@ public function configure(DefinitionConfigurator $definition): void } /** - * @param array{connections: array, telemetry?: array{service_id: string, clock_service_id: ?string, trace_queries: bool, transaction_spans: 'grouped'|'per_operation'|'off', collect_metrics: bool, log_queries: bool, max_query_length: int, include_parameters: bool, max_parameters: int, max_parameter_length: int}, profiler?: bool}>, messenger: array{enabled: bool, table_name: string, schema: string}, cache: array{pools?: array}, session: array{enabled: bool, connection: ?string, table_name: string, schema: string, id_col: string, data_col: string, lifetime_col: string, time_col: string, lock_mode: string, ttl: ?int, share_connection: bool}, migrations: array{enabled: bool, connection: ?string, directory: string, namespace: string, table_name: string, table_schema: string, migration_file_name: string, rollback_file_name: string, all_or_nothing: bool, generate_rollback: bool, drop_if_exists: bool, context?: array, exclude?: list}, catalog_providers: list}>, profiler?: array{enabled?: bool|null, include_parameters?: bool, migrations?: bool}} $config + * @param array{connections: array, telemetry?: array{service_id: string, clock_service_id: ?string, trace_queries: bool, transaction_spans: 'grouped'|'per_operation'|'off', collect_metrics: bool, log_queries: bool, max_query_length: int, include_parameters: bool, max_parameters: int, max_parameter_length: int}, profiler?: bool}>, messenger: array{enabled: bool, table_name: string, schema: string}, cache: array{pools?: array}, session: array{enabled: bool, connection: ?string, table_name: string, schema: string, id_col: string, data_col: string, lifetime_col: string, time_col: string, lock_mode: string, ttl: ?int, share_connection: bool}, migrations: array{enabled: bool, connection: ?string, directory: string, namespace: string, table_name: string, table_schema: string, migration_file_name: string, rollback_file_name: string, all_or_nothing: bool, generate_rollback: bool, drop_if_exists: bool, context?: array, exclude?: list}, catalog_providers: list}>, profiler?: array{enabled?: bool|null, include_parameters?: bool, max_queries?: int, max_retained_parameters?: int, max_query_length?: int, migrations?: bool}} $config */ #[Override] public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void @@ -568,7 +583,7 @@ private function registerMigrationsProfiler( $hasWebProfiler = $this->isWebProfilerBundleRegistered($container); - if ($enabled === null && !$hasWebProfiler) { + if ($enabled === null && (!$hasWebProfiler || !$this->isDebug($container))) { return; } @@ -602,11 +617,22 @@ private function registerProfiler( ); } - if ($enabled === null && !$hasWebProfiler) { + // Recording follows kernel.debug like DoctrineBundle's dbal profiling, so "bin/console --no-debug" + // (a different compiled container) does not decorate any connection. + if ($enabled === null && (!$hasWebProfiler || !$this->isDebug($container))) { return; } $container->setParameter('flow.postgresql.profiler.include_parameters', $includeParameters); + $container->setParameter('flow.postgresql.profiler.max_queries', (int) ($profilerConfig['max_queries'] ?? 1000)); + $container->setParameter( + 'flow.postgresql.profiler.max_retained_parameters', + (int) ($profilerConfig['max_retained_parameters'] ?? 100), + ); + $container->setParameter( + 'flow.postgresql.profiler.max_query_length', + (int) ($profilerConfig['max_query_length'] ?? 1000), + ); $configurator->import(__DIR__ . '/Resources/config/profiler.php'); $connections = is_array($config['connections'] ?? null) ? $config['connections'] : []; @@ -619,9 +645,9 @@ private function registerProfiler( continue; } - $recordingDefinition = new Definition(RecordingClient::class, [ + $recordingDefinition = new Definition(ProfilerClient::class, [ new Reference("flow.postgresql.{$name}.client.profiler.inner"), - new Reference('flow.postgresql.profiler.query_log'), + new Reference('flow.postgresql.profiler.query_recorder'), $name, ]); $recordingDefinition->setDecoratedService("flow.postgresql.{$name}.client", null, 10); @@ -644,6 +670,11 @@ private function registerProfiler( $container->setDefinition(ProfilerController::class, $controller); } + private function isDebug(ContainerBuilder $container): bool + { + return $container->hasParameter('kernel.debug') && $container->getParameter('kernel.debug') === true; + } + private function isWebProfilerBundleRegistered(ContainerBuilder $container): bool { if (!$container->hasParameter('kernel.bundles')) { diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/FlowPostgreSqlDataCollector.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/FlowPostgreSqlDataCollector.php index aade6963eb..f455fad0be 100644 --- a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/FlowPostgreSqlDataCollector.php +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/FlowPostgreSqlDataCollector.php @@ -4,8 +4,6 @@ namespace Flow\Bridge\Symfony\PostgreSqlBundle\Profiler; -use Flow\PostgreSql\Client\Debug\QueryLog; -use Flow\PostgreSql\Client\Debug\RecordedQuery; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; @@ -21,22 +19,21 @@ use function strtoupper; /** - * @phpstan-type QueryRow array{statement: string, parameters: array, returnedRows: null|int, durationMs: float, failed: bool, error: null|string, connection: string, caller: null|string, explainable: bool, runCount: int, isDuplicate: bool} + * @phpstan-type QueryRow array{statement: string, parameters: array, returnedRows: null|int, durationMs: float, failed: bool, error: null|string, connection: string, caller: null|string, explainable: bool, runCount: int, isDuplicate: bool, parametersTruncated: bool, statementTruncated: bool} */ final class FlowPostgreSqlDataCollector extends DataCollector implements LateDataCollectorInterface { private const array EXPLAINABLE_KEYWORDS = ['SELECT', 'WITH', 'INSERT', 'UPDATE', 'DELETE', 'VALUES', 'TABLE']; public function __construct( - private readonly QueryLog $queryLog, - private readonly bool $includeParameters, + private readonly QueryRecorder $recorder, ) {} public function collect(Request $request, Response $response, ?Throwable $exception = null): void {} public function lateCollect(): void { - $queries = $this->queryLog->queries(); + $queries = $this->recorder->queries(); $runCounts = []; @@ -45,44 +42,40 @@ public function lateCollect(): void } $byConnection = []; - $failedCount = 0; - $totalDurationMs = 0.0; foreach ($queries as $query) { - $totalDurationMs += $query->durationMs; - - if ($query->failed) { - $failedCount++; - } - $byConnection[$query->connection][] = [ 'statement' => $query->sql, - 'parameters' => $this->includeParameters ? $query->parameters : [], + 'parameters' => $query->parameters, 'returnedRows' => $query->rowCount, 'durationMs' => $query->durationMs, 'failed' => $query->failed, 'error' => $query->error, 'connection' => $query->connection, 'caller' => $query->caller, - 'explainable' => $this->isExplainable($query), + 'explainable' => + $this->isExplainable($query) && !$query->parametersTruncated && !$query->statementTruncated, 'runCount' => $runCounts[$query->sql], 'isDuplicate' => $runCounts[$query->sql] > 1, + 'parametersTruncated' => $query->parametersTruncated, + 'statementTruncated' => $query->statementTruncated, ]; } $this->data = [ 'queries' => $byConnection, - 'queryCount' => count($queries), - 'failedCount' => $failedCount, - 'totalDurationMs' => $totalDurationMs, - 'duplicateCount' => count($queries) - count($runCounts), + 'queryCount' => $this->recorder->recordedCount(), + 'retainedCount' => $this->recorder->retainedCount(), + 'failedCount' => $this->recorder->failedCount(), + 'totalDurationMs' => $this->recorder->totalDurationMs(), + 'duplicateCount' => $this->recorder->retainedCount() - count($runCounts), ]; } public function reset(): void { $this->data = []; - $this->queryLog->reset(); + $this->recorder->reset(); } public function getName(): string @@ -112,6 +105,11 @@ public function getQueryCount(): int return (int) ($this->data['queryCount'] ?? 0); } + public function getRetainedCount(): int + { + return (int) ($this->data['retainedCount'] ?? 0); + } + public function getFailedCount(): int { return (int) ($this->data['failedCount'] ?? 0); diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/RecordingClient.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/ProfilerClient.php similarity index 90% rename from src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/RecordingClient.php rename to src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/ProfilerClient.php index 7cae7b3e62..c731a5db69 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/RecordingClient.php +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/ProfilerClient.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flow\PostgreSql\Client\Debug; +namespace Flow\Bridge\Symfony\PostgreSqlBundle\Profiler; use Flow\PostgreSql\AST\Transformers\ExplainConfig; use Flow\PostgreSql\Client\Client; @@ -25,11 +25,13 @@ use const DEBUG_BACKTRACE_IGNORE_ARGS; -final class RecordingClient implements Client +final class ProfilerClient implements Client { + private const array INSTRUMENTATION_NAMESPACES = ['Flow\\PostgreSql\\', __NAMESPACE__ . '\\']; + public function __construct( private readonly Client $client, - private readonly QueryLog $log, + private readonly QueryRecorder $recorder, private readonly string $connection = 'default', ) {} @@ -61,13 +63,13 @@ public function cursor(Sql|string $sql, array $parameters = []): Cursor try { $cursor = $this->client->cursor($sql, $parameters); } catch (Throwable $e) { - $this->log->add($this->failure($statement, $parameters, $start, $e)); + $this->recorder->add($this->failure($statement, $parameters, $start, $e)); throw $e; } // Cursors are lazy; record the statement without consuming rows. - $this->log->add( + $this->recorder->add( new RecordedQuery( $statement, $parameters, @@ -304,12 +306,12 @@ private function record( try { $result = $operation(); } catch (Throwable $e) { - $this->log->add($this->failure($statement, $parameters, $start, $e)); + $this->recorder->add($this->failure($statement, $parameters, $start, $e)); throw $e; } - $this->log->add( + $this->recorder->add( new RecordedQuery( $statement, $parameters, @@ -352,8 +354,8 @@ private function elapsedMs(int|float $start): float * * A backtrace frame's file/line is the *call site* (where the frame's function was called from), * while class/function is the callee. So the application caller is the file/line of the - * shallowest library frame — i.e. the last `Flow\PostgreSql\` frame before control crosses into - * application code. + * shallowest instrumentation frame — the last frame belonging to this decorator or to the + * PostgreSQL client itself before control crosses into application code. */ private function callerLocation(): ?string { @@ -368,7 +370,21 @@ private function callerLocation(): ?string // @mago-expect analysis:mixed-assignment $class = $frame['class'] ?? null; - if (!is_string($class) || !str_starts_with($class, 'Flow\\PostgreSql\\')) { + if (!is_string($class)) { + return $candidate; + } + + $instrumentation = false; + + foreach (self::INSTRUMENTATION_NAMESPACES as $namespace) { + if (str_starts_with($class, $namespace)) { + $instrumentation = true; + + break; + } + } + + if (!$instrumentation) { return $candidate; } diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/QueryRecorder.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/QueryRecorder.php new file mode 100644 index 0000000000..3cb494a3ba --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/QueryRecorder.php @@ -0,0 +1,97 @@ + + */ + private array $queries = []; + + private int $failed = 0; + + private int $recorded = 0; + + private float $totalDurationMs = 0.0; + + public function __construct( + private readonly QueryRecorderOptions $options = new QueryRecorderOptions(), + ) {} + + public function add(RecordedQuery $query): void + { + $this->recorded++; + $this->totalDurationMs += $query->durationMs; + + if ($query->failed) { + $this->failed++; + } + + $retainsParameters = $this->options->retainsParameters($query->parameters); + $retainsStatement = $this->options->retainsStatement($query->sql); + + $this->queries[] = $retainsParameters && $retainsStatement + ? $query + : new RecordedQuery( + $this->options->truncateStatement($query->sql), + $retainsParameters ? $query->parameters : [], + $query->durationMs, + $query->rowCount, + $query->failed, + $query->error, + $query->connection, + $query->caller, + !$retainsParameters, + !$retainsStatement, + ); + + if (count($this->queries) > $this->options->maxQueries) { + array_shift($this->queries); + } + } + + public function failedCount(): int + { + return $this->failed; + } + + /** + * @return list + */ + public function queries(): array + { + return $this->queries; + } + + /** + * Total queries passed to add(), including entries already evicted. + */ + public function recordedCount(): int + { + return $this->recorded; + } + + public function reset(): void + { + $this->queries = []; + $this->failed = 0; + $this->recorded = 0; + $this->totalDurationMs = 0.0; + } + + public function retainedCount(): int + { + return count($this->queries); + } + + public function totalDurationMs(): float + { + return $this->totalDurationMs; + } +} diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/QueryRecorderOptions.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/QueryRecorderOptions.php new file mode 100644 index 0000000000..dbc52d101c --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/QueryRecorderOptions.php @@ -0,0 +1,91 @@ +maxQueries, $include, $this->maxRetainedParameters, $this->maxQueryLength); + } + + public function maxQueries(int $max): self + { + return new self($max, $this->includeParameters, $this->maxRetainedParameters, $this->maxQueryLength); + } + + public function maxQueryLength(?int $max): self + { + return new self($this->maxQueries, $this->includeParameters, $this->maxRetainedParameters, $max); + } + + public function maxRetainedParameters(?int $max): self + { + return new self($this->maxQueries, $this->includeParameters, $max, $this->maxQueryLength); + } + + /** + * Parameters with nothing to drop count as retained, so a parameterless query is never + * reported as truncated. + * + * @param list $parameters + */ + public function retainsParameters(array $parameters): bool + { + if (!$this->includeParameters) { + return $parameters === []; + } + + if ($this->maxRetainedParameters === null) { + return true; + } + + return count($parameters) <= $this->maxRetainedParameters; + } + + public function retainsStatement(string $sql): bool + { + return $this->maxQueryLength === null || strlen($sql) <= $this->maxQueryLength; + } + + public function truncateStatement(string $sql): string + { + return $this->retainsStatement($sql) ? $sql : substr($sql, 0, (int) $this->maxQueryLength) . '...'; + } +} diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/RecordedQuery.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/RecordedQuery.php similarity index 67% rename from src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/RecordedQuery.php rename to src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/RecordedQuery.php index be8648d4c4..a926be7421 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/RecordedQuery.php +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/RecordedQuery.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flow\PostgreSql\Client\Debug; +namespace Flow\Bridge\Symfony\PostgreSqlBundle\Profiler; final readonly class RecordedQuery { @@ -11,6 +11,8 @@ * @param null|int $rowCount affected rows for writes, returned rows for reads, null when unknown (e.g. cursors) * @param string $connection name of the connection the query ran on * @param null|string $caller "file:line" of the first application frame that issued the query, null when undetected + * @param bool $parametersTruncated parameters were dropped to bound memory, as opposed to the query having none + * @param bool $statementTruncated the statement was cut to bound memory, so it is no longer runnable */ public function __construct( public string $sql, @@ -21,5 +23,7 @@ public function __construct( public ?string $error, public string $connection = 'default', public ?string $caller = null, + public bool $parametersTruncated = false, + public bool $statementTruncated = false, ) {} } diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/profiler.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/profiler.php index b70d69ebc8..d8b0a180e0 100644 --- a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/profiler.php +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/profiler.php @@ -3,20 +3,31 @@ declare(strict_types=1); use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\FlowPostgreSqlDataCollector; -use Flow\PostgreSql\Client\Debug\QueryLog; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\QueryRecorder; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\QueryRecorderOptions; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use function Symfony\Component\DependencyInjection\Loader\Configurator\inline_service; use function Symfony\Component\DependencyInjection\Loader\Configurator\param; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; return static function (ContainerConfigurator $container): void { $services = $container->services(); - $services->set('flow.postgresql.profiler.query_log', QueryLog::class)->public(); + $services + ->set('flow.postgresql.profiler.query_recorder', QueryRecorder::class) + ->args([ + inline_service(QueryRecorderOptions::class)->args([ + param('flow.postgresql.profiler.max_queries'), + param('flow.postgresql.profiler.include_parameters'), + param('flow.postgresql.profiler.max_retained_parameters'), + param('flow.postgresql.profiler.max_query_length'), + ]), + ]) + ->public(); $services->set('flow.postgresql.profiler.collector', FlowPostgreSqlDataCollector::class)->args([ - service('flow.postgresql.profiler.query_log'), - param('flow.postgresql.profiler.include_parameters'), + service('flow.postgresql.profiler.query_recorder'), ])->tag('data_collector', [ 'id' => 'flow_postgresql', 'template' => '@FlowPostgreSql/Collector/postgresql.html.twig', diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/views/Collector/postgresql.html.twig b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/views/Collector/postgresql.html.twig index 15de03c5a3..89807250b9 100644 --- a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/views/Collector/postgresql.html.twig +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/views/Collector/postgresql.html.twig @@ -92,6 +92,14 @@ + {% if collector.retainedCount < collector.queryCount %} +
+ Showing the last {{ collector.retainedCount }} of {{ collector.queryCount }} recorded queries — + older entries were dropped (flow_postgresql.profiler.max_queries). + Duplicate detection covers retained queries only. +
+ {% endif %} + {% if collector.queryCount == 0 %}

No PostgreSQL queries were executed for this request.

{% else %} @@ -122,6 +130,11 @@ {% if query.caller %}{{ query.caller|split('/')|last }}{% endif %} {% if query.parameters is not empty %} · View parameters + {% elseif query.parametersTruncated %} + · parameters omitted + {% endif %} + {% if query.statementTruncated %} + · statement truncated {% endif %} {% if query.explainable %} · Explain query diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Context/ExtensionContext.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Context/ExtensionContext.php index 0b44d811a0..e5c2fa4765 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Context/ExtensionContext.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Context/ExtensionContext.php @@ -13,8 +13,9 @@ { /** * @param array $config + * @param list $bundles registered kernel bundles, e.g. WebProfilerBundle */ - public function load(array $config): ContainerBuilder + public function load(array $config, bool $debug = false, array $bundles = []): ContainerBuilder { $extension = (new FlowPostgreSqlBundle())->getContainerExtension(); @@ -24,9 +25,10 @@ public function load(array $config): ContainerBuilder $container = new ContainerBuilder(); $container->setParameter('kernel.environment', 'test'); - $container->setParameter('kernel.debug', false); + $container->setParameter('kernel.debug', $debug); $container->setParameter('kernel.build_dir', '/tmp'); $container->setParameter('kernel.project_dir', '/tmp'); + $container->setParameter('kernel.bundles', $bundles); $extension->load([$config], $container); return $container; diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Double/FakeClient.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Double/FakeClient.php new file mode 100644 index 0000000000..e365533382 --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Double/FakeClient.php @@ -0,0 +1,263 @@ + */ + public array $delegated = []; + + public int $executeReturn = 1; + + /** @var null|array */ + public ?array $fetchReturn = null; + + /** @var array> */ + public array $fetchAllReturn = []; + + public int $fetchScalarIntReturn = 0; + + private ?QueryException $failure = null; + + public function __construct( + private readonly ?Cursor $cursor = null, + ) {} + + public function failNextQuery(QueryException $exception): void + { + $this->failure = $exception; + } + + public function beginTransaction(): void + { + $this->delegated[] = 'beginTransaction'; + } + + public function close(): void + { + $this->delegated[] = 'close'; + } + + public function commit(): void + { + $this->delegated[] = 'commit'; + } + + public function converters(): ValueConverters + { + $this->delegated[] = 'converters'; + + return ValueConverters::create(); + } + + public function cursor(Sql|string $sql, array $parameters = []): Cursor + { + $this->guard(); + + if ($this->cursor === null) { + throw new RuntimeException('no cursor configured'); + } + + return $this->cursor; + } + + public function execute(Sql|string $sql, array $parameters = []): int + { + $this->guard(); + + return $this->executeReturn; + } + + public function explain(Sql|string $sql, array $parameters = [], ?ExplainConfig $config = null): Plan + { + $this->guard(); + + return new Plan(new PlanNode(PlanNodeType::SEQ_SCAN, new Cost(0.0, 10.0), 100, 8)); + } + + public function fetch(Sql|string $sql, array $parameters = []): ?array + { + $this->guard(); + + return $this->fetchReturn; + } + + public function fetchAll(Sql|string $sql, array $parameters = []): array + { + $this->guard(); + + return $this->fetchAllReturn; + } + + public function fetchAllInto(RowMapper $mapper, Sql|string $sql, array $parameters = []): array + { + $this->guard(); + + return []; + } + + public function fetchInto(RowMapper $mapper, Sql|string $sql, array $parameters = []): mixed + { + $this->guard(); + + return null; + } + + public function fetchOne(Sql|string $sql, array $parameters = []): ?array + { + $this->guard(); + + return $this->fetchReturn; + } + + public function fetchOneInto(RowMapper $mapper, Sql|string $sql, array $parameters = []): mixed + { + $this->guard(); + + return null; + } + + public function fetchScalar(Sql|string $sql, array $parameters = []): mixed + { + $this->guard(); + + return $this->fetchScalarIntReturn; + } + + public function fetchScalarBool(Sql|string $sql, array $parameters = []): bool + { + $this->guard(); + + return true; + } + + public function fetchScalarFloat(Sql|string $sql, array $parameters = []): float + { + $this->guard(); + + return 0.0; + } + + public function fetchScalarInt(Sql|string $sql, array $parameters = []): int + { + $this->guard(); + + return $this->fetchScalarIntReturn; + } + + public function fetchScalarString(Sql|string $sql, array $parameters = []): string + { + $this->guard(); + + return ''; + } + + public function fetchSingle(Sql|string $sql, array $parameters = []): array + { + $this->guard(); + + return $this->fetchReturn ?? []; + } + + public function fetchSingleInto(RowMapper $mapper, Sql|string $sql, array $parameters = []): mixed + { + $this->guard(); + + return null; + } + + public function getTransactionNestingLevel(): int + { + return 0; + } + + public function isAutoCommit(): bool + { + return true; + } + + public function isConnected(): bool + { + $this->delegated[] = 'isConnected'; + + return true; + } + + public function lastInsertId(string $sequenceName): int|string + { + return 0; + } + + public function listen(string $channel): void + { + $this->delegated[] = 'listen'; + } + + public function parameters(): ConnectionParameters + { + $this->delegated[] = 'parameters'; + + return pgsql_connection_params('testdb', 'localhost', 5432, 'user'); + } + + public function rollBack(): void + { + $this->delegated[] = 'rollBack'; + } + + public function setAutoCommit(bool $autoCommit): void + { + $this->delegated[] = 'setAutoCommit'; + } + + public function transaction(callable $callback): mixed + { + $this->delegated[] = 'transaction'; + + return $callback($this); + } + + public function unlisten(string $channel): void + { + $this->delegated[] = 'unlisten'; + } + + public function wait(int $milliseconds): ?Notification + { + return null; + } + + private function guard(): void + { + if ($this->failure !== null) { + $failure = $this->failure; + $this->failure = null; + + throw $failure; + } + } +} diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/Controller/QueryController.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/Controller/QueryController.php index 013dc5cde1..107a51b86c 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/Controller/QueryController.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/Controller/QueryController.php @@ -22,4 +22,11 @@ public function run(): Response return new JsonResponse(['count' => count($rows)]); } + + public function runFailing(): Response + { + $this->client->execute('SELECT * FROM table_that_does_not_exist'); + + return new JsonResponse(['unreachable' => true]); + } } diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Profiler/FlowPostgreSqlProfilerTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Profiler/FlowPostgreSqlProfilerTest.php index fe9e76d975..274249c312 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Profiler/FlowPostgreSqlProfilerTest.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Profiler/FlowPostgreSqlProfilerTest.php @@ -6,11 +6,10 @@ use Flow\Bridge\Symfony\PostgreSqlBundle\FlowPostgreSqlBundle; use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\FlowPostgreSqlDataCollector; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\ProfilerClient; use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures\Controller\QueryController; use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures\TestKernel; use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Integration\KernelTestCase; -use Flow\PostgreSql\Client\Client; -use Flow\PostgreSql\Client\Debug\RecordingClient; use LogicException; use Override; use PHPUnit\Framework\Attributes\CoversClass; @@ -23,9 +22,10 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Profiler\Profiler; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Router; -use Throwable; use function array_merge; use function getenv; @@ -50,20 +50,19 @@ public function test_collector_registered_and_client_decorated_when_enabled(): v FlowPostgreSqlDataCollector::class, $container->get('flow.postgresql.profiler.collector'), ); - static::assertInstanceOf(RecordingClient::class, $container->get('flow.postgresql.default.client')); + static::assertInstanceOf(ProfilerClient::class, $container->get('flow.postgresql.default.client')); } - public function test_executed_queries_are_collected(): void + public function test_queries_are_recorded_during_http_request(): void { - $container = $this->bootWithWebProfiler(['enabled' => true])->getContainer(); - - /** @var Client $client */ - $client = $container->get('flow.postgresql.default.client'); - $client->fetchAll('SELECT n FROM (VALUES (1), (2), (3)) AS t(n) WHERE n >= $1', [2]); + [$kernel, $token] = $this->runQueryRequest(); - /** @var FlowPostgreSqlDataCollector $collector */ - $collector = $container->get('flow.postgresql.profiler.collector'); - $collector->lateCollect(); + /** @var Profiler $profiler */ + $profiler = $kernel->getContainer()->get('profiler'); + $profile = $profiler->loadProfile($token); + static::assertNotNull($profile); + $collector = $profile->getCollector('flow_postgresql'); + static::assertInstanceOf(FlowPostgreSqlDataCollector::class, $collector); static::assertSame(1, $collector->getQueryCount()); $query = $collector->getQueries()['default'][0]; @@ -75,23 +74,18 @@ public function test_executed_queries_are_collected(): void static::assertNotNull($query['caller']); } - public function test_failed_query_is_recorded_as_failed(): void + public function test_failed_query_is_recorded_during_http_request(): void { - $container = $this->bootWithWebProfiler(['enabled' => true])->getContainer(); - - /** @var Client $client */ - $client = $container->get('flow.postgresql.default.client'); + [$kernel, $token, $response] = $this->runQueryRequest('/failing-query', 'runFailing'); - try { - $client->execute('SELECT * FROM table_that_does_not_exist'); - } catch (Throwable) { - // expected - } - - /** @var FlowPostgreSqlDataCollector $collector */ - $collector = $container->get('flow.postgresql.profiler.collector'); - $collector->lateCollect(); + /** @var Profiler $profiler */ + $profiler = $kernel->getContainer()->get('profiler'); + $profile = $profiler->loadProfile($token); + static::assertNotNull($profile); + $collector = $profile->getCollector('flow_postgresql'); + static::assertInstanceOf(FlowPostgreSqlDataCollector::class, $collector); + static::assertSame(500, $response->getStatusCode()); static::assertSame(1, $collector->getFailedCount()); static::assertTrue($collector->getQueries()['default'][0]['failed']); } @@ -101,7 +95,7 @@ public function test_collector_not_registered_when_disabled(): void $container = $this->bootWithWebProfiler(['enabled' => false])->getContainer(); static::assertFalse($container->has('flow.postgresql.profiler.collector')); - static::assertNotInstanceOf(RecordingClient::class, $container->get('flow.postgresql.default.client')); + static::assertNotInstanceOf(ProfilerClient::class, $container->get('flow.postgresql.default.client')); } public function test_connection_can_opt_out_while_profiler_enabled(): void @@ -132,7 +126,7 @@ public function test_connection_can_opt_out_while_profiler_enabled(): void // Panel still registered (profiler enabled) but the opted-out connection is not decorated. static::assertTrue($container->has('flow.postgresql.profiler.collector')); - static::assertNotInstanceOf(RecordingClient::class, $container->get('flow.postgresql.default.client')); + static::assertNotInstanceOf(ProfilerClient::class, $container->get('flow.postgresql.default.client')); } public function test_auto_disabled_when_web_profiler_bundle_absent(): void @@ -152,21 +146,7 @@ public function test_forcing_enabled_without_web_profiler_bundle_throws(): void public function test_panel_renders_executed_queries_in_the_profiler(): void { - $kernel = $this->bootForPanelRendering(); - $container = $kernel->getContainer(); - - /** @var Router $router */ - $router = $container->get('router'); - $router->getRouteCollection()->add('query', new Route('/query', [ - '_controller' => QueryController::class . '::run', - ])); - - $request = Request::create('/query', 'GET'); - $response = $kernel->handle($request); - $token = $response->headers->get('X-Debug-Token'); - $kernel->terminate($request, $response); - - static::assertNotNull($token); + [$kernel, $token] = $this->runQueryRequest(); $panel = $kernel->handle(Request::create('/_profiler/' . $token . '?panel=flow_postgresql')); $html = (string) $panel->getContent(); @@ -201,25 +181,25 @@ public function test_explain_endpoint_rejects_unknown_query(): void } /** - * @return array{0: TestKernel, 1: string} + * @return array{0: TestKernel, 1: string, 2: Response} */ - private function runQueryRequest(): array + private function runQueryRequest(string $path = '/query', string $action = 'run'): array { $kernel = $this->bootForPanelRendering(); $container = $kernel->getContainer(); /** @var Router $router */ $router = $container->get('router'); - $router->getRouteCollection()->add('query', new Route('/query', [ - '_controller' => QueryController::class . '::run', + $router->getRouteCollection()->add('query', new Route($path, [ + '_controller' => QueryController::class . '::' . $action, ])); - $request = Request::create('/query', 'GET'); + $request = Request::create($path, 'GET'); $response = $kernel->handle($request); $token = (string) $response->headers->get('X-Debug-Token'); $kernel->terminate($request, $response); - return [$kernel, $token]; + return [$kernel, $token, $response]; } /** diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php index f698f9e33f..2e14b8d3f8 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php @@ -775,4 +775,112 @@ public function test_telemetry_with_defaults(): void static::assertSame(10, $telemetry['max_parameters']); static::assertSame(100, $telemetry['max_parameter_length']); } + + public function test_profiler_max_queries_defaults_to_1000(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => [], + ]); + + static::assertSame(1000, $config['profiler']['max_queries']); + } + + public function test_profiler_max_retained_parameters_defaults_to_100(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => [], + ]); + + static::assertSame(100, $config['profiler']['max_retained_parameters']); + } + + public function test_profiler_max_query_length_defaults_to_1000(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => [], + ]); + + static::assertSame(1000, $config['profiler']['max_query_length']); + } + + public function test_profiler_max_query_length_can_be_configured(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => ['max_query_length' => 250], + ]); + + static::assertSame(250, $config['profiler']['max_query_length']); + } + + public function test_profiler_max_query_length_below_one_is_rejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => ['max_query_length' => 0], + ]); + } + + public function test_profiler_max_queries_can_be_configured(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => ['max_queries' => 250], + ]); + + static::assertSame(250, $config['profiler']['max_queries']); + } + + public function test_profiler_max_retained_parameters_can_be_configured(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => ['max_retained_parameters' => 5], + ]); + + static::assertSame(5, $config['profiler']['max_retained_parameters']); + } + + public function test_profiler_max_queries_below_one_is_rejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => ['max_queries' => 0], + ]); + } + + public function test_profiler_negative_max_retained_parameters_is_rejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => ['max_retained_parameters' => -1], + ]); + } } diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ProfilerRegistrationTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ProfilerRegistrationTest.php new file mode 100644 index 0000000000..f8895d5b1b --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ProfilerRegistrationTest.php @@ -0,0 +1,94 @@ +context = new ExtensionContext(); + } + + public function test_auto_enabled_when_web_profiler_registered_and_kernel_is_in_debug_mode(): void + { + $container = $this->context->load($this->config(), debug: true, bundles: [WebProfilerBundle::class]); + + static::assertTrue($container->hasDefinition('flow.postgresql.profiler.query_recorder')); + static::assertTrue($container->hasDefinition('flow.postgresql.default.client.profiler')); + } + + public function test_auto_disabled_when_kernel_is_not_in_debug_mode(): void + { + $container = $this->context->load($this->config(), debug: false, bundles: [WebProfilerBundle::class]); + + static::assertFalse($container->hasDefinition('flow.postgresql.profiler.query_recorder')); + static::assertFalse($container->hasDefinition('flow.postgresql.default.client.profiler')); + } + + public function test_auto_disabled_when_web_profiler_bundle_is_absent_in_debug_mode(): void + { + $container = $this->context->load($this->config(), debug: true); + + static::assertFalse($container->hasDefinition('flow.postgresql.profiler.query_recorder')); + } + + public function test_forcing_enabled_registers_without_debug_mode(): void + { + $container = $this->context->load( + $this->config(['enabled' => true]), + debug: false, + bundles: [WebProfilerBundle::class], + ); + + static::assertTrue($container->hasDefinition('flow.postgresql.profiler.query_recorder')); + static::assertTrue($container->hasDefinition('flow.postgresql.default.client.profiler')); + } + + public function test_forcing_disabled_skips_registration_in_debug_mode(): void + { + $container = $this->context->load( + $this->config(['enabled' => false]), + debug: true, + bundles: [WebProfilerBundle::class], + ); + + static::assertFalse($container->hasDefinition('flow.postgresql.profiler.query_recorder')); + } + + public function test_max_query_length_is_wired_as_a_container_parameter(): void + { + $container = $this->context->load( + $this->config(['max_query_length' => 250]), + debug: true, + bundles: [WebProfilerBundle::class], + ); + + static::assertSame(250, $container->getParameter('flow.postgresql.profiler.max_query_length')); + } + + /** + * @param array $profiler + * + * @return array + */ + private function config(array $profiler = []): array + { + return [ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'profiler' => $profiler, + ]; + } +} diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/FlowPostgreSqlDataCollectorTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/FlowPostgreSqlDataCollectorTest.php index 13fc039dda..88358b9db2 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/FlowPostgreSqlDataCollectorTest.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/FlowPostgreSqlDataCollectorTest.php @@ -5,8 +5,9 @@ namespace Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Unit\Profiler; use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\FlowPostgreSqlDataCollector; -use Flow\PostgreSql\Client\Debug\QueryLog; -use Flow\PostgreSql\Client\Debug\RecordedQuery; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\QueryRecorder; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\QueryRecorderOptions; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\RecordedQuery; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; @@ -16,13 +17,13 @@ final class FlowPostgreSqlDataCollectorTest extends TestCase { public function test_get_name_is_flow_postgresql(): void { - static::assertSame('flow_postgresql', $this->collector(new QueryLog())->getName()); + static::assertSame('flow_postgresql', $this->collector(new QueryRecorder())->getName()); } public function test_late_collect_maps_recorded_queries(): void { - $log = new QueryLog(); - $log->add( + $recorder = new QueryRecorder(); + $recorder->add( new RecordedQuery( 'SELECT * FROM users WHERE id = $1', [42], @@ -34,7 +35,7 @@ public function test_late_collect_maps_recorded_queries(): void '/app/Repo.php:10', ), ); - $collector = $this->collector($log); + $collector = $this->collector($recorder); $collector->lateCollect(); @@ -54,11 +55,11 @@ public function test_late_collect_maps_recorded_queries(): void public function test_late_collect_counts_and_totals(): void { - $log = new QueryLog(); - $log->add(new RecordedQuery('SELECT 1', [], 2.0, 1, false, null)); - $log->add(new RecordedQuery('SELECT 2', [], 3.0, 1, false, null)); - $log->add(new RecordedQuery('BAD SQL', [], 0.5, null, true, 'syntax error')); - $collector = $this->collector($log); + $recorder = new QueryRecorder(); + $recorder->add(new RecordedQuery('SELECT 1', [], 2.0, 1, false, null)); + $recorder->add(new RecordedQuery('SELECT 2', [], 3.0, 1, false, null)); + $recorder->add(new RecordedQuery('BAD SQL', [], 0.5, null, true, 'syntax error')); + $collector = $this->collector($recorder); $collector->lateCollect(); @@ -69,11 +70,11 @@ public function test_late_collect_counts_and_totals(): void public function test_groups_queries_by_connection(): void { - $log = new QueryLog(); - $log->add(new RecordedQuery('SELECT 1', [], 1.0, 1, false, null, 'default')); - $log->add(new RecordedQuery('SELECT 2', [], 1.0, 1, false, null, 'analytics')); - $log->add(new RecordedQuery('SELECT 3', [], 1.0, 1, false, null, 'analytics')); - $collector = $this->collector($log); + $recorder = new QueryRecorder(); + $recorder->add(new RecordedQuery('SELECT 1', [], 1.0, 1, false, null, 'default')); + $recorder->add(new RecordedQuery('SELECT 2', [], 1.0, 1, false, null, 'analytics')); + $recorder->add(new RecordedQuery('SELECT 3', [], 1.0, 1, false, null, 'analytics')); + $collector = $this->collector($recorder); $collector->lateCollect(); @@ -84,11 +85,11 @@ public function test_groups_queries_by_connection(): void public function test_detects_duplicate_statements(): void { - $log = new QueryLog(); - $log->add(new RecordedQuery('SELECT * FROM users WHERE id = $1', [1], 1.0, 1, false, null)); - $log->add(new RecordedQuery('SELECT * FROM users WHERE id = $1', [2], 1.0, 1, false, null)); - $log->add(new RecordedQuery('SELECT * FROM posts', [], 1.0, 5, false, null)); - $collector = $this->collector($log); + $recorder = new QueryRecorder(); + $recorder->add(new RecordedQuery('SELECT * FROM users WHERE id = $1', [1], 1.0, 1, false, null)); + $recorder->add(new RecordedQuery('SELECT * FROM users WHERE id = $1', [2], 1.0, 1, false, null)); + $recorder->add(new RecordedQuery('SELECT * FROM posts', [], 1.0, 5, false, null)); + $collector = $this->collector($recorder); $collector->lateCollect(); @@ -114,9 +115,9 @@ public function test_detects_duplicate_statements(): void #[TestWith(['CREATE TABLE t (a int)', false])] public function test_explainable_flag(string $statement, bool $expected): void { - $log = new QueryLog(); - $log->add(new RecordedQuery($statement, [], 1.0, 1, false, null)); - $collector = $this->collector($log); + $recorder = new QueryRecorder(); + $recorder->add(new RecordedQuery($statement, [], 1.0, 1, false, null)); + $collector = $this->collector($recorder); $collector->lateCollect(); @@ -125,9 +126,9 @@ public function test_explainable_flag(string $statement, bool $expected): void public function test_failed_query_is_not_explainable(): void { - $log = new QueryLog(); - $log->add(new RecordedQuery('SELECT 1', [], 0.5, null, true, 'boom')); - $collector = $this->collector($log); + $recorder = new QueryRecorder(); + $recorder->add(new RecordedQuery('SELECT 1', [], 0.5, null, true, 'boom')); + $collector = $this->collector($recorder); $collector->lateCollect(); @@ -139,31 +140,86 @@ public function test_failed_query_is_not_explainable(): void public function test_include_parameters_false_omits_parameters(): void { - $log = new QueryLog(); - $log->add(new RecordedQuery('SELECT * FROM users WHERE id = $1', [42], 1.0, 1, false, null)); - $collector = $this->collector($log, includeParameters: false); + $recorder = new QueryRecorder(new QueryRecorderOptions(includeParameters: false)); + $recorder->add(new RecordedQuery('SELECT * FROM users WHERE id = $1', [42], 1.0, 1, false, null)); + $collector = $this->collector($recorder); $collector->lateCollect(); - static::assertSame([], $collector->getQueries()['default'][0]['parameters']); + $query = $collector->getQueries()['default'][0]; + static::assertSame([], $query['parameters']); + static::assertFalse($query['explainable']); + } + + public function test_totals_are_accurate_after_eviction(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueries: 1)); + $recorder->add(new RecordedQuery('SELECT 1', [], 2.0, 1, false, null)); + $recorder->add(new RecordedQuery('BAD SQL', [], 0.5, null, true, 'syntax error')); + $recorder->add(new RecordedQuery('SELECT 2', [], 3.0, 1, false, null)); + $collector = $this->collector($recorder); + + $collector->lateCollect(); + + static::assertSame(3, $collector->getQueryCount()); + static::assertSame(1, $collector->getRetainedCount()); + static::assertSame(1, $collector->getFailedCount()); + static::assertSame(5.5, $collector->getTotalDurationMs()); + static::assertCount(1, $collector->getQueries()['default']); + } + + public function test_query_with_dropped_parameters_is_not_explainable(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxRetainedParameters: 1)); + $recorder->add(new RecordedQuery('SELECT * FROM t WHERE a = $1 AND b = $2', [1, 2], 1.0, 1, false, null)); + $collector = $this->collector($recorder); + + $collector->lateCollect(); + + static::assertFalse($collector->getQueries()['default'][0]['explainable']); + } + + public function test_query_without_any_parameters_stays_explainable(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(includeParameters: false)); + $recorder->add(new RecordedQuery('SELECT NOW()', [], 1.0, 1, false, null)); + $collector = $this->collector($recorder); + + $collector->lateCollect(); + + static::assertTrue($collector->getQueries()['default'][0]['explainable']); + } + + public function test_parameters_truncated_is_exposed_on_the_row(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxRetainedParameters: 1)); + $recorder->add(new RecordedQuery('SELECT $1, $2', [1, 2], 1.0, 1, false, null)); + $recorder->add(new RecordedQuery('SELECT $1', [1], 1.0, 1, false, null)); + $collector = $this->collector($recorder); + + $collector->lateCollect(); + + $rows = $collector->getQueries()['default']; + static::assertTrue($rows[0]['parametersTruncated']); + static::assertFalse($rows[1]['parametersTruncated']); } public function test_reset_clears_data_and_query_log(): void { - $log = new QueryLog(); - $log->add(new RecordedQuery('SELECT 1', [], 1.0, 1, false, null)); - $collector = $this->collector($log); + $recorder = new QueryRecorder(); + $recorder->add(new RecordedQuery('SELECT 1', [], 1.0, 1, false, null)); + $collector = $this->collector($recorder); $collector->lateCollect(); $collector->reset(); static::assertSame([], $collector->getQueries()); static::assertSame(0, $collector->getQueryCount()); - static::assertSame([], $log->queries()); + static::assertSame([], $recorder->queries()); } - private function collector(QueryLog $log, bool $includeParameters = true): FlowPostgreSqlDataCollector + private function collector(QueryRecorder $recorder): FlowPostgreSqlDataCollector { - return new FlowPostgreSqlDataCollector($log, $includeParameters); + return new FlowPostgreSqlDataCollector($recorder); } } diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Debug/RecordingClientTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/ProfilerClientTest.php similarity index 64% rename from src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Debug/RecordingClientTest.php rename to src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/ProfilerClientTest.php index 1b1e03b006..6c8d382835 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Debug/RecordingClientTest.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/ProfilerClientTest.php @@ -2,33 +2,33 @@ declare(strict_types=1); -namespace Flow\PostgreSql\Tests\Unit\Client\Debug; +namespace Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Unit\Profiler; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\ProfilerClient; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\QueryRecorder; +use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Double\FakeClient; use Flow\PostgreSql\Client\Cursor; -use Flow\PostgreSql\Client\Debug\QueryLog; -use Flow\PostgreSql\Client\Debug\RecordingClient; use Flow\PostgreSql\Client\Exception\PostgreSqlError; use Flow\PostgreSql\Client\Exception\QueryException; use Flow\PostgreSql\Client\RowMapper; use Flow\PostgreSql\QueryBuilder\Sql; -use Flow\PostgreSql\Tests\Mother\FakeClient; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversClass(RecordingClient::class)] -final class RecordingClientTest extends TestCase +#[CoversClass(ProfilerClient::class)] +final class ProfilerClientTest extends TestCase { public function test_execute_records_statement_parameters_and_affected_rows(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient(); $inner->executeReturn = 3; - $affected = (new RecordingClient($inner, $log))->execute('DELETE FROM users WHERE id = $1', [42]); + $affected = (new ProfilerClient($inner, $recorder))->execute('DELETE FROM users WHERE id = $1', [42]); static::assertSame(3, $affected); - static::assertCount(1, $log->queries()); - $query = $log->queries()[0]; + static::assertCount(1, $recorder->queries()); + $query = $recorder->queries()[0]; static::assertSame('DELETE FROM users WHERE id = $1', $query->sql); static::assertSame([42], $query->parameters); static::assertSame(3, $query->rowCount); @@ -38,22 +38,22 @@ public function test_execute_records_statement_parameters_and_affected_rows(): v static::assertSame('default', $query->connection); // Caller is captured and points outside the library decorator (lib frames are skipped). static::assertNotNull($query->caller); - static::assertStringNotContainsString('Debug/RecordingClient.php', $query->caller); + static::assertStringNotContainsString('Debug/ProfilerClient.php', $query->caller); } public function test_records_the_configured_connection_name(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); - (new RecordingClient(new FakeClient(), $log, 'analytics'))->execute('SELECT 1'); + (new ProfilerClient(new FakeClient(), $recorder, 'analytics'))->execute('SELECT 1'); - static::assertSame('analytics', $log->queries()[0]->connection); + static::assertSame('analytics', $recorder->queries()[0]->connection); } public function test_every_statement_bearing_method_is_recorded(): void { - $log = new QueryLog(); - $client = new RecordingClient(new FakeClient($this->createStub(Cursor::class)), $log); + $recorder = new QueryRecorder(); + $client = new ProfilerClient(new FakeClient($this->createStub(Cursor::class)), $recorder); $mapper = $this->createStub(RowMapper::class); $client->execute('UPDATE t SET a = 1'); @@ -73,15 +73,15 @@ public function test_every_statement_bearing_method_is_recorded(): void $client->fetchSingleInto($mapper, 'SELECT 1'); $client->cursor('SELECT 1'); - static::assertCount(16, $log->queries()); + static::assertCount(16, $recorder->queries()); } public function test_failed_cursor_records_failure_and_rethrows(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient($this->createStub(Cursor::class)); $inner->failNextQuery(QueryException::executionFailed('SELECT bad', PostgreSqlError::unknown('boom'))); - $client = new RecordingClient($inner, $log); + $client = new ProfilerClient($inner, $recorder); try { $client->cursor('SELECT bad'); @@ -90,15 +90,15 @@ public function test_failed_cursor_records_failure_and_rethrows(): void // expected } - static::assertTrue($log->queries()[0]->failed); - static::assertNull($log->queries()[0]->rowCount); + static::assertTrue($recorder->queries()[0]->failed); + static::assertNull($recorder->queries()[0]->rowCount); } public function test_delegation_methods_forward_without_recording(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient(); - $client = new RecordingClient($inner, $log); + $client = new ProfilerClient($inner, $recorder); $client->beginTransaction(); $client->commit(); @@ -117,60 +117,60 @@ public function test_delegation_methods_forward_without_recording(): void static::assertSame(0, $client->lastInsertId('seq')); static::assertSame('result', $client->transaction(static fn(): string => 'result')); - static::assertSame([], $log->queries()); + static::assertSame([], $recorder->queries()); } public function test_fetch_records_one_row_when_row_returned(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient(); $inner->fetchReturn = ['id' => 1]; - $row = (new RecordingClient($inner, $log))->fetch('SELECT * FROM users LIMIT 1'); + $row = (new ProfilerClient($inner, $recorder))->fetch('SELECT * FROM users LIMIT 1'); static::assertSame(['id' => 1], $row); - static::assertSame(1, $log->queries()[0]->rowCount); + static::assertSame(1, $recorder->queries()[0]->rowCount); } public function test_fetch_records_zero_rows_when_null(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient(); $inner->fetchReturn = null; - (new RecordingClient($inner, $log))->fetch('SELECT * FROM users WHERE 1 = 0'); + (new ProfilerClient($inner, $recorder))->fetch('SELECT * FROM users WHERE 1 = 0'); - static::assertSame(0, $log->queries()[0]->rowCount); + static::assertSame(0, $recorder->queries()[0]->rowCount); } public function test_fetch_all_records_returned_row_count(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient(); $inner->fetchAllReturn = [['id' => 1], ['id' => 2], ['id' => 3]]; - $rows = (new RecordingClient($inner, $log))->fetchAll('SELECT * FROM users'); + $rows = (new ProfilerClient($inner, $recorder))->fetchAll('SELECT * FROM users'); static::assertCount(3, $rows); - static::assertSame(3, $log->queries()[0]->rowCount); + static::assertSame(3, $recorder->queries()[0]->rowCount); } public function test_fetch_scalar_int_records_query(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient(); $inner->fetchScalarIntReturn = 7; - $count = (new RecordingClient($inner, $log))->fetchScalarInt('SELECT COUNT(*) FROM users'); + $count = (new ProfilerClient($inner, $recorder))->fetchScalarInt('SELECT COUNT(*) FROM users'); static::assertSame(7, $count); - static::assertSame('SELECT COUNT(*) FROM users', $log->queries()[0]->sql); - static::assertSame(1, $log->queries()[0]->rowCount); + static::assertSame('SELECT COUNT(*) FROM users', $recorder->queries()[0]->sql); + static::assertSame(1, $recorder->queries()[0]->rowCount); } public function test_sql_object_is_recorded_via_to_sql(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $sql = new class implements Sql { public function toSql(): string { @@ -178,17 +178,17 @@ public function toSql(): string } }; - (new RecordingClient(new FakeClient(), $log))->execute($sql); + (new ProfilerClient(new FakeClient(), $recorder))->execute($sql); - static::assertSame('SELECT 1', $log->queries()[0]->sql); + static::assertSame('SELECT 1', $recorder->queries()[0]->sql); } public function test_failed_query_records_failure_and_rethrows(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient(); $inner->failNextQuery(QueryException::executionFailed('SELECT bad', PostgreSqlError::unknown('boom'))); - $client = new RecordingClient($inner, $log); + $client = new ProfilerClient($inner, $recorder); $thrown = null; try { @@ -198,7 +198,7 @@ public function test_failed_query_records_failure_and_rethrows(): void $thrown = $e; } - $query = $log->queries()[0]; + $query = $recorder->queries()[0]; static::assertTrue($query->failed); static::assertSame('SELECT bad', $query->sql); static::assertSame($thrown->getMessage(), $query->error); @@ -207,23 +207,23 @@ public function test_failed_query_records_failure_and_rethrows(): void public function test_cursor_records_statement_with_unknown_row_count_and_returns_inner_cursor(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $cursor = $this->createStub(Cursor::class); $inner = new FakeClient($cursor); - $returned = (new RecordingClient($inner, $log))->cursor('SELECT * FROM big_table'); + $returned = (new ProfilerClient($inner, $recorder))->cursor('SELECT * FROM big_table'); static::assertSame($cursor, $returned); - static::assertCount(1, $log->queries()); - static::assertSame('SELECT * FROM big_table', $log->queries()[0]->sql); - static::assertNull($log->queries()[0]->rowCount); + static::assertCount(1, $recorder->queries()); + static::assertSame('SELECT * FROM big_table', $recorder->queries()[0]->sql); + static::assertNull($recorder->queries()[0]->rowCount); } public function test_transaction_control_and_connection_methods_delegate_without_recording(): void { - $log = new QueryLog(); + $recorder = new QueryRecorder(); $inner = new FakeClient(); - $client = new RecordingClient($inner, $log); + $client = new ProfilerClient($inner, $recorder); $client->beginTransaction(); $client->commit(); @@ -231,7 +231,7 @@ public function test_transaction_control_and_connection_methods_delegate_without $client->setAutoCommit(false); static::assertTrue($client->isConnected()); - static::assertSame([], $log->queries()); + static::assertSame([], $recorder->queries()); static::assertSame( ['beginTransaction', 'commit', 'rollBack', 'setAutoCommit', 'isConnected'], $inner->delegated, diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/ProfilerControllerTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/ProfilerControllerTest.php index 1e3bfdf0ae..be87e29aef 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/ProfilerControllerTest.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/ProfilerControllerTest.php @@ -6,11 +6,11 @@ use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\FlowPostgreSqlDataCollector; use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\ProfilerController; -use Flow\PostgreSql\Client\Debug\QueryLog; -use Flow\PostgreSql\Client\Debug\RecordedQuery; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\QueryRecorder; +use Flow\Bridge\Symfony\PostgreSqlBundle\Profiler\RecordedQuery; +use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Double\FakeClient; use Flow\PostgreSql\Client\Exception\PostgreSqlError; use Flow\PostgreSql\Client\Exception\QueryException; -use Flow\PostgreSql\Tests\Mother\FakeClient; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ServiceLocator; @@ -113,9 +113,9 @@ public function test_returns_message_when_explain_fails(): void */ private function collectorWith(string $statement, array $parameters): FlowPostgreSqlDataCollector { - $log = new QueryLog(); + $log = new QueryRecorder(); $log->add(new RecordedQuery($statement, $parameters, 1.0, 1, false, null)); - $collector = new FlowPostgreSqlDataCollector($log, includeParameters: true); + $collector = new FlowPostgreSqlDataCollector($log); $collector->lateCollect(); return $collector; diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/QueryRecorderOptionsTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/QueryRecorderOptionsTest.php new file mode 100644 index 0000000000..f3fd43bdd3 --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/QueryRecorderOptionsTest.php @@ -0,0 +1,119 @@ +maxQueries); + static::assertTrue($options->includeParameters); + static::assertSame(100, $options->maxRetainedParameters); + } + + public function test_max_queries_wither_returns_new_instance(): void + { + $options = new QueryRecorderOptions(); + + static::assertSame(500, $options->maxQueries(500)->maxQueries); + static::assertSame(1000, $options->maxQueries); + } + + public function test_include_parameters_wither_returns_new_instance(): void + { + $options = new QueryRecorderOptions(); + + static::assertFalse($options->includeParameters(false)->includeParameters); + static::assertTrue($options->includeParameters); + } + + public function test_max_parameters_wither_returns_new_instance(): void + { + $options = new QueryRecorderOptions(); + + static::assertSame(5, $options->maxRetainedParameters(5)->maxRetainedParameters); + static::assertSame(100, $options->maxRetainedParameters); + } + + #[TestWith([0])] + #[TestWith([-1])] + public function test_rejects_max_queries_below_one(int $maxQueries): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('QueryRecorderOptions::$maxQueries must be at least 1, got ' . $maxQueries); + + new QueryRecorderOptions(maxQueries: $maxQueries); + } + + public function test_rejects_negative_max_parameters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('QueryRecorderOptions::$maxRetainedParameters must not be negative, got -1'); + + new QueryRecorderOptions(maxRetainedParameters: -1); + } + + public function test_allows_null_max_parameters_as_unlimited(): void + { + static::assertNull((new QueryRecorderOptions(maxRetainedParameters: null))->maxRetainedParameters); + } + + public function test_retains_parameters_within_limit(): void + { + static::assertTrue((new QueryRecorderOptions(maxRetainedParameters: 100))->retainsParameters(array_fill( + 0, + 5, + 'v', + ))); + } + + public function test_retains_parameters_at_exact_limit(): void + { + static::assertTrue((new QueryRecorderOptions(maxRetainedParameters: 100))->retainsParameters(array_fill( + 0, + 100, + 'v', + ))); + } + + public function test_does_not_retain_parameters_over_limit(): void + { + static::assertFalse((new QueryRecorderOptions(maxRetainedParameters: 100))->retainsParameters(array_fill( + 0, + 101, + 'v', + ))); + } + + public function test_retains_everything_when_max_parameters_is_null(): void + { + static::assertTrue((new QueryRecorderOptions(maxRetainedParameters: null))->retainsParameters(array_fill( + 0, + 17_000, + 'v', + ))); + } + + public function test_does_not_retain_parameters_when_include_parameters_disabled(): void + { + static::assertFalse((new QueryRecorderOptions(includeParameters: false))->retainsParameters([42])); + } + + public function test_retains_empty_parameters_when_include_parameters_disabled(): void + { + static::assertTrue((new QueryRecorderOptions(includeParameters: false))->retainsParameters([])); + } +} diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/QueryRecorderTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/QueryRecorderTest.php new file mode 100644 index 0000000000..048b63ca9b --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/QueryRecorderTest.php @@ -0,0 +1,300 @@ +queries()); + } + + public function test_add_appends_in_order(): void + { + $recorder = new QueryRecorder(); + $first = new RecordedQuery('SELECT 1', [], 0.5, 1, false, null); + $second = new RecordedQuery('SELECT 2', [7], 1.5, 0, false, null); + + $recorder->add($first); + $recorder->add($second); + + static::assertSame([$first, $second], $recorder->queries()); + } + + public function test_reset_clears_entries(): void + { + $recorder = new QueryRecorder(); + $recorder->add(new RecordedQuery('SELECT 1', [], 0.5, 1, false, null)); + + $recorder->reset(); + + static::assertSame([], $recorder->queries()); + } + + public function test_recorded_query_exposes_values(): void + { + $query = new RecordedQuery('SELECT * FROM t WHERE id = $1', [42], 2.25, 1, true, 'boom'); + + static::assertSame('SELECT * FROM t WHERE id = $1', $query->sql); + static::assertSame([42], $query->parameters); + static::assertSame(2.25, $query->durationMs); + static::assertSame(1, $query->rowCount); + static::assertTrue($query->failed); + static::assertSame('boom', $query->error); + static::assertFalse($query->parametersTruncated); + } + + public function test_evicts_oldest_entry_when_cap_reached(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueries: 2)); + $first = new RecordedQuery('SELECT 1', [], 1.0, 1, false, null); + $second = new RecordedQuery('SELECT 2', [], 1.0, 1, false, null); + $third = new RecordedQuery('SELECT 3', [], 1.0, 1, false, null); + + $recorder->add($first); + $recorder->add($second); + $recorder->add($third); + + static::assertSame([$second, $third], $recorder->queries()); + } + + public function test_counters_survive_eviction(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueries: 1)); + + for ($i = 0; $i < 5; $i++) { + $recorder->add(new RecordedQuery('SELECT ' . $i, [], 1.0, 1, false, null)); + } + + static::assertSame(5, $recorder->recordedCount()); + static::assertSame(1, $recorder->retainedCount()); + } + + public function test_failed_count_survives_eviction(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueries: 1)); + $recorder->add(new RecordedQuery('BAD 1', [], 1.0, null, true, 'boom')); + $recorder->add(new RecordedQuery('BAD 2', [], 1.0, null, true, 'boom')); + $recorder->add(new RecordedQuery('SELECT 1', [], 1.0, 1, false, null)); + + static::assertSame(2, $recorder->failedCount()); + static::assertSame(1, $recorder->retainedCount()); + } + + public function test_total_duration_survives_eviction(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueries: 1)); + $recorder->add(new RecordedQuery('SELECT 1', [], 2.0, 1, false, null)); + $recorder->add(new RecordedQuery('SELECT 2', [], 3.5, 1, false, null)); + + static::assertSame(5.5, $recorder->totalDurationMs()); + } + + public function test_reset_clears_entries_and_counters(): void + { + $recorder = new QueryRecorder(); + $recorder->add(new RecordedQuery('BAD', [], 2.0, null, true, 'boom')); + + $recorder->reset(); + + static::assertSame([], $recorder->queries()); + static::assertSame(0, $recorder->recordedCount()); + static::assertSame(0, $recorder->retainedCount()); + static::assertSame(0, $recorder->failedCount()); + static::assertSame(0.0, $recorder->totalDurationMs()); + } + + public function test_parameters_dropped_when_over_max_parameters(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxRetainedParameters: 2)); + $recorder->add(new RecordedQuery('INSERT INTO t VALUES ($1, $2, $3)', [1, 2, 3], 1.0, 3, false, null)); + + static::assertSame([], $recorder->queries()[0]->parameters); + static::assertTrue($recorder->queries()[0]->parametersTruncated); + } + + public function test_parameters_kept_when_within_max_parameters(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxRetainedParameters: 2)); + $query = new RecordedQuery('SELECT * FROM t WHERE a = $1 AND b = $2', [1, 2], 1.0, 1, false, null); + + $recorder->add($query); + + static::assertSame($query, $recorder->queries()[0]); + static::assertFalse($recorder->queries()[0]->parametersTruncated); + } + + public function test_parameters_dropped_when_include_parameters_disabled(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(includeParameters: false)); + $recorder->add(new RecordedQuery('SELECT * FROM t WHERE id = $1', [42], 1.0, 1, false, null)); + + static::assertSame([], $recorder->queries()[0]->parameters); + static::assertTrue($recorder->queries()[0]->parametersTruncated); + } + + public function test_parameterless_query_is_not_marked_truncated(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(includeParameters: false)); + $recorder->add(new RecordedQuery('SELECT NOW()', [], 1.0, 1, false, null)); + + static::assertFalse($recorder->queries()[0]->parametersTruncated); + } + + public function test_dropped_entry_keeps_every_other_field(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxRetainedParameters: 0)); + $recorder->add( + new RecordedQuery( + 'INSERT INTO t VALUES ($1)', + [42], + 12.5, + 7, + true, + 'boom', + 'analytics', + '/app/Repo.php:10', + ), + ); + + $dropped = $recorder->queries()[0]; + + static::assertSame('INSERT INTO t VALUES ($1)', $dropped->sql); + static::assertSame(12.5, $dropped->durationMs); + static::assertSame(7, $dropped->rowCount); + static::assertTrue($dropped->failed); + static::assertSame('boom', $dropped->error); + static::assertSame('analytics', $dropped->connection); + static::assertSame('/app/Repo.php:10', $dropped->caller); + } + + public function test_memory_is_flat_across_many_batched_inserts(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueries: 100, maxRetainedParameters: 10)); + + for ($i = 0; $i < 500; $i++) { + $recorder->add( + new RecordedQuery('INSERT INTO t VALUES ($1)', array_fill(0, 2_000, 'value'), 1.0, 1, false, null), + ); + } + + gc_collect_cycles(); + $afterFirstHalf = memory_get_usage(); + + for ($i = 0; $i < 500; $i++) { + $recorder->add( + new RecordedQuery('INSERT INTO t VALUES ($1)', array_fill(0, 2_000, 'value'), 1.0, 1, false, null), + ); + } + + gc_collect_cycles(); + + static::assertSame(100, $recorder->retainedCount()); + static::assertSame(1000, $recorder->recordedCount()); + static::assertLessThan(512 * 1024, memory_get_usage() - $afterFirstHalf); + } + + public function test_statement_is_truncated_when_over_max_query_length(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueryLength: 10)); + + $recorder->add(new RecordedQuery('SELECT * FROM a_very_long_table_name', [], 1.0, 1, false, null)); + + static::assertSame('SELECT * F...', $recorder->queries()[0]->sql); + static::assertTrue($recorder->queries()[0]->statementTruncated); + } + + public function test_statement_is_kept_when_within_max_query_length(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueryLength: 100)); + + $recorder->add(new RecordedQuery('SELECT 1', [], 1.0, 1, false, null)); + + static::assertSame('SELECT 1', $recorder->queries()[0]->sql); + static::assertFalse($recorder->queries()[0]->statementTruncated); + } + + public function test_statement_is_kept_when_max_query_length_is_null(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueryLength: null)); + $sql = 'SELECT ' . str_repeat('a', 10_000); + + $recorder->add(new RecordedQuery($sql, [], 1.0, 1, false, null)); + + static::assertSame($sql, $recorder->queries()[0]->sql); + static::assertFalse($recorder->queries()[0]->statementTruncated); + } + + public function test_truncated_statement_keeps_every_other_field(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueryLength: 5)); + $recorder->add( + new RecordedQuery( + 'INSERT INTO t VALUES ($1)', + [42], + 12.5, + 7, + true, + 'boom', + 'analytics', + '/app/Repo.php:10', + ), + ); + + $truncated = $recorder->queries()[0]; + + static::assertSame([42], $truncated->parameters); + static::assertSame(12.5, $truncated->durationMs); + static::assertSame(7, $truncated->rowCount); + static::assertTrue($truncated->failed); + static::assertSame('boom', $truncated->error); + static::assertSame('analytics', $truncated->connection); + static::assertSame('/app/Repo.php:10', $truncated->caller); + static::assertFalse($truncated->parametersTruncated); + } + + public function test_statement_and_parameters_can_both_be_truncated(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxRetainedParameters: 1, maxQueryLength: 5)); + + $recorder->add(new RecordedQuery('INSERT INTO t VALUES ($1, $2)', [1, 2], 1.0, 1, false, null)); + + static::assertSame('INSER...', $recorder->queries()[0]->sql); + static::assertSame([], $recorder->queries()[0]->parameters); + static::assertTrue($recorder->queries()[0]->statementTruncated); + static::assertTrue($recorder->queries()[0]->parametersTruncated); + } + + public function test_memory_is_bounded_with_huge_batch_insert_statements(): void + { + $recorder = new QueryRecorder(new QueryRecorderOptions(maxQueries: 100, maxRetainedParameters: 10)); + + gc_collect_cycles(); + $baseline = memory_get_usage(); + + for ($i = 0; $i < 500; $i++) { + $recorder->add( + new RecordedQuery('INSERT INTO t VALUES ' . str_repeat("(\$1),", 20_000) . $i, [], 1.0, 1, false, null), + ); + } + + gc_collect_cycles(); + + static::assertSame(100, $recorder->retainedCount()); + static::assertLessThan(512 * 1024, memory_get_usage() - $baseline); + } +} diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/QueryLog.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/QueryLog.php deleted file mode 100644 index 7abeb47135..0000000000 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Debug/QueryLog.php +++ /dev/null @@ -1,31 +0,0 @@ - - */ - private array $queries = []; - - public function add(RecordedQuery $query): void - { - $this->queries[] = $query; - } - - /** - * @return list - */ - public function queries(): array - { - return $this->queries; - } - - public function reset(): void - { - $this->queries = []; - } -} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Debug/QueryLogTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Debug/QueryLogTest.php deleted file mode 100644 index 83633a00b9..0000000000 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Debug/QueryLogTest.php +++ /dev/null @@ -1,54 +0,0 @@ -queries()); - } - - public function test_add_appends_in_order(): void - { - $log = new QueryLog(); - $first = new RecordedQuery('SELECT 1', [], 0.5, 1, false, null); - $second = new RecordedQuery('SELECT 2', [7], 1.5, 0, false, null); - - $log->add($first); - $log->add($second); - - static::assertSame([$first, $second], $log->queries()); - } - - public function test_reset_clears_entries(): void - { - $log = new QueryLog(); - $log->add(new RecordedQuery('SELECT 1', [], 0.5, 1, false, null)); - - $log->reset(); - - static::assertSame([], $log->queries()); - } - - public function test_recorded_query_exposes_values(): void - { - $query = new RecordedQuery('SELECT * FROM t WHERE id = $1', [42], 2.25, 1, true, 'boom'); - - static::assertSame('SELECT * FROM t WHERE id = $1', $query->sql); - static::assertSame([42], $query->parameters); - static::assertSame(2.25, $query->durationMs); - static::assertSame(1, $query->rowCount); - static::assertTrue($query->failed); - static::assertSame('boom', $query->error); - } -}