diff --git a/composer.json b/composer.json index 052b70a..03337f2 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "require": { "php": ">=8.1", "cycle/orm": "^2.18", - "cycle/database": "^2.20", + "cycle/database": "^2.23", "yiisoft/friendly-exception": "^1.1" }, "require-dev": { diff --git a/psalm-baseline.xml b/psalm-baseline.xml index aeb576b..0e2b5e3 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -370,32 +370,20 @@ - - - relations[$entity][$name]]]> - tables[$entity]['database']]]> - tables[$entity]['schema']]]> - tables[$entity]['table']]]> - - - children[$entity]]]> relations[$entity]]]> relations[$entity][$name]]]> - tables[$entity]['database']]]> - tables[$entity]['schema']]]> - tables[$entity]['table']]]> diff --git a/src/Registry.php b/src/Registry.php index a65d7f4..df994a8 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -20,7 +20,15 @@ final class Registry implements \IteratorAggregate private array $entities = []; private DatabaseProviderInterface $dbal; + + /** + * @var \SplObjectStorage< + * Entity, + * array{database: string, table: non-empty-string, schema: AbstractTable|null}|null + * > + */ private \SplObjectStorage $tables; + private \SplObjectStorage $children; private \SplObjectStorage $relations; private Defaults $defaults; @@ -143,33 +151,12 @@ public function linkTable(Entity $entity, ?string $database, string $table): sel $database = $this->dbal->database($database)->getName(); - $schema = null; - foreach ($this->tables as $other) { - $association = $this->tables[$other]; - - if ($association === null) { - continue; - } - - // avoid schema duplication - if ($association['database'] === $database && $association['table'] === $table) { - $schema = $association['schema']; - break; - } - } - - if ($schema === null) { - $dbTable = $this->dbal->database($database)->table($table); - if (!\method_exists($dbTable, 'getSchema')) { - throw new RegistryException('Unable to retrieve table schema.'); - } - $schema = $dbTable->getSchema(); - } - + // Table schemas are loaded lazily and in bulk: by the time the first schema is requested + // all the linked tables are known, so the whole set costs a constant number of queries. $this->tables[$entity] = [ 'database' => $database, 'table' => $table, - 'schema' => $schema, + 'schema' => null, ]; return $this; @@ -192,11 +179,7 @@ public function hasTable(Entity $entity): bool */ public function getDatabase(Entity $entity): string { - if (!$this->hasTable($entity)) { - throw new RegistryException("Entity `{$entity->getRole()}` has no assigned table"); - } - - return $this->tables[$entity]['database']; + return $this->getTableAssociation($entity)['database']; } /** @@ -206,11 +189,7 @@ public function getDatabase(Entity $entity): string */ public function getTable(Entity $entity): string { - if (!$this->hasTable($entity)) { - throw new RegistryException("Entity `{$entity->getRole()}` has no assigned table"); - } - - return $this->tables[$entity]['table']; + return $this->getTableAssociation($entity)['table']; } /** @@ -218,11 +197,15 @@ public function getTable(Entity $entity): string */ public function getTableSchema(Entity $entity): AbstractTable { - if (!$this->hasTable($entity)) { - throw new RegistryException("Entity `{$entity->getRole()}` has no assigned table"); + $schema = $this->getTableAssociation($entity)['schema']; + + if ($schema === null) { + $this->loadTableSchemas(); + $schema = $this->getTableAssociation($entity)['schema']; + \assert($schema !== null); } - return $this->tables[$entity]['schema']; + return $schema; } /** @@ -286,4 +269,87 @@ protected function hasInstance(Entity $entity): bool { return array_search($entity, $this->entities, true) !== false; } + + /** + * @return array{database: string, table: non-empty-string, schema: AbstractTable|null} + * + * @throws RegistryException + */ + private function getTableAssociation(Entity $entity): array + { + if (!$this->hasInstance($entity)) { + throw new RegistryException("Undefined entity `{$entity->getRole()}`"); + } + + $association = $this->tables[$entity]; + + if ($association === null) { + throw new RegistryException("Entity `{$entity->getRole()}` has no assigned table"); + } + + return $association; + } + + /** + * Load schemas for all the linked tables that don't have one yet. Entities sharing the same + * database and table receive the same {@see AbstractTable} instance. + * + * @throws RegistryException + * @throws DBALException + */ + private function loadTableSchemas(): void + { + /** @var array> $loaded */ + $loaded = []; + /** @var array> $pending */ + $pending = []; + foreach ($this->tables as $entity) { + $association = $this->tables[$entity]; + if ($association === null) { + continue; + } + + if ($association['schema'] !== null) { + $loaded[$association['database']][$association['table']] = $association['schema']; + } else { + $pending[$association['database']][$association['table']] = true; + } + } + + foreach ($pending as $database => $tables) { + // avoid schema duplication + $names = \array_keys(\array_diff_key($tables, $loaded[$database] ?? [])); + if ($names === []) { + continue; + } + + $db = $this->dbal->database($database); + if (\method_exists($db, 'getSchemas')) { + /** @var array $schemas */ + $schemas = $db->getSchemas($names); + $loaded[$database] = ($loaded[$database] ?? []) + $schemas; + continue; + } + + foreach ($names as $name) { + $dbTable = $db->table($name); + if (!\method_exists($dbTable, 'getSchema')) { + throw new RegistryException('Unable to retrieve table schema.'); + } + /** @var AbstractTable $schema */ + $schema = $dbTable->getSchema(); + $loaded[$database][$name] = $schema; + } + } + + foreach ($this->tables as $entity) { + $association = $this->tables[$entity]; + if ($association === null || $association['schema'] !== null) { + continue; + } + + $association['schema'] = $loaded[$association['database']][$association['table']]; + $this->tables[$entity] = $association; + } + } } diff --git a/tests/Schema/Fixtures/LegacyDatabase.php b/tests/Schema/Fixtures/LegacyDatabase.php new file mode 100644 index 0000000..6f03dcd --- /dev/null +++ b/tests/Schema/Fixtures/LegacyDatabase.php @@ -0,0 +1,126 @@ +database->getName(); + } + + public function table(string $name): TableInterface + { + if ($this->bareTables) { + return new LegacyTable($this->database->table($name)); + } + + return $this->database->table($name); + } + + public function getType(): string + { + return $this->database->getType(); + } + + public function getDriver(int $type = self::WRITE): DriverInterface + { + return $this->database->getDriver($type); + } + + public function withPrefix(string $prefix, bool $add = true): DatabaseInterface + { + throw new \BadMethodCallException(__METHOD__ . ' is not expected to be called'); + } + + public function getPrefix(): string + { + return $this->database->getPrefix(); + } + + public function hasTable(string $name): bool + { + return $this->database->hasTable($name); + } + + public function getTables(): array + { + return $this->database->getTables(); + } + + public function execute(string $query, array $parameters = []): int + { + return $this->database->execute($query, $parameters); + } + + public function query(string $query, array $parameters = []): StatementInterface + { + return $this->database->query($query, $parameters); + } + + public function insert(string $table = ''): InsertQuery + { + return $this->database->insert($table); + } + + public function update(string $table = '', array $values = [], array $where = []): UpdateQuery + { + return $this->database->update($table, $values, $where); + } + + public function delete(string $table = '', array $where = []): DeleteQuery + { + return $this->database->delete($table, $where); + } + + public function select(mixed $columns = '*'): SelectQuery + { + return $this->database->select($columns); + } + + public function transaction(callable $callback, ?string $isolationLevel = null): mixed + { + return $this->database->transaction($callback, $isolationLevel); + } + + public function begin(?string $isolationLevel = null): bool + { + return $this->database->begin($isolationLevel); + } + + public function commit(): bool + { + return $this->database->commit(); + } + + public function rollback(): bool + { + return $this->database->rollback(); + } +} diff --git a/tests/Schema/Fixtures/LegacyTable.php b/tests/Schema/Fixtures/LegacyTable.php new file mode 100644 index 0000000..bdf2a0e --- /dev/null +++ b/tests/Schema/Fixtures/LegacyTable.php @@ -0,0 +1,73 @@ +table->exists(); + } + + public function getName(): string + { + return $this->table->getName(); + } + + public function getFullName(): string + { + return $this->table->getFullName(); + } + + public function getPrimaryKeys(): array + { + return $this->table->getPrimaryKeys(); + } + + public function hasColumn(string $name): bool + { + return $this->table->hasColumn($name); + } + + public function getColumns(): array + { + return $this->table->getColumns(); + } + + public function hasIndex(array $columns = []): bool + { + return $this->table->hasIndex($columns); + } + + public function getIndexes(): array + { + return $this->table->getIndexes(); + } + + public function hasForeignKey(array $columns): bool + { + return $this->table->hasForeignKey($columns); + } + + public function getForeignKeys(): array + { + return $this->table->getForeignKeys(); + } + + public function getDependencies(): array + { + return $this->table->getDependencies(); + } +} diff --git a/tests/Schema/RegistryTest.php b/tests/Schema/RegistryTest.php index 1614c6a..e6741c1 100644 --- a/tests/Schema/RegistryTest.php +++ b/tests/Schema/RegistryTest.php @@ -4,12 +4,14 @@ namespace Cycle\Schema\Tests; +use Cycle\Database\Schema\AbstractTable; use Cycle\Schema\Compiler; use Cycle\Schema\Definition\Entity; use Cycle\Schema\Definition\Field; use Cycle\Schema\Exception\RegistryException; use Cycle\Schema\Registry; use Cycle\Schema\Tests\Fixtures\Author; +use Cycle\Schema\Tests\Fixtures\LegacyDatabase; use Cycle\Schema\Tests\Fixtures\Post; use Cycle\Schema\Tests\Fixtures\User; @@ -113,6 +115,99 @@ public function testGetTableSchemaException(): void $r->getTableSchema(new Entity()); } + public function testGetTableNotLinked(): void + { + $r = new Registry($this->dbal); + + $e = new Entity(); + $e->setRole('user')->setClass(User::class); + $r->register($e); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Entity `user` has no assigned table'); + + $r->getTable($e); + } + + public function testLinkTableDoesNotLoadSchema(): void + { + $r = new Registry($this->dbal); + + $e = new Entity(); + $e->setRole('user')->setClass(User::class); + $r->register($e)->linkTable($e, 'default', 'user'); + + $this->assertTrue($r->hasTable($e)); + $this->assertSame('default', $r->getDatabase($e)); + $this->assertSame('user', $r->getTable($e)); + } + + public function testEntitiesOnSameTableShareSchema(): void + { + $r = new Registry($this->dbal); + + $e = new Entity(); + $e->setRole('user')->setClass(User::class); + + $e2 = new Entity(); + $e2->setRole('author')->setClass(Author::class); + + $r->register($e)->linkTable($e, 'default', 'user'); + $r->register($e2)->linkTable($e2, 'default', 'user'); + + $this->assertSame($r->getTableSchema($e), $r->getTableSchema($e2)); + } + + public function testLateLinkedEntityReusesLoadedSchema(): void + { + $r = new Registry($this->dbal); + + $e = new Entity(); + $e->setRole('user')->setClass(User::class); + $r->register($e)->linkTable($e, 'default', 'user'); + + // triggers the bulk load of every linked table + $schema = $r->getTableSchema($e); + + // an entity linked after the load (embedded relations do this) must reuse the instance + $e2 = new Entity(); + $e2->setRole('author')->setClass(Author::class); + $r->register($e2)->linkTable($e2, 'default', 'user'); + + $this->assertSame($schema, $r->getTableSchema($e2)); + } + + public function testLinkTableWithoutGetSchemasSupport(): void + { + $r = new Registry(new LegacyDatabase($this->dbal->database('default'))); + + $e = new Entity(); + $e->setRole('user')->setClass(User::class); + + $e2 = new Entity(); + $e2->setRole('author')->setClass(Author::class); + + $r->register($e)->linkTable($e, 'default', 'user'); + $r->register($e2)->linkTable($e2, 'default', 'user'); + + $this->assertInstanceOf(AbstractTable::class, $r->getTableSchema($e)); + $this->assertSame($r->getTableSchema($e), $r->getTableSchema($e2)); + } + + public function testLinkTableWithoutSchemaSupportThrowsAnException(): void + { + $r = new Registry(new LegacyDatabase($this->dbal->database('default'), bareTables: true)); + + $e = new Entity(); + $e->setRole('user')->setClass(User::class); + $r->register($e)->linkTable($e, 'default', 'user'); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Unable to retrieve table schema.'); + + $r->getTableSchema($e); + } + public function testRegisterChildNoEntity(): void { $e = new Entity();