Skip to content

Commit 9bd1b4a

Browse files
authored
Merge pull request #2407 from flow-php/migrations-exclusion-policy
feature: postgresql bundle - migrations exclusion policy
2 parents 547e174 + b64f56b commit 9bd1b4a

29 files changed

Lines changed: 1152 additions & 43 deletions

documentation/components/bridges/symfony-postgresql-bundle.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,69 @@ flow_postgresql:
163163

164164
Multiple catalog providers can be combined. They are merged via `ChainCatalogProvider` into a single catalog.
165165

166+
### Exclusions
167+
168+
By default `flow:migrations:diff` compares **every** object found in the database against the catalog, so any
169+
object that is not described by a catalog provider is reported as a difference to drop. That is a problem when some
170+
objects are created outside of migrations — for example tables generated dynamically from user-uploaded content, or
171+
a whole schema owned by another system. List those objects under `migrations.exclude` and they are skipped while the
172+
diff is generated (the migrations tracking table is always excluded automatically).
173+
174+
```yaml
175+
flow_postgresql:
176+
migrations:
177+
enabled: true
178+
exclude:
179+
- { schema: tenant_data } # exclude an entire schema and everything in it
180+
- { table: legacy_audit } # exclude a table by exact name
181+
- { starts_with: user_upload_ } # exclude objects whose name starts with a prefix
182+
- { ends_with: _tmp, type: view } # ...ending with a suffix, narrowed to views
183+
- { pattern: '/^cache_\d+$/', type: sequence } # ...matching a PCRE pattern, narrowed to sequences
184+
- { exact: scratch, for_schema: staging } # exact name, narrowed to the "staging" schema
185+
- { policy_id: app.my_exclusion_policy } # delegate to a custom ExclusionPolicy service
186+
```
187+
188+
Each entry defines **exactly one** matcher:
189+
190+
| Key | Excludes |
191+
|---------------|--------------------------------------------------------------------|
192+
| `schema` | The named schema and every object inside it (tables, views, sequences, functions, …) |
193+
| `table` | A table by exact name (shorthand for `exact` narrowed to tables) |
194+
| `exact` | Any object whose name matches exactly |
195+
| `starts_with` | Any object whose name starts with the given prefix |
196+
| `ends_with` | Any object whose name ends with the given suffix |
197+
| `pattern` | Any object whose name matches the given PCRE pattern (with delimiters) |
198+
| `policy_id` | Delegates to a service implementing `Flow\PostgreSql\Schema\Exclusion\ExclusionPolicy` |
199+
200+
The `exact`, `starts_with`, `ends_with`, `pattern` and `policy_id` matchers can be narrowed with optional scopes:
201+
202+
- `type` — limit to a single object type: `table`, `view`, `materialized_view`, `sequence`, `function`,
203+
`procedure`, `domain` or `extension`. When omitted, the matcher applies to every type.
204+
- `for_schema` — limit to a single schema. When omitted, the matcher applies to every schema.
205+
206+
Tables and whole schemas are filtered before they are introspected, so excluding dynamically created tables also
207+
avoids the cost of reading their columns, indexes and constraints on every diff.
208+
209+
A custom `policy_id` service implements the same interface and receives every candidate object:
210+
211+
```php
212+
<?php
213+
214+
namespace App\Database;
215+
216+
use Flow\PostgreSql\Schema\Exclusion\ExclusionPolicy;
217+
use Flow\PostgreSql\Schema\Exclusion\SchemaObject;
218+
219+
final class MyExclusionPolicy implements ExclusionPolicy
220+
{
221+
public function exclude(SchemaObject $object): bool
222+
{
223+
// $object->type, $object->schema and $object->name are available
224+
return str_contains($object->name ?? '', '__generated__');
225+
}
226+
}
227+
```
228+
166229
## Console Commands
167230

168231
### Database Commands

