From b7c6cc302add8af4d84233b884fed45e6c40e4d2 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Mon, 22 Jun 2026 13:41:23 +0200 Subject: [PATCH] feat(flow-php/symfony-postgresql-bundle): add Flow Migrations profiler panel - new data collector and panel showing executed/pending/unavailable migrations per connection - toggle via profiler.migrations config (default: true) - expose migration executionTimeMs through MigrationStat --- .../bridges/symfony-postgresql-bundle.md | 14 +- .../PostgreSqlBundle/FlowPostgreSqlBundle.php | 38 ++- .../Profiler/FlowMigrationsDataCollector.php | 169 ++++++++++++ .../Resources/config/profiler_migrations.php | 26 ++ .../views/Collector/migrations.html.twig | 128 +++++++++ .../Fixtures/config/profiler_panel_routes.php | 2 + .../Profiler/FlowMigrationsProfilerTest.php | 197 ++++++++++++++ .../FlowMigrationsDataCollectorTest.php | 256 ++++++++++++++++++ .../PostgreSql/Migrations/MigrationStatus.php | 1 + .../Flow/PostgreSql/Migrations/Migrator.php | 2 + .../Tests/Integration/MigratorTest.php | 8 + .../Tests/Unit/MigrationStatusListTest.php | 15 + 12 files changed, 853 insertions(+), 3 deletions(-) create mode 100644 src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/FlowMigrationsDataCollector.php create mode 100644 src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/profiler_migrations.php create mode 100644 src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/views/Collector/migrations.html.twig create mode 100644 src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Profiler/FlowMigrationsProfilerTest.php create mode 100644 src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/FlowMigrationsDataCollectorTest.php diff --git a/documentation/components/bridges/symfony-postgresql-bundle.md b/documentation/components/bridges/symfony-postgresql-bundle.md index 25272de91f..a61774772f 100644 --- a/documentation/components/bridges/symfony-postgresql-bundle.md +++ b/documentation/components/bridges/symfony-postgresql-bundle.md @@ -133,10 +133,9 @@ and failures. ```yaml flow_postgresql: profiler: - # enabled: null (default) auto-enables when WebProfilerBundle is registered; true forces it on - # (throws if WebProfilerBundle is absent); false forces it off entirely. enabled: ~ include_parameters: true # show bound query parameters in the panel + migrations: true # show the Flow Migrations panel (requires migrations enabled) ``` Recording is dev-only and adds nothing in production: when the profiler is disabled — or @@ -151,6 +150,17 @@ flow_postgresql: profiler: false # do not record queries in selected connection (default: true) ``` +When `migrations` are enabled, a separate **Flow Migrations** panel reports each 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` +to disable it while keeping the query panel: + +```yaml +flow_postgresql: + profiler: + migrations: false # do not query/show migration status in the profiler (default: true) +``` + ### Migrations Migrations are configured at the top level, not per connection. Use `--connection` to target a specific connection 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 9b124a87a6..e8b72636df 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 @@ -475,13 +475,17 @@ public function configure(DefinitionConfigurator $definition): void ->info('Show bound query parameters in the panel') ->defaultTrue() ->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() + ->end() ->end() ->end() ->end(); } /** - * @param array{connections: array, telemetry?: array{service_id: string, clock_service_id: ?string, trace_queries: bool, trace_transactions: bool, 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, 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}} $config + * @param array{connections: array, telemetry?: array{service_id: string, clock_service_id: ?string, trace_queries: bool, trace_transactions: bool, 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, 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 */ #[Override] public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void @@ -520,6 +524,38 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $this->registerCache($config['cache'] ?? [], $connectionNames, $container); $this->registerSession($config['session'] ?? [], $connectionNames, $container); $this->registerProfiler($config, $configurator, $container, $connectionNames); + $this->registerMigrationsProfiler($config, $configurator, $container); + } + + /** + * @param array $config + */ + private function registerMigrationsProfiler( + array $config, + ContainerConfigurator $configurator, + ContainerBuilder $container, + ): void { + $migrationsConfig = is_array($config['migrations'] ?? null) ? $config['migrations'] : []; + + if (($migrationsConfig['enabled'] ?? false) !== true) { + return; + } + + $profilerConfig = is_array($config['profiler'] ?? null) ? $config['profiler'] : []; + // @mago-expect analysis:mixed-assignment + $enabled = $profilerConfig['enabled'] ?? null; + + if ($enabled === false || ($profilerConfig['migrations'] ?? true) === false) { + return; + } + + $hasWebProfiler = $this->isWebProfilerBundleRegistered($container); + + if ($enabled === null && !$hasWebProfiler) { + return; + } + + $configurator->import(__DIR__ . '/Resources/config/profiler_migrations.php'); } /** diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/FlowMigrationsDataCollector.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/FlowMigrationsDataCollector.php new file mode 100644 index 0000000000..4bf96aadf0 --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Profiler/FlowMigrationsDataCollector.php @@ -0,0 +1,169 @@ +, configuration: null|ConfigurationData, error: null|string} + */ +final class FlowMigrationsDataCollector extends DataCollector +{ + /** + * @param list $connections + */ + public function __construct( + private readonly ContainerInterface $locator, + private readonly array $connections, + ) {} + + public function collect(Request $request, Response $response, ?Throwable $exception = null): void + { + if ($this->data !== []) { + return; + } + + $connections = []; + + foreach ($this->connections as $name) { + $connections[$name] = $this->collectConnection($name); + } + + $this->data = ['connections' => $connections]; + } + + public function reset(): void + { + $this->data = []; + } + + public function getName(): string + { + return 'flow_postgresql_migrations'; + } + + /** + * @return array + */ + public function getConnections(): array + { + // @mago-expect analysis:mixed-return-statement + return $this->data['connections'] ?? []; + } + + public function getExecutedCount(): int + { + return $this->sum('executed'); + } + + public function getPendingCount(): int + { + return $this->sum('pending'); + } + + public function getUnavailableCount(): int + { + return $this->sum('unavailable'); + } + + public function getTotalCount(): int + { + return $this->sum('total'); + } + + /** + * @return ConnectionData + */ + private function collectConnection(string $name): array + { + try { + $configuration = $this->describeConfiguration($name); + $status = type_instance_of(Migrator::class) + ->assert($this->locator->get("flow.postgresql.{$name}.migrations.migrator")) + ->status(); + + $migrations = []; + $counts = [ + MigrationState::EXECUTED->value => 0, + MigrationState::PENDING->value => 0, + MigrationState::UNAVAILABLE->value => 0, + ]; + + foreach ($status as $migration) { + $counts[$migration->state->value]++; + $migrations[] = [ + 'version' => (string) $migration->version, + 'name' => $migration->name, + 'state' => $migration->state->value, + 'executedAt' => $migration->executedAt?->format('Y-m-d H:i:s'), + 'executionTimeMs' => $migration->executionTimeMs, + ]; + } + + return [ + 'total' => count($status), + 'executed' => $counts[MigrationState::EXECUTED->value], + 'pending' => $counts[MigrationState::PENDING->value], + 'unavailable' => $counts[MigrationState::UNAVAILABLE->value], + 'migrations' => $migrations, + 'configuration' => $configuration, + 'error' => null, + ]; + } catch (Throwable $exception) { + return [ + 'total' => 0, + 'executed' => 0, + 'pending' => 0, + 'unavailable' => 0, + 'migrations' => [], + 'configuration' => null, + 'error' => $exception->getMessage(), + ]; + } + } + + /** + * @return ConfigurationData + */ + private function describeConfiguration(string $name): array + { + $configuration = type_instance_of(Configuration::class)->assert($this->locator->get( + "flow.postgresql.{$name}.migrations.configuration", + )); + + return [ + 'tableName' => $configuration->tableName, + 'tableSchema' => $configuration->tableSchema, + 'directory' => $configuration->migrationsDirectory, + 'namespace' => $configuration->migrationsNamespace, + 'allOrNothing' => $configuration->allOrNothing, + ]; + } + + /** + * @param 'executed'|'pending'|'total'|'unavailable' $key + */ + private function sum(string $key): int + { + return (int) array_sum(array_map( + static fn(array $connection): int => (int) ($connection[$key] ?? 0), + $this->getConnections(), + )); + } +} diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/profiler_migrations.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/profiler_migrations.php new file mode 100644 index 0000000000..77c27c1ec0 --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/profiler_migrations.php @@ -0,0 +1,26 @@ +services(); + + $services + ->set('flow.postgresql.profiler.migrations_collector', FlowMigrationsDataCollector::class) + ->args([ + service('flow.postgresql.command_locator'), + param('flow.postgresql.migrations.connections'), + ]) + ->public() + ->tag('data_collector', [ + 'id' => 'flow_postgresql_migrations', + 'template' => '@FlowPostgreSql/Collector/migrations.html.twig', + 'priority' => 239, + ]); +}; diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/views/Collector/migrations.html.twig b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/views/Collector/migrations.html.twig new file mode 100644 index 0000000000..ecd5de500c --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/views/Collector/migrations.html.twig @@ -0,0 +1,128 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% macro migrations_icon() %} + +{% endmacro %} + +{% block toolbar %} + {% set status_color = collector.unavailableCount > 0 ? 'yellow' : '' %} + {% set status_color = collector.pendingCount > 0 ? 'red' : status_color %} + + {% set icon %} + {{ _self.migrations_icon() }} + {{ collector.pendingCount + collector.unavailableCount }} + {% endset %} + + {% set text %} +
+ Executed + {{ collector.executedCount }} +
+
+ Pending + {{ collector.pendingCount }} +
+
+ Unavailable + {{ collector.unavailableCount }} +
+
+ Total + {{ collector.totalCount }} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: collector.totalCount > 0 ? status_color : 'disabled' }) }} +{% endblock %} + +{% block menu %} + + {{ _self.migrations_icon() }} + Flow Migrations + {% if collector.pendingCount + collector.unavailableCount > 0 %} + {{ collector.pendingCount + collector.unavailableCount }} + {% endif %} + +{% endblock %} + +{% block panel %} +

