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();