src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@
3838
use Flow\PostgreSql\Migrations\Store\MigrationStore;
3939
use Flow\PostgreSql\Migrations\VersionGenerator\TimestampVersionGenerator;
4040
use Flow\PostgreSql\Migrations\VersionResolver;
41+
use Flow\PostgreSql\Schema\Exclusion\AnyExclusionPolicy;
42+
use Flow\PostgreSql\Schema\Exclusion\EndsWithExclusionPolicy;
43+
use Flow\PostgreSql\Schema\Exclusion\ExactMatchExclusionPolicy;
44+
use Flow\PostgreSql\Schema\Exclusion\PatternExclusionPolicy;
45+
use Flow\PostgreSql\Schema\Exclusion\ScopedExclusionPolicy;
46+
use Flow\PostgreSql\Schema\Exclusion\SchemaObjectType;
47+
use Flow\PostgreSql\Schema\Exclusion\StartsWithExclusionPolicy;
48+
use Flow\PostgreSql\Schema\Exclusion\WholeSchemaExclusionPolicy;
4149
use Flow\Telemetry\Provider\Clock\SystemClock;
4250
use LogicException;
4351
use Override;
@@ -324,6 +332,50 @@ public function configure(DefinitionConfigurator $definition): void
324332
->defaultTrue()
325333
->info('Generate rollback files when creating migrations (default: true)')
326334
->end()
335+
->arrayNode('exclude')
336+
->info('Schema objects excluded from migration diffing (e.g. tables created dynamically at runtime). Each entry defines exactly one matcher: schema, table, exact, starts_with, ends_with, pattern or policy_id.')
337+
->arrayPrototype()
338+
->children()
339+
->scalarNode('schema')
340+
->defaultNull()
341+
->info('Exclude an entire schema with all of its objects.')
342+
->end()
343+
->scalarNode('table')
344+
->defaultNull()
345+
->info('Exclude a table by exact name (shorthand for exact scoped to tables).')
346+
->end()
347+
->scalarNode('exact')
348+
->defaultNull()
349+
->info('Exclude any object whose name matches exactly.')
350+
->end()
351+
->scalarNode('starts_with')
352+
->defaultNull()
353+
->info('Exclude objects whose name starts with this prefix.')
354+
->end()
355+
->scalarNode('ends_with')
356+
->defaultNull()
357+
->info('Exclude objects whose name ends with this suffix.')
358+
->end()
359+
->scalarNode('pattern')
360+
->defaultNull()
361+
->info('Exclude objects whose name matches this PCRE pattern (with delimiters).')
362+
->end()
363+
->scalarNode('policy_id')
364+
->defaultNull()
365+
->info('Service ID of a custom Flow\\PostgreSql\\Schema\\Exclusion\\ExclusionPolicy.')
366+
->end()
367+
->enumNode('type')
368+
->values(['table', 'view', 'materialized_view', 'sequence', 'function', 'procedure', 'domain', 'extension'])
369+
->defaultNull()
370+
->info('Narrow the matcher to a single object type (ignored when using "schema" or "table").')
371+
->end()
372+
->scalarNode('for_schema')
373+
->defaultNull()
374+
->info('Narrow the matcher to a single schema.')
375+
->end()
376+
->end()
377+
->end()
378+
->end()
327379
->end()
328380
->end()
329381
->arrayNode('catalog_providers')
@@ -347,7 +399,7 @@ public function configure(DefinitionConfigurator $definition): void
347399
}
348400

349401
/**
350-
* @param array{connections: array<string, array{dsn: string, test_transaction_rollback: bool, context?: array<string, mixed>, 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}}>, messenger: array{enabled: bool, table_name: string, schema: string}, cache: array{pools?: array<string, array{connection: ?string, table_name: string, schema: string, id_col: string, data_col: string, lifetime_col: string, time_col: string, namespace: string, default_lifetime: int, marshaller_service_id: ?string, share_connection: bool}>}, 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}, catalog_providers: list<array{catalog_provider_id: ?string, catalog: ?array<string, mixed>}>} $config
402+
* @param array{connections: array<string, array{dsn: string, test_transaction_rollback: bool, context?: array<string, mixed>, 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}}>, messenger: array{enabled: bool, table_name: string, schema: string}, cache: array{pools?: array<string, array{connection: ?string, table_name: string, schema: string, id_col: string, data_col: string, lifetime_col: string, time_col: string, namespace: string, default_lifetime: int, marshaller_service_id: ?string, share_connection: bool}>}, 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, exclude?: list<array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string}>}, catalog_providers: list<array{catalog_provider_id: ?string, catalog: ?array<string, mixed>}>} $config
351403
*/
352404
#[Override]
353405
public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void
@@ -578,7 +630,7 @@ private function registerMessenger(
578630
}
579631

