diff --git a/docs/book/sql-ddl/alter-drop.md b/docs/book/sql-ddl/alter-drop.md index c0105350..bd2bd895 100644 --- a/docs/book/sql-ddl/alter-drop.md +++ b/docs/book/sql-ddl/alter-drop.md @@ -430,6 +430,68 @@ $fk = new ForeignKey( ); ``` +### Table Prefixes + +`TableIdentifier` accepts an optional prefix and separator. When a prefix is +set, `getTable()` and `getTableAndSchema()` return the prefixed table name; +`getUnprefixedTable()` returns the table name as given: + +```php title="Prefixed Table Identifiers" +use PhpDb\Sql\TableIdentifier; + +$identifier = new TableIdentifier('users', null, 'backup'); +$identifier->getTable(); // 'backup_users' +$identifier->getUnprefixedTable(); // 'users' + +// Custom separator +$identifier = new TableIdentifier('users', null, 'backup', '__'); +$identifier->getTable(); // 'backup__users' +``` + +The prefix must be a non-empty string or `null`. The separator defaults to +`'_'` and must also be a non-empty string; passing `''` for either throws +`PhpDb\Sql\Exception\InvalidArgumentException`. + +### The TableIdentifierFactory + +`PhpDb\Sql\TableIdentifierFactory` is a callable factory that creates +`TableIdentifier` instances sharing a preconfigured prefix and separator: + +```php title="Creating Identifiers via the Factory" +use PhpDb\Sql\TableIdentifierFactory; + +$factory = new TableIdentifierFactory('backup'); + +$factory('users')->getTable(); // 'backup_users' +$factory('orders', 'sales')->getTable(); // 'backup_orders' + +// A prefix or separator passed at call time overrides the configured one +$factory('users', null, 'archive')->getTable(); // 'archive_users' +``` + +The configured separator is only applied when a prefix is passed, so a factory +created with a separator but no prefix produces unprefixed identifiers. Passing +`null` as the separator falls back to the default `'_'`; passing `''` — at +construction or at call time — throws +`PhpDb\Sql\Exception\InvalidArgumentException`. + +`PhpDb\ConfigProvider` registers the factory as a container service. Configure +the prefix and separator through the application config: + +```php title="Configuring the Factory Service" +use PhpDb\Sql\TableIdentifierFactory; + +return [ + TableIdentifierFactory::class => [ + 'prefix' => 'backup', + 'separator' => '_', + ], +]; +``` + +When no configuration is present the service is created without a prefix, and +the separator defaults to `'_'`. + ## Nullable and Default Values ### Setting Nullable diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index eb1758d9..78d69ac0 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -4,7 +4,7 @@ namespace PhpDb; -final class ConfigProvider +final readonly class ConfigProvider { public const NAMED_ADAPTER_KEY = 'adapters'; @@ -25,7 +25,8 @@ public function getDependencies(): array Adapter\AdapterInterface::class => Adapter\Adapter::class, ], 'factories' => [ - Adapter\Adapter::class => Container\AdapterInterfaceFactory::class, + Adapter\Adapter::class => Container\AdapterInterfaceFactory::class, + Sql\TableIdentifierFactory::class => Container\TableIdentifierFactoryFactory::class, ], ]; } diff --git a/src/Container/TableIdentifierFactoryFactory.php b/src/Container/TableIdentifierFactoryFactory.php new file mode 100644 index 00000000..6f918434 --- /dev/null +++ b/src/Container/TableIdentifierFactoryFactory.php @@ -0,0 +1,44 @@ + + * return [ + * TableIdentifierFactory::class => [ + * 'prefix' => 'backup', + * 'separator' => '_', + * ], + * ]; + * + * + * When no configuration is present the factory is created without a prefix. + * The separator defaults to '_' when not configured. + */ +final class TableIdentifierFactoryFactory +{ + public function __invoke(ContainerInterface $container): TableIdentifierFactory + { + /** @var array $config */ + $config = $container->has('config') ? $container->get('config') ?? [] : []; + + /** @var array{prefix?: string|null, separator?: string} $factoryConfig */ + $factoryConfig = $config[TableIdentifierFactory::class] ?? []; + + return new TableIdentifierFactory( + $factoryConfig['prefix'] ?? null, + $factoryConfig['separator'] ?? TableIdentifier::SEPARATOR, + ); + } +} diff --git a/src/Sql/TableIdentifier.php b/src/Sql/TableIdentifier.php index eb5daee8..76337e8d 100644 --- a/src/Sql/TableIdentifier.php +++ b/src/Sql/TableIdentifier.php @@ -4,38 +4,75 @@ namespace PhpDb\Sql; -class TableIdentifier +final readonly class TableIdentifier { - protected string $table; + public const SEPARATOR = '_'; - protected ?string $schema = null; - - public function __construct(string $table, ?string $schema = null) - { + /** + * @throws Exception\InvalidArgumentException If $table, $schema, $prefix or $separator is an empty string. + */ + public function __construct( + protected string $table, + protected ?string $schema = null, + protected ?string $prefix = null, + protected ?string $separator = self::SEPARATOR, + ) { if ('' === $table) { throw new Exception\InvalidArgumentException( '$table must be a valid table name, empty string given' ); } - $this->table = $table; + if ('' === $schema) { + throw new Exception\InvalidArgumentException( + '$schema must be a valid schema name or null, empty string given' + ); + } - if ($schema !== null) { - if ('' === $schema) { - throw new Exception\InvalidArgumentException( - '$schema must be a valid schema name or null, empty string given' - ); - } + if ('' === $prefix) { + throw new Exception\InvalidArgumentException( + '$prefix must be a valid table prefix or null, empty string given' + ); + } - $this->schema = $schema; + if ('' === $separator) { + throw new Exception\InvalidArgumentException( + '$separator must be a valid table separator, empty string given' + ); } } + /** + * Returns the table name with the prefix and separator applied, when a + * prefix is set. + */ public function getTable(): string + { + if ($this->prefix === null) { + return $this->table; + } + + return $this->prefix . $this->separator . $this->table; + } + + /** + * Returns the table name as given, without the prefix applied. + */ + public function getUnprefixedTable(): string { return $this->table; } + public function getPrefix(): ?string + { + return $this->prefix; + } + + public function getSeparator(): string + { + return $this->separator; + } + public function getSchema(): ?string { return $this->schema; @@ -44,6 +81,6 @@ public function getSchema(): ?string /** @return array{0: string, 1: null|string} */ public function getTableAndSchema(): array { - return [$this->table, $this->schema]; + return [$this->getTable(), $this->schema]; } } diff --git a/src/Sql/TableIdentifierFactory.php b/src/Sql/TableIdentifierFactory.php new file mode 100644 index 00000000..316803e4 --- /dev/null +++ b/src/Sql/TableIdentifierFactory.php @@ -0,0 +1,65 @@ +prefix; + } + + public function getSeparator(): string + { + return $this->separator ?? TableIdentifier::SEPARATOR; + } + + /** + * Creates a TableIdentifier carrying the configured prefix and separator. + * + * A prefix or separator passed at call time takes precedence over the + * configured one. + * + * @throws Exception\InvalidArgumentException If $prefix or $separator is an empty string. + */ + public function __invoke( + string $table, + ?string $schema = null, + ?string $prefix = null, + ?string $separator = null, + ): TableIdentifier { + return new TableIdentifier($table, $schema, $prefix ?? $this->prefix, $separator ?? $this->getSeparator()); + } +} diff --git a/test/unit/ConfigProviderTest.php b/test/unit/ConfigProviderTest.php index 9fc3a0e2..085f05ee 100644 --- a/test/unit/ConfigProviderTest.php +++ b/test/unit/ConfigProviderTest.php @@ -7,8 +7,13 @@ use PhpDb\Adapter; use PhpDb\ConfigProvider; use PhpDb\Container; +use PhpDb\Sql; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +#[CoversClass(ConfigProvider::class)] +#[Group('unit')] class ConfigProviderTest extends TestCase { /** @@ -29,7 +34,8 @@ class ConfigProviderTest extends TestCase Adapter\AdapterInterface::class => Adapter\Adapter::class, ], 'factories' => [ - Adapter\Adapter::class => Container\AdapterInterfaceFactory::class, + Adapter\Adapter::class => Container\AdapterInterfaceFactory::class, + Sql\TableIdentifierFactory::class => Container\TableIdentifierFactoryFactory::class, ], ], ]; diff --git a/test/unit/Container/TableIdentifierFactoryFactoryTest.php b/test/unit/Container/TableIdentifierFactoryFactoryTest.php new file mode 100644 index 00000000..9f37ca6f --- /dev/null +++ b/test/unit/Container/TableIdentifierFactoryFactoryTest.php @@ -0,0 +1,143 @@ +getPrefix()); + } + + public function testInvokeCreatesFactoryWithoutPrefixWhenConfigIsEmpty(): void + { + $container = new ServiceManager(); + $container->setService('config', []); + + $factory = new TableIdentifierFactoryFactory(); + $result = $factory($container); + + self::assertNull($result->getPrefix()); + } + + public function testInvokeCreatesFactoryWithConfiguredPrefix(): void + { + $container = new ServiceManager(); + $container->setService('config', [ + TableIdentifierFactory::class => [ + 'prefix' => 'backup', + ], + ]); + + $factory = new TableIdentifierFactoryFactory(); + $result = $factory($container); + + self::assertSame('backup', $result->getPrefix()); + } + + public function testInvokeCreatesFactoryWithConfiguredSeparator(): void + { + $container = new ServiceManager(); + $container->setService('config', [ + TableIdentifierFactory::class => [ + 'prefix' => 'backup', + 'separator' => '__', + ], + ]); + + $factory = new TableIdentifierFactoryFactory(); + $result = $factory($container); + + self::assertSame('__', $result->getSeparator()); + } + + public function testInvokeCreatesFactoryWithoutPrefixWhenConfigServiceIsNull(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->with('config')->willReturn(true); + $container->method('get')->with('config')->willReturn(null); + + $factory = new TableIdentifierFactoryFactory(); + $result = $factory($container); + + self::assertNull($result->getPrefix()); + } + + public function testInvokeCreatesFactoryWithConfiguredSeparatorWithoutPrefix(): void + { + $container = new ServiceManager(); + $container->setService('config', [ + TableIdentifierFactory::class => [ + 'separator' => '__', + ], + ]); + + $factory = new TableIdentifierFactoryFactory(); + $result = $factory($container); + + self::assertNull($result->getPrefix()); + self::assertSame('__', $result->getSeparator()); + } + + public function testInvokeUsesDefaultSeparatorWhenSeparatorKeyIsAbsent(): void + { + $container = new ServiceManager(); + $container->setService('config', [ + TableIdentifierFactory::class => [ + 'prefix' => 'backup', + ], + ]); + + $factory = new TableIdentifierFactoryFactory(); + $result = $factory($container); + + self::assertSame('_', $result->getSeparator()); + } + + public function testInvokeRejectsEmptyStringSeparatorFromConfig(): void + { + $container = new ServiceManager(); + $container->setService('config', [ + TableIdentifierFactory::class => [ + 'separator' => '', + ], + ]); + + $factory = new TableIdentifierFactoryFactory(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid table separator, empty string given'); + $factory($container); + } + + public function testInvokeCreatesFactoryWithoutPrefixWhenPrefixKeyIsAbsent(): void + { + $container = new ServiceManager(); + $container->setService('config', [ + TableIdentifierFactory::class => [], + ]); + + $factory = new TableIdentifierFactoryFactory(); + $result = $factory($container); + + self::assertNull($result->getPrefix()); + } +} diff --git a/test/unit/Sql/TableIdentifierFactoryTest.php b/test/unit/Sql/TableIdentifierFactoryTest.php new file mode 100644 index 00000000..28846ed9 --- /dev/null +++ b/test/unit/Sql/TableIdentifierFactoryTest.php @@ -0,0 +1,179 @@ +getPrefix()); + } + + public function testReturnsConfiguredPrefix(): void + { + $factory = new TableIdentifierFactory('backup'); + + self::assertSame('backup', $factory->getPrefix()); + } + + public function testSeparatorDefaultsToUnderscore(): void + { + $factory = new TableIdentifierFactory('backup'); + + self::assertSame('_', $factory->getSeparator()); + } + + public function testSeparatorDefaultsToUnderscoreWhenNoPrefixConfigured(): void + { + $factory = new TableIdentifierFactory(); + + self::assertSame('_', $factory->getSeparator()); + } + + public function testSeparatorFallsBackToDefaultWhenPassedAsNull(): void + { + $factory = new TableIdentifierFactory('backup', null); + + self::assertSame('_', $factory->getSeparator()); + } + + public function testReturnsConfiguredSeparator(): void + { + $factory = new TableIdentifierFactory('backup', '__'); + + self::assertSame('__', $factory->getSeparator()); + } + + public function testRejectsEmptyStringPrefix(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$prefix must be a valid table prefix or null, empty string given'); + new TableIdentifierFactory(''); + } + + public function testRejectsEmptyStringSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid table separator, empty string given'); + new TableIdentifierFactory('backup', ''); + } + + public function testRejectsEmptyStringSeparatorWithoutPrefix(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid table separator, empty string given'); + new TableIdentifierFactory(null, ''); + } + + public function testCreatesIdentifierWithConfiguredPrefix(): void + { + $factory = new TableIdentifierFactory('backup'); + $tableIdentifier = $factory('users'); + + self::assertSame('backup', $tableIdentifier->getPrefix()); + self::assertSame('backup_users', $tableIdentifier->getTable()); + self::assertNull($tableIdentifier->getSchema()); + } + + public function testCreatesIdentifierWithSchema(): void + { + $factory = new TableIdentifierFactory('backup'); + $tableIdentifier = $factory('users', 'public'); + + self::assertSame(['backup_users', 'public'], $tableIdentifier->getTableAndSchema()); + } + + public function testCreatesIdentifierWithConfiguredSeparator(): void + { + $factory = new TableIdentifierFactory('backup', '__'); + $tableIdentifier = $factory('users'); + + self::assertSame('__', $tableIdentifier->getSeparator()); + self::assertSame('backup__users', $tableIdentifier->getTable()); + } + + public function testCreatesIdentifierWithoutPrefixWhenNoneConfigured(): void + { + $factory = new TableIdentifierFactory(); + $tableIdentifier = $factory('users', 'public'); + + self::assertNull($tableIdentifier->getPrefix()); + self::assertSame('users', $tableIdentifier->getTable()); + } + + public function testCallTimePrefixOverridesConfiguredPrefix(): void + { + $factory = new TableIdentifierFactory('backup'); + $tableIdentifier = $factory('users', null, 'archive'); + + self::assertSame('archive', $tableIdentifier->getPrefix()); + self::assertSame('archive_users', $tableIdentifier->getTable()); + } + + public function testCallTimeSeparatorOverridesConfiguredSeparator(): void + { + $factory = new TableIdentifierFactory('backup', '__'); + $tableIdentifier = $factory('users', null, null, '_'); + + self::assertSame('_', $tableIdentifier->getSeparator()); + self::assertSame('backup_users', $tableIdentifier->getTable()); + } + + public function testCallTimePrefixIsAppliedWhenNonePreconfigured(): void + { + $factory = new TableIdentifierFactory(); + $tableIdentifier = $factory('users', null, 'archive'); + + self::assertSame('archive', $tableIdentifier->getPrefix()); + self::assertSame('archive_users', $tableIdentifier->getTable()); + } + + public function testCallTimeSeparatorIsAppliedWhenNonePreconfigured(): void + { + $factory = new TableIdentifierFactory(); + $tableIdentifier = $factory('users', null, 'archive', '__'); + + self::assertSame('__', $tableIdentifier->getSeparator()); + self::assertSame('archive__users', $tableIdentifier->getTable()); + } + + public function testConfiguredSeparatorIsCarriedButUnusedWhenNoPrefixApplies(): void + { + $factory = new TableIdentifierFactory(null, '__'); + $tableIdentifier = $factory('users'); + + self::assertSame('__', $tableIdentifier->getSeparator()); + self::assertNull($tableIdentifier->getPrefix()); + self::assertSame('users', $tableIdentifier->getTable()); + } + + public function testRejectsEmptyStringCallTimePrefix(): void + { + $factory = new TableIdentifierFactory('backup'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$prefix must be a valid table prefix or null, empty string given'); + $factory('users', null, ''); + } + + public function testRejectsEmptyStringCallTimeSeparator(): void + { + $factory = new TableIdentifierFactory('backup'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid table separator, empty string given'); + $factory('users', null, null, ''); + } +} diff --git a/test/unit/Sql/TableIdentifierTest.php b/test/unit/Sql/TableIdentifierTest.php index 2e6c717e..10f667f1 100644 --- a/test/unit/Sql/TableIdentifierTest.php +++ b/test/unit/Sql/TableIdentifierTest.php @@ -9,6 +9,7 @@ use PhpDbTest\TestAsset\ObjectToString; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use stdClass; use TypeError; @@ -19,6 +20,7 @@ * Tests for {@see TableIdentifier} */ #[CoversClass(TableIdentifier::class)] +#[Group('unit')] class TableIdentifierTest extends TestCase { public function testGetTable(): void @@ -63,6 +65,76 @@ public function testGetSchemaFromObjectStringCast(): void self::assertSame('castResult', $tableIdentifier->getSchema()); } + public function testGetDefaultPrefix(): void + { + $tableIdentifier = new TableIdentifier('foo'); + + self::assertNull($tableIdentifier->getPrefix()); + } + + public function testGetPrefix(): void + { + $tableIdentifier = new TableIdentifier('foo', null, 'backup'); + + self::assertSame('backup', $tableIdentifier->getPrefix()); + } + + public function testGetDefaultSeparator(): void + { + $tableIdentifier = new TableIdentifier('foo'); + + self::assertSame('_', $tableIdentifier->getSeparator()); + } + + public function testGetSeparator(): void + { + $tableIdentifier = new TableIdentifier('foo', null, 'backup', '__'); + + self::assertSame('__', $tableIdentifier->getSeparator()); + } + + public function testGetTableAppliesPrefixWithDefaultSeparator(): void + { + $tableIdentifier = new TableIdentifier('foo', null, 'backup'); + + self::assertSame('backup_foo', $tableIdentifier->getTable()); + } + + public function testGetTableAppliesPrefixWithCustomSeparator(): void + { + $tableIdentifier = new TableIdentifier('foo', null, 'backup', '__'); + + self::assertSame('backup__foo', $tableIdentifier->getTable()); + } + + public function testGetTableIgnoresSeparatorWithoutPrefix(): void + { + $tableIdentifier = new TableIdentifier('foo', null, null, '__'); + + self::assertSame('foo', $tableIdentifier->getTable()); + } + + public function testGetUnprefixedTableReturnsTableAsGiven(): void + { + $tableIdentifier = new TableIdentifier('foo', null, 'backup'); + + self::assertSame('foo', $tableIdentifier->getUnprefixedTable()); + } + + public function testGetTableAndSchemaAppliesPrefix(): void + { + $tableIdentifier = new TableIdentifier('foo', 'bar', 'backup'); + + self::assertSame(['backup_foo', 'bar'], $tableIdentifier->getTableAndSchema()); + } + + public function testGetTableAndSchemaWithoutPrefix(): void + { + $tableIdentifier = new TableIdentifier('foo', 'bar'); + + self::assertSame(['foo', 'bar'], $tableIdentifier->getTableAndSchema()); + } + #[DataProvider('invalidTableProvider')] public function testRejectsInvalidTable(mixed $invalidTable): void { @@ -71,7 +143,7 @@ public function testRejectsInvalidTable(mixed $invalidTable): void new TableIdentifier($invalidTable); } - #[DataProvider('invalidSchemaProvider')] + #[DataProvider('invalidNameArgumentProvider')] public function testRejectsInvalidSchema(mixed $invalidSchema): void { $this->expectException($invalidSchema === '' ? InvalidArgumentException::class : TypeError::class); @@ -79,6 +151,29 @@ public function testRejectsInvalidSchema(mixed $invalidSchema): void new TableIdentifier('foo', $invalidSchema); } + #[DataProvider('invalidNameArgumentProvider')] + public function testRejectsInvalidPrefix(mixed $invalidPrefix): void + { + $this->expectException($invalidPrefix === '' ? InvalidArgumentException::class : TypeError::class); + /** @psalm-suppress MixedArgument */ + new TableIdentifier('foo', 'bar', $invalidPrefix); + } + + #[DataProvider('invalidNameArgumentProvider')] + public function testRejectsInvalidSeparator(mixed $invalidSeparator): void + { + $this->expectException($invalidSeparator === '' ? InvalidArgumentException::class : TypeError::class); + /** @psalm-suppress MixedArgument */ + new TableIdentifier('foo', 'bar', 'backup', $invalidSeparator); + } + + public function testRejectsEmptyStringSeparatorWithoutPrefix(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid table separator, empty string given'); + new TableIdentifier('foo', null, null, ''); + } + /** * Data provider * @@ -87,8 +182,8 @@ public function testRejectsInvalidSchema(mixed $invalidSchema): void public static function invalidTableProvider(): array { return array_merge( - [[null]], - self::invalidSchemaProvider() + ['null' => [null]], + self::invalidNameArgumentProvider() ); } @@ -97,12 +192,12 @@ public static function invalidTableProvider(): array * * @return array[] */ - public static function invalidSchemaProvider(): array + public static function invalidNameArgumentProvider(): array { return [ - [''], - [new stdClass()], - [[]], + 'empty string' => [''], + 'object' => [new stdClass()], + 'array' => [[]], ]; } }