Flow PostgreSQL Migrations

+ +
+
+ {{ collector.executedCount }} + Executed +
+
+ {{ collector.pendingCount }} + Pending +
+
+ {{ collector.unavailableCount }} + Unavailable +
+
+ {{ collector.totalCount }} + Total +
+
+ + {% for connection, data in collector.connections %} +

Connection: {{ connection }}

+ + {% if data.error %} +

{{ data.error }}

+ {% endif %} + + {% if data.migrations is empty %} + {% if not data.error %} +

No migrations found for this connection.

+ {% endif %} + {% else %} + + + + + + + + + + + + {% for migration in data.migrations %} + + + + + + + + {% endfor %} + +
VersionNameStatusExecuted atExecution time
{{ migration.version }}{{ migration.name }} + {% if migration.state == 'executed' %} + Executed + {% elseif migration.state == 'pending' %} + Pending + {% else %} + Unavailable + {% endif %} + {{ migration.executedAt ?? '—' }}{{ migration.executionTimeMs is not null ? migration.executionTimeMs ~ ' ms' : '—' }}
+ {% endif %} + + {% if data.configuration %} + + + + + + + + + + +
Configuration
Table{{ data.configuration.tableSchema }}.{{ data.configuration.tableName }}
Directory{{ data.configuration.directory }}
Namespace{{ data.configuration.namespace }}
All or nothing{{ data.configuration.allOrNothing ? 'true' : 'false' }}
+ {% endif %} + {% endfor %} +{% endblock %} diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/config/profiler_panel_routes.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/config/profiler_panel_routes.php index 027fd37b87..0a75e43922 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/config/profiler_panel_routes.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/config/profiler_panel_routes.php @@ -9,7 +9,9 @@ return static function (RoutingConfigurator $routes): void { if (InstalledVersions::satisfies(new VersionParser(), 'symfony/web-profiler-bundle', '^6.4.0')) { $routes->import('@WebProfilerBundle/Resources/config/routing/profiler.xml')->prefix('/_profiler'); + $routes->import('@WebProfilerBundle/Resources/config/routing/wdt.xml')->prefix('/_wdt'); } else { $routes->import('@WebProfilerBundle/Resources/config/routing/profiler.php')->prefix('/_profiler'); + $routes->import('@WebProfilerBundle/Resources/config/routing/wdt.php')->prefix('/_wdt'); } }; diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Profiler/FlowMigrationsProfilerTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Profiler/FlowMigrationsProfilerTest.php new file mode 100644 index 0000000000..50e023e115 --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Profiler/FlowMigrationsProfilerTest.php @@ -0,0 +1,197 @@ +parse($this->dsn())); + $client->execute('DROP TABLE IF EXISTS users, ' . self::STORE_TABLE . ' CASCADE'); + $client->close(); + + restore_exception_handler(); + parent::tearDown(); + } + + public function test_collector_registered_when_migrations_and_profiler_enabled(): void + { + $container = $this->boot(['enabled' => true])->getContainer(); + + static::assertTrue($container->has('flow.postgresql.profiler.migrations_collector')); + static::assertInstanceOf( + FlowMigrationsDataCollector::class, + $container->get('flow.postgresql.profiler.migrations_collector'), + ); + } + + public function test_collector_not_registered_when_toggle_disabled(): void + { + $container = $this->boot(['enabled' => true, 'migrations' => false])->getContainer(); + + static::assertFalse($container->has('flow.postgresql.profiler.migrations_collector')); + } + + public function test_collector_not_registered_when_migrations_disabled(): void + { + $container = $this->boot(['enabled' => true], migrationsEnabled: false)->getContainer(); + + static::assertFalse($container->has('flow.postgresql.profiler.migrations_collector')); + } + + public function test_collect_reports_executed_and_pending_with_execution_time(): void + { + $container = $this->boot(['enabled' => true])->getContainer(); + + type_instance_of(Migrator::class) + ->assert($container->get('flow.postgresql.default.migrations.migrator')) + ->migrate(Version::fromString('20260401120000')); + + $collector = type_instance_of(FlowMigrationsDataCollector::class)->assert($container->get( + 'flow.postgresql.profiler.migrations_collector', + )); + $collector->collect(new Request(), new Response()); + + static::assertSame(1, $collector->getExecutedCount()); + static::assertGreaterThan(0, $collector->getPendingCount()); + + $connection = $collector->getConnections()['default']; + static::assertNull($connection['error']); + $configuration = $connection['configuration']; + static::assertIsArray($configuration); + static::assertSame(self::STORE_TABLE, $configuration['tableName']); + + $executed = array_values(array_filter( + $connection['migrations'], + static fn(array $migration): bool => $migration['state'] === 'executed', + )); + + static::assertCount(1, $executed); + static::assertSame('20260401120000', $executed[0]['version']); + static::assertNotNull($executed[0]['executedAt']); + static::assertNotNull($executed[0]['executionTimeMs']); + } + + public function test_toolbar_and_panel_render_without_error(): void + { + $kernel = $this->boot(['enabled' => true]); + $container = $kernel->getContainer(); + + type_instance_of(Migrator::class) + ->assert($container->get('flow.postgresql.default.migrations.migrator')) + ->migrate(Version::fromString('20260401120000')); + + type_instance_of(Router::class) + ->assert($container->get('router')) + ->getRouteCollection() + ->add('probe', new Route('/probe', ['_controller' => QueryController::class . '::run'])); + + $request = Request::create('/probe', 'GET'); + $response = $kernel->handle($request); + $token = (string) $response->headers->get('X-Debug-Token'); + $kernel->terminate($request, $response); + + // A Twig error in the toolbar/menu blocks would surface as a 500 here. + $toolbar = $kernel->handle(Request::create('/_wdt/' . $token)); + static::assertSame(200, $toolbar->getStatusCode()); + static::assertStringContainsString('M12 2 2 7l10 5', (string) $toolbar->getContent()); + + $panel = $kernel->handle(Request::create('/_profiler/' . $token . '?panel=flow_postgresql_migrations')); + $html = (string) $panel->getContent(); + + static::assertSame(200, $panel->getStatusCode()); + static::assertStringContainsString('Flow PostgreSQL Migrations', $html); + static::assertStringContainsString('Flow Migrations', $html); + static::assertStringContainsString('20260401120000', $html); + static::assertStringContainsString('status-success', $html); + static::assertStringContainsString('status-warning', $html); + } + + /** + * @param array{enabled?: bool|null, include_parameters?: bool, migrations?: bool} $profilerConfig + */ + private function boot(array $profilerConfig, bool $migrationsEnabled = true): TestKernel + { + return $this->bootKernel([ + 'config' => function (TestKernel $kernel) use ($profilerConfig, $migrationsEnabled): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestBundle(TwigBundle::class); + $kernel->addTestBundle(WebProfilerBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'router' => [ + 'utf8' => true, + 'resource' => __DIR__ . '/../../Fixtures/config/profiler_panel_routes.php', + ], + 'profiler' => ['enabled' => true, 'collect' => true], + ]); + $kernel->addTestExtensionConfig('twig', ['debug' => true, 'strict_variables' => false]); + $kernel->addTestExtensionConfig('web_profiler', ['toolbar' => true, 'intercept_redirects' => false]); + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container->register('test.catalog_provider', SimpleTestCatalogProvider::class)->setPublic(true); + + $controller = new Definition(QueryController::class, [new Reference( + 'flow.postgresql.default.client', + )]); + $controller->setPublic(true); + $controller->addTag('controller.service_arguments'); + $container->setDefinition(QueryController::class, $controller); + }); + $kernel->addTestExtensionConfig('flow_postgresql', [ + 'connections' => ['default' => ['dsn' => $this->dsn()]], + 'migrations' => [ + 'enabled' => $migrationsEnabled, + 'directory' => self::FIXTURE_MIGRATIONS_DIR, + 'namespace' => 'FlowTest\\Migrations', + 'table_name' => self::STORE_TABLE, + ], + 'catalog_providers' => [['catalog_provider_id' => 'test.catalog_provider']], + 'profiler' => $profilerConfig, + ]); + }, + ]); + } + + private function dsn(): string + { + return getenv('PGSQL_DATABASE_URL') ?: 'postgresql://postgres:postgres@127.0.0.1:5452/postgres'; + } +} diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/FlowMigrationsDataCollectorTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/FlowMigrationsDataCollectorTest.php new file mode 100644 index 0000000000..087eca4f3f --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/Profiler/FlowMigrationsDataCollectorTest.php @@ -0,0 +1,256 @@ +getName(), + ); + } + + public function test_collects_executed_and_pending_with_execution_time(): void + { + $repository = new FakeMigrationRepository( + new AvailableMigration( + Version::fromString('20260401120000'), + 'create_users', + new SpyMigration(), + new SpyRollback(), + ), + new AvailableMigration( + Version::fromString('20260402100000'), + 'seed_data', + new SpyMigration(), + new SpyRollback(), + ), + ); + $store = new FakeMigrationStore(); + $store->initialize(); + $store->complete(Version::fromString('20260401120000'), 15); + + $container = new Container(); + $client = new SpyClient(); + $container->set( + 'flow.postgresql.default.migrations.migrator', + new Migrator( + $repository, + $store, + new SpyMigrationExecutor(), + $client, + new Configuration( + $client, + new FakeCatalogProvider(new Catalog([])), + '/tmp/migrations', + 'App\\Migrations', + ), + ), + ); + $container->set( + 'flow.postgresql.default.migrations.configuration', + new Configuration($client, new FakeCatalogProvider(new Catalog([])), '/tmp/migrations', 'App\\Migrations'), + ); + + $collector = new FlowMigrationsDataCollector($container, ['default']); + $collector->collect(new Request(), new Response()); + + static::assertSame(2, $collector->getTotalCount()); + static::assertSame(1, $collector->getExecutedCount()); + static::assertSame(1, $collector->getPendingCount()); + static::assertSame(0, $collector->getUnavailableCount()); + + $connection = $collector->getConnections()['default']; + static::assertNull($connection['error']); + $configuration = $connection['configuration']; + static::assertIsArray($configuration); + static::assertSame('flow_migrations', $configuration['tableName']); + + $executed = $connection['migrations'][0]; + static::assertSame('20260401120000', $executed['version']); + static::assertSame('create_users', $executed['name']); + static::assertSame('executed', $executed['state']); + static::assertNotNull($executed['executedAt']); + static::assertSame(15, $executed['executionTimeMs']); + + $pending = $connection['migrations'][1]; + static::assertSame('pending', $pending['state']); + static::assertNull($pending['executedAt']); + static::assertNull($pending['executionTimeMs']); + } + + public function test_reports_unavailable_migration(): void + { + $repository = new FakeMigrationRepository(); + $store = new FakeMigrationStore(); + $store->initialize(); + $store->complete(Version::fromString('20260401120000'), 5); + + $container = new Container(); + $client = new SpyClient(); + $container->set( + 'flow.postgresql.default.migrations.migrator', + new Migrator( + $repository, + $store, + new SpyMigrationExecutor(), + $client, + new Configuration( + $client, + new FakeCatalogProvider(new Catalog([])), + '/tmp/migrations', + 'App\\Migrations', + ), + ), + ); + $container->set( + 'flow.postgresql.default.migrations.configuration', + new Configuration($client, new FakeCatalogProvider(new Catalog([])), '/tmp/migrations', 'App\\Migrations'), + ); + + $collector = new FlowMigrationsDataCollector($container, ['default']); + $collector->collect(new Request(), new Response()); + + static::assertSame(1, $collector->getUnavailableCount()); + static::assertSame('unavailable', $collector->getConnections()['default']['migrations'][0]['state']); + } + + public function test_aggregates_across_multiple_connections(): void + { + $container = new Container(); + $client = new SpyClient(); + + foreach (['default', 'analytics'] as $name) { + $repository = new FakeMigrationRepository( + new AvailableMigration( + Version::fromString('20260401120000'), + 'create_users', + new SpyMigration(), + new SpyRollback(), + ), + ); + $container->set( + "flow.postgresql.{$name}.migrations.migrator", + new Migrator( + $repository, + new FakeMigrationStore(), + new SpyMigrationExecutor(), + $client, + new Configuration( + $client, + new FakeCatalogProvider(new Catalog([])), + '/tmp/migrations', + 'App\\Migrations', + ), + ), + ); + $container->set( + "flow.postgresql.{$name}.migrations.configuration", + new Configuration( + $client, + new FakeCatalogProvider(new Catalog([])), + '/tmp/migrations', + 'App\\Migrations', + ), + ); + } + + $collector = new FlowMigrationsDataCollector($container, ['default', 'analytics']); + $collector->collect(new Request(), new Response()); + + static::assertSame(['default', 'analytics'], array_keys($collector->getConnections())); + static::assertSame(2, $collector->getPendingCount()); + static::assertSame(2, $collector->getTotalCount()); + } + + public function test_collect_degrades_gracefully_when_connection_fails(): void + { + $collector = new FlowMigrationsDataCollector(new Container(), ['default']); + $collector->collect(new Request(), new Response()); + + $connection = $collector->getConnections()['default']; + static::assertNotNull($connection['error']); + static::assertNull($connection['configuration']); + static::assertSame(0, $collector->getTotalCount()); + static::assertSame([], $connection['migrations']); + } + + public function test_collect_is_idempotent_within_a_request(): void + { + $repository = new FakeMigrationRepository( + new AvailableMigration( + Version::fromString('20260401120000'), + 'create_users', + new SpyMigration(), + new SpyRollback(), + ), + ); + $store = new FakeMigrationStore(); + + $container = new Container(); + $client = new SpyClient(); + $container->set( + 'flow.postgresql.default.migrations.migrator', + new Migrator( + $repository, + $store, + new SpyMigrationExecutor(), + $client, + new Configuration( + $client, + new FakeCatalogProvider(new Catalog([])), + '/tmp/migrations', + 'App\\Migrations', + ), + ), + ); + $container->set( + 'flow.postgresql.default.migrations.configuration', + new Configuration($client, new FakeCatalogProvider(new Catalog([])), '/tmp/migrations', 'App\\Migrations'), + ); + + $collector = new FlowMigrationsDataCollector($container, ['default']); + $collector->collect(new Request(), new Response()); + $store->complete(Version::fromString('20260401120000'), 5); + $collector->collect(new Request(), new Response()); + + static::assertSame(1, $collector->getPendingCount()); + static::assertSame(0, $collector->getExecutedCount()); + } + + public function test_reset_clears_data(): void + { + $collector = new FlowMigrationsDataCollector(new Container(), ['default']); + $collector->collect(new Request(), new Response()); + $collector->reset(); + + static::assertSame([], $collector->getConnections()); + static::assertSame(0, $collector->getTotalCount()); + } +} diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/MigrationStatus.php b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/MigrationStatus.php index acb0f37ba7..9e0fc9f474 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/MigrationStatus.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/MigrationStatus.php @@ -13,5 +13,6 @@ public function __construct( public string $name, public MigrationState $state, public ?DateTimeImmutable $executedAt, + public ?int $executionTimeMs = null, ) {} } diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Migrator.php b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Migrator.php index 0f2d238673..5891152f0f 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Migrator.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Migrator.php @@ -146,6 +146,7 @@ public function status(): MigrationStatusList $migration->name, MigrationState::EXECUTED, $em->executedAt, + $em->executionTimeMs, ); } else { $statuses[] = new MigrationStatus($migration->version, $migration->name, MigrationState::PENDING, null); @@ -159,6 +160,7 @@ public function status(): MigrationStatusList (string) $em->version, MigrationState::UNAVAILABLE, $em->executedAt, + $em->executionTimeMs, ); } } diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Integration/MigratorTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Integration/MigratorTest.php index bce4e0f1f6..5759d3ed51 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Integration/MigratorTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Integration/MigratorTest.php @@ -187,6 +187,14 @@ public function test_status_returns_correct_states(): void static::assertCount(3, $status); static::assertCount(1, $status->executed()); static::assertCount(2, $status->pending()); + + foreach ($status->executed() as $executed) { + static::assertNotNull($executed->executionTimeMs); + } + + foreach ($status->pending() as $pending) { + static::assertNull($pending->executionTimeMs); + } } private function fixtureRepository(): FakeMigrationRepository diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigrationStatusListTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigrationStatusListTest.php index 06d9311448..e3fa7ca9cd 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigrationStatusListTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigrationStatusListTest.php @@ -71,6 +71,21 @@ public function test_executed_filter(): void } } + public function test_execution_time_round_trips(): void + { + $executed = new MigrationStatus( + Version::fromString('20260401120000'), + 'first', + MigrationState::EXECUTED, + new DateTimeImmutable(), + 42, + ); + $pending = new MigrationStatus(Version::fromString('20260402120000'), 'second', MigrationState::PENDING, null); + + static::assertSame(42, $executed->executionTimeMs); + static::assertNull($pending->executionTimeMs); + } + public function test_iteration(): void { $s1 = new MigrationStatus(Version::fromString('20260401120000'), 'first', MigrationState::PENDING, null);