580632
/**
581-
* @param 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} $mc
633+
* @param 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, exclude?: list<array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string}>} $mc
582634
*/
583635
private function registerMigrations(string $name, array $mc, ContainerBuilder $container, bool $isFirst): void
584636
{
@@ -595,6 +647,7 @@ private function registerMigrations(string $name, array $mc, ContainerBuilder $c
595647
$mc['rollback_file_name'],
596648
$mc['all_or_nothing'],
597649
$mc['generate_rollback'],
650+
$this->buildExclusionPolicy($mc['exclude'] ?? []),
598651
]);
599652
$configDef->setPublic(true);
600653
$container->setDefinition("flow.postgresql.{$name}.migrations.configuration", $configDef);
@@ -684,6 +737,61 @@ private function registerMigrations(string $name, array $mc, ContainerBuilder $c
684737
}
685738
}
686739

740+
/**
741+
* @param list<array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string}> $exclude
742+
*/
743+
private function buildExclusionPolicy(array $exclude): ?Definition
744+
{
745+
if ($exclude === []) {
746+
return null;
747+
}
748+
749+
$policies = [];
750+
751+
foreach ($exclude as $entry) {
752+
$policies[] = $this->buildExclusionPolicyEntry($entry);
753+
}
754+
755+
return new Definition(AnyExclusionPolicy::class, $policies);
756+
}
757+
758+
/**
759+
* @param array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string} $entry
760+
*/
761+
private function buildExclusionPolicyEntry(array $entry): Definition|Reference
762+
{
763+
if ($entry['schema'] !== null) {
764+
return new Definition(WholeSchemaExclusionPolicy::class, [$entry['schema']]);
765+
}
766+
767+
$impliedType = null;
768+
769+
if ($entry['table'] !== null) {
770+
$matcher = new Definition(ExactMatchExclusionPolicy::class, [$entry['table']]);
771+
$impliedType = SchemaObjectType::TABLE;
772+
} elseif ($entry['exact'] !== null) {
773+
$matcher = new Definition(ExactMatchExclusionPolicy::class, [$entry['exact']]);
774+
} elseif ($entry['starts_with'] !== null) {
775+
$matcher = new Definition(StartsWithExclusionPolicy::class, [$entry['starts_with']]);
776+
} elseif ($entry['ends_with'] !== null) {
777+
$matcher = new Definition(EndsWithExclusionPolicy::class, [$entry['ends_with']]);
778+
} elseif ($entry['pattern'] !== null) {
779+
$matcher = new Definition(PatternExclusionPolicy::class, [$entry['pattern']]);
780+
} elseif ($entry['policy_id'] !== null) {
781+
$matcher = new Reference($entry['policy_id']);
782+
} else {
783+
throw new LogicException('Each "flow_postgresql.migrations.exclude" entry must define exactly one of: schema, table, exact, starts_with, ends_with, pattern, policy_id.');
784+
}
785+
786+
$type = $entry['type'] !== null ? SchemaObjectType::from($entry['type']) : $impliedType;
787+
788+
if ($type === null && $entry['for_schema'] === null) {
789+
return $matcher;
790+
}
791+
792+
return new Definition(ScopedExclusionPolicy::class, [$matcher, $type, $entry['for_schema']]);
793+
}
794+
687795
/**
688796
* @param 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} $sessionConfig
689797
* @param list<string> $connectionNames

src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/FlowPostgreSqlExtensionTest.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
use Flow\PostgreSql\Migrations\VersionResolver;
3838
use Flow\PostgreSql\Schema\Catalog;
3939
use Flow\PostgreSql\Schema\ChainCatalogProvider;
40+
use Flow\PostgreSql\Schema\Exclusion\AnyExclusionPolicy;
41+
use Flow\PostgreSql\Schema\Exclusion\SchemaObject;
42+
use Flow\PostgreSql\Schema\Exclusion\SchemaObjectType;
4043
use Flow\Telemetry\Provider\Clock\SystemClock;
4144
use Flow\Telemetry\Telemetry;
4245
use LogicException;
@@ -450,6 +453,88 @@ public function test_class_aliases_for_migration_services(): void
450453
static::assertTrue($this->getContainer()->has(DiffMigrationGenerator::class));
451454
}
452455

456+
public function test_migrations_exclude_config_builds_exclusion_policy(): void
457+
{
458+
$this->bootKernel([
459+
'config' => static function (TestKernel $kernel): void {
460+
$kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void {
461+
$container->register('flow.postgresql.default.client', SpyClient::class)->setPublic(true);
462+
});
463+
$kernel->addTestExtensionConfig('flow_postgresql', [
464+
'connections' => [
465+
'default' => [
466+
'dsn' => 'postgresql://postgres:postgres@localhost:5432/postgres',
467+
],
468+
],
469+
'migrations' => [
470+
'enabled' => true,
471+
'directory' => '/tmp/test_migrations',
472+
'namespace' => 'App\\Migrations',
473+
'exclude' => [
474+
['schema' => 'tenant_data'],
475+
['starts_with' => 'user_upload_'],
476+
['ends_with' => '_tmp', 'type' => 'view'],
477+
['pattern' => '/^cache_\d+$/', 'type' => 'sequence'],
478+
['table' => 'legacy_audit'],
479+
],
480+
],
481+
'catalog_providers' => [
482+
['catalog' => ['schemas' => []]],
483+
],
484+
]);
485+
},
486+
]);
487+
488+
$configuration = $this->getContainer()->get('flow.postgresql.default.migrations.configuration');
489+
static::assertInstanceOf(MigrationsConfiguration::class, $configuration);
490+
491+
$policy = $configuration->exclusionPolicy;
492+
static::assertInstanceOf(AnyExclusionPolicy::class, $policy);
493+
494+
static::assertTrue($policy->exclude(new SchemaObject(SchemaObjectType::TABLE, 'tenant_data', 'uploads')));
495+
static::assertTrue($policy->exclude(new SchemaObject(SchemaObjectType::TABLE, 'public', 'user_upload_9')));
496+
497+
static::assertTrue($policy->exclude(new SchemaObject(SchemaObjectType::VIEW, 'public', 'report_tmp')));
498+
static::assertFalse($policy->exclude(new SchemaObject(SchemaObjectType::TABLE, 'public', 'report_tmp')));
499+
500+
static::assertTrue($policy->exclude(new SchemaObject(SchemaObjectType::SEQUENCE, 'public', 'cache_42')));
501+
static::assertFalse($policy->exclude(new SchemaObject(SchemaObjectType::TABLE, 'public', 'cache_42')));
502+
503+
static::assertTrue($policy->exclude(new SchemaObject(SchemaObjectType::TABLE, 'public', 'legacy_audit')));
504+
static::assertFalse($policy->exclude(new SchemaObject(SchemaObjectType::SEQUENCE, 'public', 'legacy_audit')));
505+
506+
static::assertFalse($policy->exclude(new SchemaObject(SchemaObjectType::TABLE, 'public', 'orders')));
507+
}
508+
509+
public function test_migrations_exclude_entry_without_matcher_throws(): void
510+
{
511+
$this->expectException(LogicException::class);
512+
$this->expectExceptionMessage('must define exactly one of');
513+
514+
$this->bootKernel([
515+
'config' => static function (TestKernel $kernel): void {
516+
$kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void {
517+
$container->register('flow.postgresql.default.client', SpyClient::class)->setPublic(true);
518+
});
519+
$kernel->addTestExtensionConfig('flow_postgresql', [
520+
'connections' => [
521+
'default' => [
522+
'dsn' => 'postgresql://postgres:postgres@localhost:5432/postgres',
523+
],
524+
],
525+
'migrations' => [
526+
'enabled' => true,
527+
'directory' => '/tmp/test_migrations',
528+
'namespace' => 'App\\Migrations',
529+
'exclude' => [
530+
['for_schema' => 'public'],
531+
],
532+
],
533+
]);
534+
},
535+
]);
536+
}
537+
453538
public function test_client_with_telemetry_creates_default_clock_when_not_specified(): void
454539
{
455540
$this->bootKernel([

0 commit comments

Comments
 (0)