diff --git a/src/Database/Connection.php b/src/Database/Connection.php index c25cb4a2..3edc9f87 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -116,6 +116,12 @@ public function getSupplementalDriver(): Driver } + public function getReflection(): Reflection + { + return new Reflection($this->getDriver()); + } + + public function setRowNormalizer(?callable $normalizer): static { $this->rowNormalizer = $normalizer; diff --git a/src/Database/Drivers/SqlsrvDriver.php b/src/Database/Drivers/SqlsrvDriver.php index ccd8326f..61e0bd3c 100644 --- a/src/Database/Drivers/SqlsrvDriver.php +++ b/src/Database/Drivers/SqlsrvDriver.php @@ -198,7 +198,7 @@ public function getForeignKeys(string $table): array fk.name AS name, cl.name AS local, tf.name AS [table], - cf.name AS [column] + cf.name AS [foreign] FROM sys.foreign_keys fk JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id diff --git a/src/Database/Reflection.php b/src/Database/Reflection.php new file mode 100644 index 00000000..609a426d --- /dev/null +++ b/src/Database/Reflection.php @@ -0,0 +1,84 @@ + */ + public readonly array $tables; + private ?string $schema; + + + public function __construct( + private readonly Driver $driver, + ) { + $this->schema = $this->driver->isSupported(Driver::SUPPORT_SCHEMA) ? 'public' : null; + unset($this->tables); + } + + + /** @return Table[] */ + public function getTables(): array + { + return array_values($this->tables); + } + + + public function getTable(string $name): Table + { + $name = $this->getFullName($name); + return $this->tables[$name] ?? throw new \InvalidArgumentException("Table '$name' not found."); + } + + + public function hasTable(string $name): bool + { + $name = $this->getFullName($name); + return isset($this->tables[$name]); + } + + + private function getFullName(string $name): string + { + return $this->schema !== null && !str_contains($name, '.') + ? $this->schema . '.' . $name + : $name; + } + + + /** @internal */ + public function getDriver(): Driver + { + return $this->driver; + } + + + private function initTables(): void + { + $res = []; + foreach ($this->driver->getTables() as $row) { + $res[$row['fullName'] ?? $row['name']] = new Table($this, $row['name'], $row['view'], $row['fullName'] ?? null); + } + $this->tables = $res; + } + + + public function __get($name): mixed + { + match ($name) { + 'tables' => $this->initTables(), + default => throw new \LogicException("Undefined property '$name'."), + }; + return $this->$name; + } +} diff --git a/src/Database/Reflection/Column.php b/src/Database/Reflection/Column.php new file mode 100644 index 00000000..48eaa87c --- /dev/null +++ b/src/Database/Reflection/Column.php @@ -0,0 +1,37 @@ +name; + } +} diff --git a/src/Database/Reflection/ForeignKey.php b/src/Database/Reflection/ForeignKey.php new file mode 100644 index 00000000..a4808ae4 --- /dev/null +++ b/src/Database/Reflection/ForeignKey.php @@ -0,0 +1,34 @@ +name; + } +} diff --git a/src/Database/Reflection/Index.php b/src/Database/Reflection/Index.php new file mode 100644 index 00000000..f54898d5 --- /dev/null +++ b/src/Database/Reflection/Index.php @@ -0,0 +1,33 @@ +name; + } +} diff --git a/src/Database/Reflection/Table.php b/src/Database/Reflection/Table.php new file mode 100644 index 00000000..d4beb4cc --- /dev/null +++ b/src/Database/Reflection/Table.php @@ -0,0 +1,114 @@ + */ + public readonly array $columns; + + /** @var list */ + public readonly array $indexes; + public readonly ?Index $primaryKey; + + /** @var list */ + public readonly array $foreignKeys; + + + /** @internal */ + public function __construct( + private readonly Reflection $reflection, + public readonly string $name, + public readonly bool $view = false, + public readonly ?string $fullName = null, + ) { + unset($this->columns, $this->indexes, $this->primaryKey, $this->foreignKeys); + } + + + public function getColumn(string $name): Column + { + return $this->columns[$name] ?? throw new \InvalidArgumentException("Column '$name' not found in table '$this->name'."); + } + + + private function initColumns(): void + { + $res = []; + foreach ($this->reflection->getDriver()->getColumns($this->name) as $row) { + $res[$row['name']] = new Column($row['name'], $this, $row['nativetype'], $row['size'], $row['nullable'], $row['default'], $row['autoincrement'], $row['primary'], $row['vendor']); + } + $this->columns = $res; + } + + + private function initIndexes(): void + { + $this->indexes = array_map( + fn($row) => new Index( + array_map(fn($name) => $this->getColumn($name), $row['columns']), + $row['unique'], + $row['primary'], + is_string($row['name']) ? $row['name'] : null, + ), + $this->reflection->getDriver()->getIndexes($this->name), + ); + } + + + private function initPrimaryKey(): void + { + $res = array_filter( + $this->columns, + fn($row) => $row->primary, + ); + $this->primaryKey = $res ? new Index(array_values($res), true, true) : null; + } + + + private function initForeignKeys(): void + { + $tmp = []; + foreach ($this->reflection->getDriver()->getForeignKeys($this->name) as $row) { + $id = $row['name']; + $foreignTable = $this->reflection->getTable($row['table']); + $tmp[$id][0] = $foreignTable; + $tmp[$id][1][] = $this->getColumn($row['local']); + $tmp[$id][2][] = $foreignTable->getColumn($row['foreign']); + $tmp[$id][3] = is_string($id) ? $id : null; + } + $this->foreignKeys = array_map(fn($row) => new ForeignKey(...$row), array_values($tmp)); + } + + + public function __get($name): mixed + { + match ($name) { + 'columns' => $this->initColumns(), + 'indexes' => $this->initIndexes(), + 'primaryKey' => $this->initPrimaryKey(), + 'foreignKeys' => $this->initForeignKeys(), + default => throw new \LogicException("Undefined property '$name'."), + }; + return $this->$name; + } + + + public function __toString(): string + { + return $this->name; + } +} diff --git a/tests/Database/Reflection.driver.phpt b/tests/Database/Reflection.driver.phpt new file mode 100644 index 00000000..2a85dde3 --- /dev/null +++ b/tests/Database/Reflection.driver.phpt @@ -0,0 +1,192 @@ +getDriver(); +$tables = $driver->getTables(); +$tables = array_filter($tables, fn($t) => in_array($t['name'], ['author', 'book', 'book_tag', 'tag'], true)); +usort($tables, fn($a, $b) => strcmp($a['name'], $b['name'])); + +if ($driver->isSupported(Driver::SUPPORT_SCHEMA)) { + Assert::same( + [ + ['name' => 'author', 'view' => false, 'fullName' => 'public.author'], + ['name' => 'book', 'view' => false, 'fullName' => 'public.book'], + ['name' => 'book_tag', 'view' => false, 'fullName' => 'public.book_tag'], + ['name' => 'tag', 'view' => false, 'fullName' => 'public.tag'], + ], + $tables, + ); +} else { + Assert::same([ + ['name' => 'author', 'view' => false], + ['name' => 'book', 'view' => false], + ['name' => 'book_tag', 'view' => false], + ['name' => 'tag', 'view' => false], + ], $tables); +} + + +$columns = $driver->getColumns('author'); +array_walk($columns, function (&$item) { + Assert::type('array', $item['vendor']); + unset($item['vendor']); +}); + +$expectedColumns = [ + [ + 'name' => 'id', + 'table' => 'author', + 'nativetype' => 'INT', + 'size' => 11, + 'nullable' => false, + 'default' => null, + 'autoincrement' => true, + 'primary' => true, + ], + [ + 'name' => 'name', + 'table' => 'author', + 'nativetype' => 'VARCHAR', + 'size' => 30, + 'nullable' => false, + 'default' => null, + 'autoincrement' => false, + 'primary' => false, + ], + [ + 'name' => 'web', + 'table' => 'author', + 'nativetype' => 'VARCHAR', + 'size' => 100, + 'nullable' => false, + 'default' => null, + 'autoincrement' => false, + 'primary' => false, + ], + [ + 'name' => 'born', + 'table' => 'author', + 'nativetype' => 'DATE', + 'size' => null, + 'nullable' => true, + 'default' => null, + 'autoincrement' => false, + 'primary' => false, + ], +]; + +switch ($driverName) { + case 'mysql': + $version = $connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + if (version_compare($version, '8.0', '>=')) { + $expectedColumns[0]['size'] = null; + } + break; + case 'pgsql': + $expectedColumns[0]['nativetype'] = 'INT4'; + $expectedColumns[0]['default'] = "nextval('author_id_seq'::regclass)"; + $expectedColumns[0]['size'] = null; + break; + case 'sqlite': + $expectedColumns[0]['nativetype'] = 'INTEGER'; + $expectedColumns[0]['size'] = null; + $expectedColumns[1]['nativetype'] = 'TEXT'; + $expectedColumns[1]['size'] = null; + $expectedColumns[2]['nativetype'] = 'TEXT'; + $expectedColumns[2]['size'] = null; + break; + case 'sqlsrv': + $expectedColumns[0]['size'] = null; + $expectedColumns[1]['size'] = null; + $expectedColumns[2]['size'] = null; + break; + default: + Assert::fail("Unsupported driver $driverName"); +} + +Assert::same($expectedColumns, $columns); + + +$indexes = $driver->getIndexes('book_tag'); +switch ($driverName) { + case 'pgsql': + Assert::same([ + [ + 'name' => 'book_tag_pkey', + 'unique' => true, + 'primary' => true, + 'columns' => [ + 'book_id', + 'tag_id', + ], + ], + ], $indexes); + break; + case 'sqlite': + Assert::same([ + [ + 'name' => 'sqlite_autoindex_book_tag_1', + 'unique' => true, + 'primary' => true, + 'columns' => [ + 'book_id', + 'tag_id', + ], + ], + ], $indexes); + break; + case 'sqlsrv': + Assert::same([ + [ + 'name' => 'PK_book_tag', + 'unique' => true, + 'primary' => true, + 'columns' => [ + 'book_id', + 'tag_id', + ], + ], + ], $indexes); + break; + case 'mysql': + Assert::same([ + [ + 'name' => 'PRIMARY', + 'unique' => true, + 'primary' => true, + 'columns' => [ + 'book_id', + 'tag_id', + ], + ], + [ + 'name' => 'book_tag_tag', + 'unique' => false, + 'primary' => false, + 'columns' => [ + 'tag_id', + ], + ], + ], $indexes); + break; + default: + Assert::fail("Unsupported driver $driverName"); +} + +$structure->rebuild(); +$primary = $structure->getPrimaryKey('book_tag'); +Assert::same(['book_id', 'tag_id'], $primary); diff --git a/tests/Database/Reflection.phpt b/tests/Database/Reflection.phpt index 2a85dde3..5f953cab 100644 --- a/tests/Database/Reflection.phpt +++ b/tests/Database/Reflection.phpt @@ -15,76 +15,98 @@ require __DIR__ . '/connect.inc.php'; // create $connection Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/files/{$driverName}-nette_test1.sql"); -$driver = $connection->getDriver(); -$tables = $driver->getTables(); -$tables = array_filter($tables, fn($t) => in_array($t['name'], ['author', 'book', 'book_tag', 'tag'], true)); -usort($tables, fn($a, $b) => strcmp($a['name'], $b['name'])); +$reflection = $connection->getReflection(); +$schemaSupported = $connection->getDriver()->isSupported(Driver::SUPPORT_SCHEMA); -if ($driver->isSupported(Driver::SUPPORT_SCHEMA)) { +// table names +$tableNames = array_keys($reflection->tables); +if ($schemaSupported) { + Assert::same( + ['public.author', 'public.book', 'public.book_tag', 'public.tag'], + array_intersect(['public.author', 'public.book', 'public.book_tag', 'public.tag'], $tableNames), + ); + Assert::true($reflection->hasTable('public.author')); + Assert::false($reflection->hasTable('unknown')); +} else { + Assert::same( + ['author', 'book', 'book_tag', 'tag'], + array_intersect(['author', 'book', 'book_tag', 'tag'], $tableNames), + ); + Assert::true($reflection->hasTable('author')); + Assert::false($reflection->hasTable('unknown')); +} + + +// tables +$tables = array_filter($reflection->tables, fn($t) => in_array($t->name, ['author', 'book', 'book_tag', 'tag'], true)); +usort($tables, fn($a, $b) => $a->name <=> $b->name); +Assert::same('author', (string) $tables[0]); + +if ($schemaSupported) { Assert::same( [ - ['name' => 'author', 'view' => false, 'fullName' => 'public.author'], - ['name' => 'book', 'view' => false, 'fullName' => 'public.book'], - ['name' => 'book_tag', 'view' => false, 'fullName' => 'public.book_tag'], - ['name' => 'tag', 'view' => false, 'fullName' => 'public.tag'], + ['author', false, 'public.author'], + ['book', false, 'public.book'], + ['book_tag', false, 'public.book_tag'], + ['tag', false, 'public.tag'], ], - $tables, + array_map(fn($t) => [$t->name, $t->view, $t->fullName], $tables), ); } else { - Assert::same([ - ['name' => 'author', 'view' => false], - ['name' => 'book', 'view' => false], - ['name' => 'book_tag', 'view' => false], - ['name' => 'tag', 'view' => false], - ], $tables); + Assert::same( + [ + ['author', false, null], + ['book', false, null], + ['book_tag', false, null], + ['tag', false, null], + ], + array_map(fn($t) => [$t->name, $t->view, $t->fullName], $tables), + ); } -$columns = $driver->getColumns('author'); -array_walk($columns, function (&$item) { - Assert::type('array', $item['vendor']); - unset($item['vendor']); -}); +// columns +$table = $reflection->getTable('author'); $expectedColumns = [ - [ + 'id' => [ 'name' => 'id', 'table' => 'author', - 'nativetype' => 'INT', + 'nativeType' => 'INT', 'size' => 11, 'nullable' => false, 'default' => null, - 'autoincrement' => true, + 'autoIncrement' => true, 'primary' => true, ], - [ + 'name' => [ 'name' => 'name', 'table' => 'author', - 'nativetype' => 'VARCHAR', + 'nativeType' => 'VARCHAR', 'size' => 30, 'nullable' => false, 'default' => null, - 'autoincrement' => false, + 'autoIncrement' => false, 'primary' => false, ], - [ + 'web' => [ 'name' => 'web', 'table' => 'author', - 'nativetype' => 'VARCHAR', + 'nativeType' => 'VARCHAR', 'size' => 100, 'nullable' => false, 'default' => null, - 'autoincrement' => false, + 'autoIncrement' => false, 'primary' => false, ], - [ + 'born' => [ 'name' => 'born', 'table' => 'author', - 'nativetype' => 'DATE', + 'nativeType' => 'DATE', 'size' => null, 'nullable' => true, 'default' => null, - 'autoincrement' => false, + 'autoIncrement' => false, 'primary' => false, ], ]; @@ -93,100 +115,96 @@ switch ($driverName) { case 'mysql': $version = $connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); if (version_compare($version, '8.0', '>=')) { - $expectedColumns[0]['size'] = null; + $expectedColumns['id']['size'] = null; } break; case 'pgsql': - $expectedColumns[0]['nativetype'] = 'INT4'; - $expectedColumns[0]['default'] = "nextval('author_id_seq'::regclass)"; - $expectedColumns[0]['size'] = null; + $expectedColumns['id']['nativeType'] = 'INT4'; + $expectedColumns['id']['default'] = "nextval('author_id_seq'::regclass)"; + $expectedColumns['id']['size'] = null; break; case 'sqlite': - $expectedColumns[0]['nativetype'] = 'INTEGER'; - $expectedColumns[0]['size'] = null; - $expectedColumns[1]['nativetype'] = 'TEXT'; - $expectedColumns[1]['size'] = null; - $expectedColumns[2]['nativetype'] = 'TEXT'; - $expectedColumns[2]['size'] = null; + $expectedColumns['id']['nativeType'] = 'INTEGER'; + $expectedColumns['id']['size'] = null; + $expectedColumns['name']['nativeType'] = 'TEXT'; + $expectedColumns['name']['size'] = null; + $expectedColumns['web']['nativeType'] = 'TEXT'; + $expectedColumns['web']['size'] = null; break; case 'sqlsrv': - $expectedColumns[0]['size'] = null; - $expectedColumns[1]['size'] = null; - $expectedColumns[2]['size'] = null; + $expectedColumns['id']['size'] = null; + $expectedColumns['name']['size'] = null; + $expectedColumns['web']['size'] = null; break; default: Assert::fail("Unsupported driver $driverName"); } -Assert::same($expectedColumns, $columns); - - -$indexes = $driver->getIndexes('book_tag'); +Assert::same('id', array_key_first($table->columns)); +Assert::same( + $expectedColumns, + array_map(fn($c) => [ + 'name' => $c->name, + 'table' => $c->table->name, + 'nativeType' => $c->nativeType, + 'size' => $c->size, + 'nullable' => $c->nullable, + 'default' => $c->default, + 'autoIncrement' => $c->autoIncrement, + 'primary' => $c->primary, + ], $table->columns), +); + + +// indexes +$table = $reflection->getTable('book_tag'); +$index = $table->indexes[0]; switch ($driverName) { case 'pgsql': - Assert::same([ - [ - 'name' => 'book_tag_pkey', - 'unique' => true, - 'primary' => true, - 'columns' => [ - 'book_id', - 'tag_id', - ], - ], - ], $indexes); + Assert::count(1, $table->indexes); + Assert::same('book_tag_pkey', $index->name); break; case 'sqlite': - Assert::same([ - [ - 'name' => 'sqlite_autoindex_book_tag_1', - 'unique' => true, - 'primary' => true, - 'columns' => [ - 'book_id', - 'tag_id', - ], - ], - ], $indexes); + Assert::count(1, $table->indexes); + Assert::same('sqlite_autoindex_book_tag_1', $index->name); break; case 'sqlsrv': - Assert::same([ - [ - 'name' => 'PK_book_tag', - 'unique' => true, - 'primary' => true, - 'columns' => [ - 'book_id', - 'tag_id', - ], - ], - ], $indexes); + Assert::count(1, $table->indexes); + Assert::same('PK_book_tag', $index->name); break; case 'mysql': - Assert::same([ - [ - 'name' => 'PRIMARY', - 'unique' => true, - 'primary' => true, - 'columns' => [ - 'book_id', - 'tag_id', - ], - ], - [ - 'name' => 'book_tag_tag', - 'unique' => false, - 'primary' => false, - 'columns' => [ - 'tag_id', - ], - ], - ], $indexes); + Assert::count(2, $table->indexes); + Assert::same('PRIMARY', $index->name); break; default: Assert::fail("Unsupported driver $driverName"); } -$structure->rebuild(); -$primary = $structure->getPrimaryKey('book_tag'); -Assert::same(['book_id', 'tag_id'], $primary); +Assert::true($index->unique); +Assert::true($index->primary); +Assert::same([$table->getColumn('book_id'), $table->getColumn('tag_id')], $index->columns); + + +// primary keys +$table = $reflection->getTable('book_tag'); +Assert::same([$table->getColumn('book_id'), $table->getColumn('tag_id')], $table->primaryKey->columns); + + +// foreign keys +$table = $reflection->getTable('book_tag'); +Assert::count(2, $table->foreignKeys); + +$keys = $table->foreignKeys; +usort($keys, fn($a, $b) => $a->name <=> $b->name); +$key = $keys[0]; +switch ($driverName) { + case 'sqlite': + Assert::null($key->name); + break; + default: + Assert::same('book_tag_book', $key->name); +} + +Assert::same([$table->getColumn('book_id')], $key->localColumns); +Assert::same('book', $key->foreignTable->name); +Assert::same([$key->foreignTable->getColumn('id')], $key->foreignColumns);