diff --git a/src/Database.php b/src/Database.php index 7839a407..b100b6fc 100644 --- a/src/Database.php +++ b/src/Database.php @@ -11,11 +11,13 @@ namespace Cycle\Database; +use Cycle\Database\Driver\BulkSchemaProviderInterface; use Cycle\Database\Driver\Driver; use Cycle\Database\Driver\DriverInterface; use Cycle\Database\Driver\CursorInterface; use Cycle\Database\Driver\CursorOptions; use Cycle\Database\Exception\DriverException; +use Cycle\Database\Schema\AbstractTable; use Cycle\Database\Query\DeleteQuery; use Cycle\Database\Query\InsertQuery; use Cycle\Database\Query\QueryParameters; @@ -117,6 +119,43 @@ public function table(string $name): Table return new Table($this, $name); } + /** + * Introspect several tables at once. When the driver supports batched introspection (currently + * Postgres and SQL Server) this costs a constant number of queries instead of a full + * introspection per table; otherwise it falls back to introspecting each table on its own. The + * observable result is identical to calling {@see Table::getSchema()} for each table. + * + * @param non-empty-string[]|null $tables Table names WITHOUT the database prefix. When `null`, + * every table of the database is introspected (names resolved via the driver, then fed + * into the batched path — this is the explicit "whole database" entry point). + * + * @return array Keyed by the table name. + */ + public function getSchemas(?array $tables = null): array + { + $handler = $this->getDriver(self::READ)->getSchemaHandler(); + + if ($tables === null) { + $tables = []; + foreach ($handler->getTableNames($this->prefix) as $table) { + $tables[] = \str_contains($table, '.') + ? \str_replace('.' . $this->prefix, '.', $table) + : \substr($table, \strlen($this->prefix)); + } + } + + if ($handler instanceof BulkSchemaProviderInterface) { + return $handler->getSchemas($tables, $this->prefix); + } + + $result = []; + foreach ($tables as $table) { + $result[$table] = $handler->getSchema($table, $this->prefix); + } + + return $result; + } + /** * @psalm-param non-empty-string $query */ diff --git a/src/Driver/BulkSchemaProviderInterface.php b/src/Driver/BulkSchemaProviderInterface.php new file mode 100644 index 00000000..a43bf8ff --- /dev/null +++ b/src/Driver/BulkSchemaProviderInterface.php @@ -0,0 +1,41 @@ + Keyed by the input table name, in the input order. + */ + public function getSchemas(array $tables, ?string $prefix = null): array; +} diff --git a/src/Driver/Handler.php b/src/Driver/Handler.php index 20fb751d..7c156c93 100644 --- a/src/Driver/Handler.php +++ b/src/Driver/Handler.php @@ -22,7 +22,7 @@ use Cycle\Database\Schema\ComparatorInterface; use Cycle\Database\Schema\ElementInterface; -abstract class Handler implements HandlerInterface +abstract class Handler implements HandlerInterface, BulkSchemaProviderInterface { protected ?DriverInterface $driver = null; @@ -34,6 +34,26 @@ public function withDriver(DriverInterface $driver): HandlerInterface return $handler; } + /** + * Default implementation introspects each table on its own. Drivers whose catalog can be read + * for a set of tables at once override this with a batched implementation; the observable result + * must stay identical to {@see getSchema()}. + * + * @param non-empty-string[] $tables + * + * @return array + */ + #[\Override] + public function getSchemas(array $tables, ?string $prefix = null): array + { + $result = []; + foreach ($tables as $table) { + $result[$table] = $this->getSchema($table, $prefix); + } + + return $result; + } + /** * Associated driver. */ diff --git a/src/Driver/HandlerInterface.php b/src/Driver/HandlerInterface.php index 6d33d19a..dbb190bd 100644 --- a/src/Driver/HandlerInterface.php +++ b/src/Driver/HandlerInterface.php @@ -61,14 +61,12 @@ public function withDriver(DriverInterface $driver): self; /** * Get all available table names. * - * @param string|null $prefix - * + * @return array */ public function getTableNames(string $prefix = ''): array; /** * Check if given table exists in database. - * */ public function hasTable(string $table): bool; @@ -76,7 +74,6 @@ public function hasTable(string $table): bool; * Get or create table schema. * * @throws HandlerException - * */ public function getSchema(string $table, ?string $prefix = null): AbstractTable; diff --git a/src/Driver/Postgres/PostgresHandler.php b/src/Driver/Postgres/PostgresHandler.php index 5e9bfbc8..59df92be 100644 --- a/src/Driver/Postgres/PostgresHandler.php +++ b/src/Driver/Postgres/PostgresHandler.php @@ -18,12 +18,19 @@ use Cycle\Database\Exception\SchemaException; use Cycle\Database\Schema\AbstractColumn; use Cycle\Database\Schema\AbstractTable; +use Cycle\Database\Schema\PrefetchedIntrospection; /** * @property PostgresDriver $driver */ class PostgresHandler extends Handler { + /** + * Maximum number of (schema, table) pairs bound into a single `IN (...)` list. Postgres allows + * far more, but chunking keeps the parameter count bounded on any set size. + */ + private const BULK_CHUNK = 1000; + /** * @psalm-param non-empty-string $table */ @@ -32,6 +39,77 @@ public function getSchema(string $table, ?string $prefix = null): AbstractTable return new PostgresTable($this->driver, $table, $prefix ?? ''); } + /** + * Introspect a set of tables with a constant number of queries. Every query is the per-table + * one widened from `table_schema = ? AND table_name = ?` to a row-value `IN (...)` list, so the + * rows fed to {@see PostgresTable} are identical to the per-table path. + * + * @param non-empty-string[] $tables + * + * @return array + */ + #[\Override] + public function getSchemas(array $tables, ?string $prefix = null): array + { + if ($tables === []) { + return []; + } + + $prefix ??= ''; + + // Resolve every requested table to its (schema, prefixed name) pair — exactly the way + // PostgresTable does, so that the bucket keys line up with PostgresTable::getFullName(). + $targets = []; + $pairs = []; + $seen = []; + foreach ($tables as $table) { + [$schema, $name] = $this->driver->parseSchemaAndTable($table); + $prefixed = $prefix . $name; + $full = $schema . '.' . $prefixed; + + $targets[$table] = $full; + if (!isset($seen[$full])) { + $seen[$full] = true; + $pairs[] = [$schema, $prefixed]; + } + } + + $existing = $this->fetchBulkExisting($pairs); + $columns = $this->groupRows($this->fetchBulkColumns($pairs), 'table_schema', 'table_name'); + $primaryKeys = $this->groupPrimaryKeys($this->fetchBulkPrimaryKeys($pairs)); + $indexRows = $this->groupRows($this->fetchBulkIndexRows($pairs), 'schemaname', 'tablename'); + $references = $this->groupRows($this->fetchBulkReferences($pairs), 'table_schema', 'table_name'); + + // CHECK constraints are only relevant for tables that carry a char column with a size (the + // same guard as the per-table path), so we scope the query to those tables. + $checkConstraints = $this->groupCheckConstraints( + $this->fetchBulkCheckConstraints($this->constrainedPairs($columns)), + ); + + // Native enum ranges are looked up by `.`, so a single database-wide map is + // shared by every table. + $enumValues = $this->fetchBulkEnumValues($columns); + + $result = []; + foreach ($targets as $table => $full) { + $result[$table] = new PostgresTable( + $this->driver, + $table, + $prefix, + new PrefetchedIntrospection(isset($existing[$full]), [ + 'columns' => $columns[$full] ?? [], + 'primaryKeys' => $primaryKeys[$full] ?? [], + 'indexRows' => $indexRows[$full] ?? [], + 'references' => $references[$full] ?? [], + 'checkConstraints' => $checkConstraints[$full] ?? [], + 'enumValues' => $enumValues, + ]), + ); + } + + return $result; + } + public function getTableNames(string $prefix = ''): array { $query = "SELECT table_schema, table_name @@ -207,4 +285,275 @@ private function renameColumn( $this->run($statement); } + + /** + * Run a query whose `WHERE` filters a row-value `IN (...)` list of (schema, table) pairs. The + * pair list is chunked so the bound parameter count stays bounded, and the resulting rows are + * concatenated. + * + * @param array $pairs + * @param callable(string):string $build Receives the `(?, ?), ...` placeholder list and returns + * the full SQL statement. + * + * @return array + */ + private function fetchByPairs(array $pairs, callable $build): array + { + if ($pairs === []) { + return []; + } + + $rows = []; + foreach (\array_chunk($pairs, self::BULK_CHUNK) as $chunk) { + $placeholders = \implode(', ', \array_fill(0, \count($chunk), '(?, ?)')); + $parameters = \array_merge(...$chunk); + + foreach ($this->driver->query($build($placeholders), $parameters) as $row) { + $rows[] = $row; + } + } + + return $rows; + } + + /** + * Full names of the tables that actually exist. Replaces the per-table {@see hasTable()} call + * and matches its semantics (`information_schema.tables`, `BASE TABLE`). + * + * @param array $pairs + * + * @return array + */ + private function fetchBulkExisting(array $pairs): array + { + $rows = $this->fetchByPairs($pairs, static fn(string $in): string => << $pairs + */ + private function fetchBulkColumns(array $pairs): array + { + return $this->fetchByPairs($pairs, static fn(string $in): string => << $pairs + */ + private function fetchBulkPrimaryKeys(array $pairs): array + { + return $this->fetchByPairs($pairs, static fn(string $in): string => << $pairs + */ + private function fetchBulkIndexRows(array $pairs): array + { + return $this->fetchByPairs($pairs, static fn(string $in): string => << $pairs + */ + private function fetchBulkReferences(array $pairs): array + { + return $this->fetchByPairs($pairs, static fn(string $in): string => << $pairs + */ + private function fetchBulkCheckConstraints(array $pairs): array + { + return $this->fetchByPairs($pairs, static fn(string $in): string => << $columns Column rows grouped per table. + * + * @return array> Keyed as `.`. + */ + private function fetchBulkEnumValues(array $columns): array + { + $types = []; + foreach ($columns as $rows) { + foreach ($rows as $schema) { + if ($schema['data_type'] === 'USER-DEFINED' && $schema['typtype'] === 'e') { + $types[$schema['udt_schema'] . '.' . $schema['udt_name']] = [ + $schema['udt_schema'], + $schema['udt_name'], + ]; + } + } + } + + $rows = $this->fetchByPairs(\array_values($types), static fn(string $in): string => << $columns Column rows grouped per table (`.`). + * + * @return array + */ + private function constrainedPairs(array $columns): array + { + $pairs = []; + foreach ($columns as $full => $rows) { + foreach ($rows as $schema) { + if ( + $schema['character_maximum_length'] !== null + && \str_contains((string) $schema['data_type'], 'char') + ) { + [$s, $t] = \explode('.', (string) $full, 2); + $pairs[] = [$s, $t]; + break; + } + } + } + + return $pairs; + } + + /** + * Group raw rows per table into `.
=> rows[]`. + * + * @param array $rows + * + * @return array> + */ + private function groupRows(array $rows, string $schemaKey, string $tableKey): array + { + $result = []; + foreach ($rows as $row) { + $result[$row[$schemaKey] . '.' . $row[$tableKey]][] = $row; + } + + return $result; + } + + /** + * @param array $rows + * + * @return array> + */ + private function groupPrimaryKeys(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[$row['table_schema'] . '.' . $row['table_name']][] = $row['column_name']; + } + + return $result; + } + + /** + * Group CHECK constraint rows per table and, within a table, by the textual `conkey`. + * + * @param array $rows + * + * @return array>> + */ + private function groupCheckConstraints(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[$row['nspname'] . '.' . $row['relname']][(string) $row['conkey']][] = $row; + } + + return $result; + } } diff --git a/src/Driver/Postgres/Schema/PostgresTable.php b/src/Driver/Postgres/Schema/PostgresTable.php index 0e9a2424..b42558e8 100644 --- a/src/Driver/Postgres/Schema/PostgresTable.php +++ b/src/Driver/Postgres/Schema/PostgresTable.php @@ -99,42 +99,49 @@ protected function fetchColumns(): array { [$tableSchema, $tableName] = $this->driver->parseSchemaAndTable($this->getFullName()); - $query = $this->driver->query( - 'SELECT columns.*, pg_type.*, pg_description.description - FROM information_schema.columns - JOIN pg_catalog.pg_type - ON (pg_type.typname = columns.udt_name) - JOIN pg_catalog.pg_statio_all_tables - ON (pg_statio_all_tables.relname = columns.table_name - AND pg_statio_all_tables.schemaname = columns.table_schema) - LEFT JOIN pg_catalog.pg_description - ON (pg_description.objoid = pg_statio_all_tables.relid - AND pg_description.objsubid = columns.ordinal_position) - WHERE columns.table_schema = ? - AND columns.table_name = ?', - [$tableSchema, $tableName], - ); + if ($this->prefetch !== null) { + $schemas = $this->prefetch->data['columns']; + $primaryKeys = $this->prefetch->data['primaryKeys']; + $checkConstraints = $this->prefetch->data['checkConstraints']; + $enumValues = $this->prefetch->data['enumValues']; + } else { + $query = $this->driver->query( + 'SELECT columns.*, pg_type.*, pg_description.description + FROM information_schema.columns + JOIN pg_catalog.pg_type + ON (pg_type.typname = columns.udt_name) + JOIN pg_catalog.pg_statio_all_tables + ON (pg_statio_all_tables.relname = columns.table_name + AND pg_statio_all_tables.schemaname = columns.table_schema) + LEFT JOIN pg_catalog.pg_description + ON (pg_description.objoid = pg_statio_all_tables.relid + AND pg_description.objsubid = columns.ordinal_position) + WHERE columns.table_schema = ? + AND columns.table_name = ?', + [$tableSchema, $tableName], + ); - $primaryKeys = \array_column($this->driver->query( - 'SELECT key_column_usage.column_name - FROM information_schema.table_constraints - JOIN information_schema.key_column_usage - ON ( - key_column_usage.table_name = table_constraints.table_name AND - key_column_usage.table_schema = table_constraints.table_schema AND - key_column_usage.constraint_name = table_constraints.constraint_name - ) - WHERE table_constraints.constraint_type = \'PRIMARY KEY\' AND - key_column_usage.ordinal_position IS NOT NULL AND - table_constraints.table_schema = ? AND - table_constraints.table_name = ?', - [$tableSchema, $tableName], - )->fetchAll(), 'column_name'); - - $schemas = $query->fetchAll(); - - $checkConstraints = $this->fetchCheckConstraints($tableSchema, $tableName, $schemas); - $enumValues = $this->fetchEnumValues($schemas); + $primaryKeys = \array_column($this->driver->query( + 'SELECT key_column_usage.column_name + FROM information_schema.table_constraints + JOIN information_schema.key_column_usage + ON ( + key_column_usage.table_name = table_constraints.table_name AND + key_column_usage.table_schema = table_constraints.table_schema AND + key_column_usage.constraint_name = table_constraints.constraint_name + ) + WHERE table_constraints.constraint_type = \'PRIMARY KEY\' AND + key_column_usage.ordinal_position IS NOT NULL AND + table_constraints.table_schema = ? AND + table_constraints.table_name = ?', + [$tableSchema, $tableName], + )->fetchAll(), 'column_name'); + + $schemas = $query->fetchAll(); + + $checkConstraints = $this->fetchCheckConstraints($tableSchema, $tableName, $schemas); + $enumValues = $this->fetchEnumValues($schemas); + } $result = []; foreach ($schemas as $schema) { @@ -187,21 +194,27 @@ protected function fetchReferences(): array { [$tableSchema, $tableName] = $this->driver->parseSchemaAndTable($this->getFullName()); - //Mindblowing - $query = 'SELECT tc.constraint_name, tc.constraint_schema, tc.table_name, kcu.column_name, rc.update_rule, ' - . 'rc.delete_rule, ccu.table_name AS foreign_table_name, ' - . "ccu.column_name AS foreign_column_name\n" - . "FROM information_schema.table_constraints AS tc\n" - . "JOIN information_schema.key_column_usage AS kcu\n" - . " ON tc.constraint_name = kcu.constraint_name\n" - . "JOIN information_schema.constraint_column_usage AS ccu\n" - . " ON ccu.constraint_name = tc.constraint_name\n" - . "JOIN information_schema.referential_constraints AS rc\n" - . " ON rc.constraint_name = tc.constraint_name\n" - . "WHERE constraint_type = 'FOREIGN KEY' AND tc.table_schema = ? AND tc.table_name = ?"; + if ($this->prefetch !== null) { + $rows = $this->prefetch->data['references']; + } else { + //Mindblowing + $query = 'SELECT tc.constraint_name, tc.constraint_schema, tc.table_name, kcu.column_name, rc.update_rule, ' + . 'rc.delete_rule, ccu.table_name AS foreign_table_name, ' + . "ccu.column_name AS foreign_column_name\n" + . "FROM information_schema.table_constraints AS tc\n" + . "JOIN information_schema.key_column_usage AS kcu\n" + . " ON tc.constraint_name = kcu.constraint_name\n" + . "JOIN information_schema.constraint_column_usage AS ccu\n" + . " ON ccu.constraint_name = tc.constraint_name\n" + . "JOIN information_schema.referential_constraints AS rc\n" + . " ON rc.constraint_name = tc.constraint_name\n" + . "WHERE constraint_type = 'FOREIGN KEY' AND tc.table_schema = ? AND tc.table_name = ?"; + + $rows = $this->driver->query($query, [$tableSchema, $tableName]); + } $fks = []; - foreach ($this->driver->query($query, [$tableSchema, $tableName]) as $schema) { + foreach ($rows as $schema) { if (!isset($fks[$schema['constraint_name']])) { $fks[$schema['constraint_name']] = $schema; $fks[$schema['constraint_name']]['column_name'] = [$schema['column_name']]; @@ -336,6 +349,10 @@ private function indexRows(): array return $this->indexRows; } + if ($this->prefetch !== null) { + return $this->indexRows = $this->prefetch->data['indexRows']; + } + [$tableSchema, $tableName] = $this->driver->parseSchemaAndTable($this->getFullName()); $query = << + */ + #[\Override] + public function getSchemas(array $tables, ?string $prefix = null): array + { + if ($this->parent instanceof BulkSchemaProviderInterface) { + return $this->parent->getSchemas($tables, $prefix); + } + + $result = []; + foreach ($tables as $table) { + $result[$table] = $this->parent->getSchema($table, $prefix); + } + + return $result; + } + public function withDriver(DriverInterface $driver): HandlerInterface { $handler = clone $this; diff --git a/src/Driver/SQLServer/SQLServerHandler.php b/src/Driver/SQLServer/SQLServerHandler.php index 8b81e11e..7b13bfbd 100644 --- a/src/Driver/SQLServer/SQLServerHandler.php +++ b/src/Driver/SQLServer/SQLServerHandler.php @@ -18,9 +18,15 @@ use Cycle\Database\Schema\AbstractColumn; use Cycle\Database\Schema\AbstractIndex; use Cycle\Database\Schema\AbstractTable; +use Cycle\Database\Schema\PrefetchedIntrospection; class SQLServerHandler extends Handler { + /** + * Maximum number of names bound into a single `IN (...)` list. + */ + private const BULK_CHUNK = 1000; + /** * @psalm-param non-empty-string $table */ @@ -29,6 +35,69 @@ public function getSchema(string $table, ?string $prefix = null): AbstractTable return new SQLServerTable($this->driver, $table, $prefix ?? ''); } + /** + * Introspect a set of tables with a constant number of queries. The per-table `table_name = ?` + * filters become `table_name IN (...)`, and the foreign keys — normally read with one + * `sp_fkeys` call per table — are read for the whole set from `sys.foreign_keys` in a shape that + * matches the `sp_fkeys` rows consumed by {@see \Cycle\Database\Driver\SQLServer\Schema\SQLServerForeignKey}. + * + * @param non-empty-string[] $tables + * + * @return array + */ + #[\Override] + public function getSchemas(array $tables, ?string $prefix = null): array + { + if ($tables === []) { + return []; + } + + $prefix ??= ''; + + $targets = []; + $names = []; + $seen = []; + foreach ($tables as $table) { + $full = $prefix . $table; + $targets[$table] = $full; + if (!isset($seen[$full])) { + $seen[$full] = true; + $names[] = $full; + } + } + + $existing = $this->fetchBulkExisting($names); + $columns = $this->groupByBulkTable($this->fetchBulkColumns($names)); + $indexes = $this->groupByBulkTable($this->fetchBulkIndexes($names)); + $primaryKeys = $this->groupPrimaryKeys($this->fetchBulkPrimaryKeys($names)); + $references = $this->groupReferences($this->fetchBulkReferences($names)); + + // DEFAULT and CHECK constraints are keyed by database-wide unique ids (object id, and + // parent object id + column id), so a single map is shared by all tables. + $objectIds = $this->collectObjectIds($columns); + $defaultConstraints = $this->fetchBulkDefaultConstraints($objectIds, $columns); + $checkConstraints = $this->fetchBulkCheckConstraints($objectIds, $columns); + + $result = []; + foreach ($targets as $table => $full) { + $result[$table] = new SQLServerTable( + $this->driver, + $table, + $prefix, + new PrefetchedIntrospection(isset($existing[$full]), [ + 'columns' => $columns[$full] ?? [], + 'defaultConstraints' => $defaultConstraints, + 'checkConstraints' => $checkConstraints, + 'indexes' => $indexes[$full] ?? [], + 'primaryKeys' => $primaryKeys[$full] ?? [], + 'references' => $references[$full] ?? [], + ]), + ); + } + + return $result; + } + public function getTableNames(string $prefix = ''): array { $query = "SELECT [table_name] FROM [information_schema].[tables] WHERE [table_type] = 'BASE TABLE'"; @@ -174,4 +243,265 @@ private function renameColumn( ], ); } + + /** + * Run a query whose `WHERE` filters an `IN (...)` list, chunking the values so the bound + * parameter count stays bounded. + * + * @param list $values + * @param callable(string):string $build Receives the `?, ?, ...` placeholder list. + * + * @return array + */ + private function fetchByValues(array $values, callable $build): array + { + if ($values === []) { + return []; + } + + $rows = []; + foreach (\array_chunk($values, self::BULK_CHUNK) as $chunk) { + $placeholders = \implode(', ', \array_fill(0, \count($chunk), '?')); + + foreach ($this->driver->query($build($placeholders), \array_values($chunk)) as $row) { + $rows[] = $row; + } + } + + return $rows; + } + + /** + * @param list $names + * + * @return array + */ + private function fetchBulkExisting(array $names): array + { + $rows = $this->fetchByValues($names, static fn(string $in): string => "SELECT [table_name] " + . "FROM [information_schema].[tables] " + . "WHERE [table_type] = 'BASE TABLE' AND [table_name] IN ({$in})"); + + $result = []; + foreach ($rows as $row) { + $result[$row['table_name']] = true; + } + + return $result; + } + + /** + * @param list $names + */ + private function fetchBulkColumns(array $names): array + { + return $this->fetchByValues($names, static fn(string $in): string => 'SELECT *, ' + . '[information_schema].[columns].[table_name] AS [bulkTable] ' + . 'FROM [information_schema].[columns] INNER JOIN [sys].[columns] AS [sysColumns] ' + . 'ON (object_name([object_id]) = [table_name] AND [sysColumns].[name] = [COLUMN_NAME]) ' + . "WHERE [table_name] IN ({$in}) " + . 'ORDER BY [table_name], [ORDINAL_POSITION]'); + } + + /** + * @param list $names + */ + private function fetchBulkIndexes(array $names): array + { + return $this->fetchByValues($names, static fn(string $in): string => 'SELECT [indexes].[name] AS [indexName], ' + . '[cl].[name] AS [columnName], [columns].[is_descending_key] AS [isDescendingKey], ' + . '[is_primary_key] AS [isPrimary], [is_unique] AS [isUnique], [t].[name] AS [bulkTable] ' + . 'FROM [sys].[indexes] AS [indexes] ' + . 'INNER JOIN [sys].[index_columns] as [columns] ' + . ' ON [indexes].[object_id] = [columns].[object_id] AND [indexes].[index_id] = [columns].[index_id] ' + . 'INNER JOIN [sys].[columns] AS [cl] ' + . ' ON [columns].[object_id] = [cl].[object_id] AND [columns].[column_id] = [cl].[column_id] ' + . 'INNER JOIN [sys].[tables] AS [t] ' + . ' ON [indexes].[object_id] = [t].[object_id] ' + . "WHERE [t].[name] IN ({$in}) AND [is_primary_key] = 0 " + . 'ORDER BY [t].[name], [indexes].[name], [indexes].[index_id], [columns].[index_column_id]'); + } + + /** + * @param list $names + */ + private function fetchBulkPrimaryKeys(array $names): array + { + return $this->fetchByValues($names, static fn(string $in): string => 'SELECT [indexes].[name] AS [indexName], ' + . '[cl].[name] AS [columnName], [t].[name] AS [bulkTable] ' + . 'FROM [sys].[indexes] AS [indexes] ' + . 'INNER JOIN [sys].[index_columns] as [columns] ' + . ' ON [indexes].[object_id] = [columns].[object_id] AND [indexes].[index_id] = [columns].[index_id] ' + . 'INNER JOIN [sys].[columns] AS [cl] ' + . ' ON [columns].[object_id] = [cl].[object_id] AND [columns].[column_id] = [cl].[column_id] ' + . 'INNER JOIN [sys].[tables] AS [t] ' + . ' ON [indexes].[object_id] = [t].[object_id] ' + . "WHERE [t].[name] IN ({$in}) AND [is_primary_key] = 1 " + . 'ORDER BY [t].[name], [indexes].[name], [indexes].[index_id], [columns].[index_column_id]'); + } + + /** + * Read the foreign keys of the whole set from `sys.foreign_keys`, reproducing the columns of the + * `sp_fkeys` result consumed by {@see \Cycle\Database\Driver\SQLServer\Schema\SQLServerForeignKey}. + * The referential rule is emitted with `sp_fkeys` semantics (`0` = CASCADE, non-zero = NO ACTION). + * + * @param list $names + */ + private function fetchBulkReferences(array $names): array + { + return $this->fetchByValues($names, static fn(string $in): string => 'SELECT [fk].[name] AS [FK_NAME], ' + . '[pt].[name] AS [FKTABLE_NAME], [fcol].[name] AS [FKCOLUMN_NAME], ' + . '[rt].[name] AS [PKTABLE_NAME], [rcol].[name] AS [PKCOLUMN_NAME], ' + . 'CASE WHEN [fk].[update_referential_action] = 1 THEN 0 ELSE 1 END AS [UPDATE_RULE], ' + . 'CASE WHEN [fk].[delete_referential_action] = 1 THEN 0 ELSE 1 END AS [DELETE_RULE] ' + . 'FROM [sys].[foreign_keys] AS [fk] ' + . 'INNER JOIN [sys].[foreign_key_columns] AS [fkc] ON [fkc].[constraint_object_id] = [fk].[object_id] ' + . 'INNER JOIN [sys].[tables] AS [pt] ON [pt].[object_id] = [fk].[parent_object_id] ' + . 'INNER JOIN [sys].[columns] AS [fcol] ' + . ' ON [fcol].[object_id] = [fkc].[parent_object_id] AND [fcol].[column_id] = [fkc].[parent_column_id] ' + . 'INNER JOIN [sys].[tables] AS [rt] ON [rt].[object_id] = [fk].[referenced_object_id] ' + . 'INNER JOIN [sys].[columns] AS [rcol] ' + . ' ON [rcol].[object_id] = [fkc].[referenced_object_id] ' + . ' AND [rcol].[column_id] = [fkc].[referenced_column_id] ' + . "WHERE [pt].[name] IN ({$in}) " + . 'ORDER BY [pt].[name], [fk].[name], [fkc].[constraint_column_id]'); + } + + /** + * Distinct object ids of every table in the fetched column rows. + * + * @param array $columns Column rows grouped per table. + * + * @return list + */ + private function collectObjectIds(array $columns): array + { + $ids = []; + foreach ($columns as $rows) { + foreach ($rows as $row) { + $ids[(string) $row['object_id']] = $row['object_id']; + } + } + + return \array_values($ids); + } + + /** + * @param list $objectIds + * @param array $columns + * + * @return array + */ + private function fetchBulkDefaultConstraints(array $objectIds, array $columns): array + { + if (!$this->columnsHave($columns, static fn(array $c): bool => !empty($c['default_object_id']))) { + return []; + } + + $rows = $this->fetchByValues($objectIds, static fn(string $in): string => 'SELECT [object_id], [name] ' + . "FROM [sys].[default_constraints] WHERE [parent_object_id] IN ({$in})"); + + $result = []; + foreach ($rows as $row) { + $result[(string) $row['object_id']] = $row['name']; + } + + return $result; + } + + /** + * @param list $objectIds + * @param array $columns + * + * @return array> + */ + private function fetchBulkCheckConstraints(array $objectIds, array $columns): array + { + $required = $this->columnsHave( + $columns, + static fn(array $c): bool => $c['DATA_TYPE'] === 'varchar' && !empty($c['CHARACTER_MAXIMUM_LENGTH']), + ); + + if (!$required) { + return []; + } + + $rows = $this->fetchByValues($objectIds, static fn(string $in): string => 'SELECT ' + . 'object_definition([o].[object_id]) AS [definition], ' + . 'OBJECT_NAME([o].[object_id]) AS [name], [o].[parent_object_id] AS [parentId], [c].[colid] AS [colid] ' + . 'FROM [sys].[objects] AS [o] ' + . 'JOIN [sys].[sysconstraints] AS [c] ON [o].[object_id] = [c].[constid] ' + . "WHERE [type_desc] = 'CHECK_CONSTRAINT' AND [parent_object_id] IN ({$in})"); + + $result = []; + foreach ($rows as $row) { + $result[$row['parentId'] . ':' . $row['colid']][] = $row; + } + + return $result; + } + + /** + * @param array $columns + * @param callable(array):bool $predicate + */ + private function columnsHave(array $columns, callable $predicate): bool + { + foreach ($columns as $rows) { + foreach ($rows as $row) { + if ($predicate($row)) { + return true; + } + } + } + + return false; + } + + /** + * Group raw rows per table using the `bulkTable` alias. + * + * @param array $rows + * + * @return array> + */ + private function groupByBulkTable(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[$row['bulkTable']][] = $row; + } + + return $result; + } + + /** + * @param array $rows + * + * @return array> + */ + private function groupPrimaryKeys(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[$row['bulkTable']][] = $row['columnName']; + } + + return $result; + } + + /** + * @param array $rows + * + * @return array> + */ + private function groupReferences(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[$row['FKTABLE_NAME']][] = $row; + } + + return $result; + } } diff --git a/src/Driver/SQLServer/Schema/SQLServerTable.php b/src/Driver/SQLServer/Schema/SQLServerTable.php index 148bd5be..50dc2549 100644 --- a/src/Driver/SQLServer/Schema/SQLServerTable.php +++ b/src/Driver/SQLServer/Schema/SQLServerTable.php @@ -43,27 +43,38 @@ public function save(int $operation = HandlerInterface::DO_ALL, bool $reset = tr #[\Override] protected function fetchColumns(): array { - $query = 'SELECT * FROM [information_schema].[columns] INNER JOIN [sys].[columns] AS [sysColumns] ' - . 'ON (object_name([object_id]) = [table_name] AND [sysColumns].[name] = [COLUMN_NAME]) ' - . 'WHERE [table_name] = ?'; + if ($this->prefetch !== null) { + $schemas = $this->prefetch->data['columns']; - $schemas = $this->driver->query($query, [$this->getFullName()])->fetchAll(); + if ($schemas === []) { + return []; + } - if ($schemas === []) { - return []; - } + $defaultConstraints = $this->prefetch->data['defaultConstraints']; + $checkConstraints = $this->prefetch->data['checkConstraints']; + } else { + $query = 'SELECT * FROM [information_schema].[columns] INNER JOIN [sys].[columns] AS [sysColumns] ' + . 'ON (object_name([object_id]) = [table_name] AND [sysColumns].[name] = [COLUMN_NAME]) ' + . 'WHERE [table_name] = ?'; - // The queries above are not scoped by the table schema, so the rows may belong to several - // same-named tables from different schemas. Constraints are batched per object to keep - // the resolution correct for every row. - $objectIds = []; - foreach ($schemas as $schema) { - $objectIds[(string) $schema['object_id']] = $schema['object_id']; - } - $objectIds = \array_values($objectIds); + $schemas = $this->driver->query($query, [$this->getFullName()])->fetchAll(); - $defaultConstraints = $this->fetchDefaultConstraints($objectIds, $schemas); - $checkConstraints = $this->fetchCheckConstraints($objectIds, $schemas); + if ($schemas === []) { + return []; + } + + // The queries above are not scoped by the table schema, so the rows may belong to several + // same-named tables from different schemas. Constraints are batched per object to keep + // the resolution correct for every row. + $objectIds = []; + foreach ($schemas as $schema) { + $objectIds[(string) $schema['object_id']] = $schema['object_id']; + } + $objectIds = \array_values($objectIds); + + $defaultConstraints = $this->fetchDefaultConstraints($objectIds, $schemas); + $checkConstraints = $this->fetchCheckConstraints($objectIds, $schemas); + } $result = []; foreach ($schemas as $schema) { @@ -96,8 +107,12 @@ protected function fetchIndexes(): array . "WHERE [t].[name] = ? AND [is_primary_key] = 0 \n" . 'ORDER BY [indexes].[name], [indexes].[index_id], [columns].[index_column_id]'; + $rows = $this->prefetch !== null + ? $this->prefetch->data['indexes'] + : $this->driver->query($query, [$this->getFullName()]); + $result = $indexes = []; - foreach ($this->driver->query($query, [$this->getFullName()]) as $index) { + foreach ($rows as $index) { //Collecting schemas first $indexes[$index['indexName']][] = $index; } @@ -113,11 +128,13 @@ protected function fetchIndexes(): array #[\Override] protected function fetchReferences(): array { - $query = $this->driver->query('sp_fkeys @fktable_name = ?', [$this->getFullName()]); + $rows = $this->prefetch !== null + ? $this->prefetch->data['references'] + : $this->driver->query('sp_fkeys @fktable_name = ?', [$this->getFullName()]); // join keys together $fks = []; - foreach ($query as $schema) { + foreach ($rows as $schema) { if (!isset($fks[$schema['FK_NAME']])) { $fks[$schema['FK_NAME']] = $schema; $fks[$schema['FK_NAME']]['PKCOLUMN_NAME'] = [$schema['PKCOLUMN_NAME']]; @@ -144,6 +161,10 @@ protected function fetchReferences(): array #[\Override] protected function fetchPrimaryKeys(): array { + if ($this->prefetch !== null) { + return $this->prefetch->data['primaryKeys']; + } + $query = "SELECT [indexes].[name] AS [indexName], [cl].[name] AS [columnName]\n" . "FROM [sys].[indexes] AS [indexes]\n" . "INNER JOIN [sys].[index_columns] as [columns]\n" diff --git a/src/Schema/AbstractTable.php b/src/Schema/AbstractTable.php index a7b68a8d..77dfdebc 100644 --- a/src/Schema/AbstractTable.php +++ b/src/Schema/AbstractTable.php @@ -79,6 +79,16 @@ abstract class AbstractTable implements TableInterface, ElementInterface */ protected State $current; + /** + * Raw introspection rows supplied by the bulk introspection path. Consumed once during the + * initial schema load and dropped afterwards, so that any later re-introspection (e.g. the + * post-save reload in {@see \Cycle\Database\Driver\Postgres\Schema\PostgresTable::save()}) hits + * the database as usual. + * + * @internal + */ + protected ?PrefetchedIntrospection $prefetch = null; + /** * Indication that table is exists and current schema is fetched from database. */ @@ -88,27 +98,40 @@ abstract class AbstractTable implements TableInterface, ElementInterface * @param DriverInterface $driver Parent driver. * * @param string $prefix Database specific table prefix. Required for table renames. + * @param PrefetchedIntrospection|null $prefetch Pre-fetched introspection rows for this table. + * Supplied by the bulk introspection path; when given, the table is populated from these + * rows and no per-table introspection query is issued. Internal, not part of the public + * API. * @psalm-param non-empty-string $name Table name, must include table prefix. */ public function __construct( protected DriverInterface $driver, string $name, private string $prefix, + ?PrefetchedIntrospection $prefetch = null, ) { //Initializing states $prefixedName = $this->prefixTableName($name); $this->initial = new State($prefixedName); $this->current = new State($prefixedName); - if ($this->driver->getSchemaHandler()->hasTable($this->getFullName())) { - $this->status = self::STATUS_EXISTS; + if ($prefetch !== null) { + // Bulk path: existence is derived from the batched result, no extra hasTable() query. + $this->prefetch = $prefetch; + $exists = $prefetch->exists; + } else { + $exists = $this->driver->getSchemaHandler()->hasTable($this->getFullName()); } - if ($this->exists()) { + if ($exists) { + $this->status = self::STATUS_EXISTS; //Initiating table schema $this->initSchema($this->initial); } + // The pre-fetched rows are valid only for the initial load above. + $this->prefetch = null; + $this->setState($this->initial); } diff --git a/src/Schema/PrefetchedIntrospection.php b/src/Schema/PrefetchedIntrospection.php new file mode 100644 index 00000000..723cd952 --- /dev/null +++ b/src/Schema/PrefetchedIntrospection.php @@ -0,0 +1,38 @@ + $data Driver-defined buckets of raw rows for this table. + */ + public function __construct( + public readonly bool $exists, + public readonly array $data, + ) {} +} diff --git a/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php b/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php new file mode 100644 index 00000000..aaad7726 --- /dev/null +++ b/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php @@ -0,0 +1,287 @@ +assertSame([], $this->bulkProvider()->getSchemas([])); + } + + public function testResultIsKeyedByInputNameInOrder(): void + { + $this->makeSampleSchema(); + + $names = ['tags', 'authors', 'books']; + $schemas = $this->bulkProvider()->getSchemas($names); + + $this->assertSame($names, \array_keys($schemas)); + } + + public function testBulkSchemaMatchesPerTableSchema(): void + { + $this->makeSampleSchema(); + + $handler = $this->bulkProvider(); + $names = ['authors', 'books', 'tags']; + $bulk = $handler->getSchemas($names); + + foreach ($names as $name) { + $this->assertTrue($bulk[$name]->exists(), "Table {$name} must be reported as existing"); + $this->assertSameAsInDB($handler->getSchema($name), $bulk[$name]); + } + } + + public function testNonExistentTableIsReturnedAsNewSchema(): void + { + $schemas = $this->bulkProvider()->getSchemas(['this_table_does_not_exist']); + + $this->assertArrayHasKey('this_table_does_not_exist', $schemas); + $this->assertFalse($schemas['this_table_does_not_exist']->exists()); + $this->assertSame(AbstractTable::STATUS_NEW, $schemas['this_table_does_not_exist']->getStatus()); + } + + public function testMixOfExistingAndMissingTables(): void + { + $this->makeSampleSchema(); + + $schemas = $this->bulkProvider()->getSchemas(['authors', 'missing', 'books']); + + $this->assertTrue($schemas['authors']->exists()); + $this->assertFalse($schemas['missing']->exists()); + $this->assertTrue($schemas['books']->exists()); + } + + public function testPrefixIsHonored(): void + { + $db = $this->db('default', 'pre_'); + $schema = $db->table('widgets')->getSchema(); + $schema->primary('id'); + $schema->string('label', 32)->defaultValue('x'); + $schema->save(Handler::DO_ALL); + + $handler = $this->bulkProvider(); + $bulk = $handler->getSchemas(['widgets'], 'pre_'); + + $this->assertTrue($bulk['widgets']->exists()); + $this->assertSameAsInDB($handler->getSchema('widgets', 'pre_'), $bulk['widgets']); + } + + public function testBulkIntrospectionQueryCountDoesNotGrowWithTableCount(): void + { + if (!$this->isBatchedProvider()) { + $this->markTestSkipped('Driver introspects tables one by one.'); + } + + $this->makeSampleSchema(); + for ($i = 0; $i < 6; $i++) { + $schema = $this->schema("extra_{$i}"); + $schema->primary('id'); + $schema->string("value", 32)->defaultValue('x'); + $schema->save(Handler::DO_ALL); + } + + $few = $this->countBulkQueries(['authors', 'books']); + $many = $this->countBulkQueries( + ['authors', 'books', 'tags', 'extra_0', 'extra_1', 'extra_2', 'extra_3', 'extra_4', 'extra_5'], + ); + + $this->assertSame( + $few[0], + $many[0], + \sprintf( + "Bulk introspection of 9 tables took %d queries instead of %d.\n\nFew:\n%s\n\nMany:\n%s", + $many[0], + $few[0], + $few[1], + $many[1], + ), + ); + } + + /** + * Composite primary keys, foreign keys and indexes are the case where the per-table and the + * batched query could disagree on column order — the batched queries rely on ORDER BY to + * reproduce it. The identity assertion compares those orders exactly. + */ + public function testCompositeKeysMatchPerTable(): void + { + $parent = $this->schema('composite_parent'); + $parent->integer('part_a')->nullable(false); + $parent->integer('part_b')->nullable(false); + $parent->setPrimaryKeys(['part_a', 'part_b']); + $parent->save(Handler::DO_ALL); + + $child = $this->schema('composite_child'); + $child->primary('id'); + $child->integer('ref_a')->nullable(true); + $child->integer('ref_b')->nullable(true); + $child->integer('c1')->defaultValue(0); + $child->integer('c2')->defaultValue(0); + $child->index(['c1', 'c2']); + $child->foreignKey(['ref_a', 'ref_b'])->references('composite_parent', ['part_a', 'part_b']); + $child->save(Handler::DO_ALL); + + $handler = $this->bulkProvider(); + $bulk = $handler->getSchemas(['composite_parent', 'composite_child']); + + $this->assertSame(['part_a', 'part_b'], $bulk['composite_parent']->getPrimaryKeys()); + $this->assertSameAsInDB($handler->getSchema('composite_parent'), $bulk['composite_parent']); + $this->assertSameAsInDB($handler->getSchema('composite_child'), $bulk['composite_child']); + } + + /** + * Column ORDER is not covered by {@see \Cycle\Database\Tests\Traits\TableAssertions::assertSameAsInDB()} + * (it matches columns by name), while the batched catalog queries return rows in + * planner-dependent order unless they ORDER BY the ordinal position. The reorder shows up only + * when the result set is large enough for the planner to prefer a hash join, hence the dozens + * of tables with interleaved column types. + */ + public function testColumnOrderMatchesPerTable(): void + { + $names = []; + for ($i = 0; $i < 30; $i++) { + $name = "col_order_{$i}"; + $schema = $this->schema($name); + $schema->primary('id'); + $schema->string('str_0', 64); + $schema->integer('int_0'); + $schema->string('str_1', 64); + $schema->integer('int_1'); + $schema->string('str_2', 64); + $schema->datetime('created_at'); + $schema->string('str_3', 64); + $schema->save(Handler::DO_ALL); + $names[] = $name; + } + + $handler = $this->bulkProvider(); + $bulk = $handler->getSchemas($names); + + foreach ($names as $name) { + $this->assertSame( + \array_keys($handler->getSchema($name)->getColumns()), + \array_keys($bulk[$name]->getColumns()), + "Column order of {$name} diverged between per-table and bulk introspection", + ); + } + } + + public function testReadonlyHandlerDelegatesBulkIntrospection(): void + { + $this->makeSampleSchema(); + + $inner = $this->bulkProvider(); + $readonly = new ReadonlyHandler($inner); + + $bulk = $readonly->getSchemas(['authors', 'books']); + + $this->assertSame(['authors', 'books'], \array_keys($bulk)); + $this->assertTrue($bulk['authors']->exists()); + $this->assertSameAsInDB($inner->getSchema('authors'), $bulk['authors']); + } + + public function testDatabaseGetSchemasWithExplicitList(): void + { + $this->makeSampleSchema(); + + $schemas = $this->database->getSchemas(['authors', 'books']); + + $this->assertSame(['authors', 'books'], \array_keys($schemas)); + $this->assertTrue($schemas['authors']->exists()); + $this->assertTrue($schemas['books']->exists()); + } + + public function testDatabaseGetSchemasWithoutListReadsWholeDatabase(): void + { + $this->makeSampleSchema(); + + $schemas = $this->database->getSchemas(); + + $names = []; + foreach ($schemas as $schema) { + $this->assertTrue($schema->exists()); + $names[] = $schema->getName(); + } + + \sort($names); + $this->assertSame(['authors', 'books', 'tags'], $names); + } + + /** + * Drivers that actually batch introspection queries opt in here. + */ + protected function isBatchedProvider(): bool + { + return false; + } + + protected function bulkProvider(): BulkSchemaProviderInterface + { + $handler = $this->database->getDriver()->getSchemaHandler(); + $this->assertInstanceOf(BulkSchemaProviderInterface::class, $handler); + + return $handler; + } + + /** + * @param non-empty-string[] $tables + * + * @return array{0: int, 1: QueryCounter} + */ + protected function countBulkQueries(array $tables): array + { + $driver = $this->database->getDriver(); + $counter = new QueryCounter(); + $driver->setLogger($counter); + + try { + $this->bulkProvider()->getSchemas($tables); + } finally { + $driver->setLogger(static::$logger); + } + + return [$counter->count(), $counter]; + } + + /** + * authors(id, name), books(id, title, status enum, author_id -> authors, index on title), + * tags(id, label). Exercises columns, PK, index, FK, native/emulated enum and char columns. + */ + protected function makeSampleSchema(): void + { + $authors = $this->schema('authors'); + $authors->primary('id'); + $authors->string('name', 64)->defaultValue('anonymous'); + $authors->save(Handler::DO_ALL); + + $tags = $this->schema('tags'); + $tags->primary('id'); + $tags->string('label', 32)->defaultValue('tag'); + $tags->save(Handler::DO_ALL); + + $books = $this->schema('books'); + $books->primary('id'); + $books->string('title', 128)->defaultValue('untitled'); + $books->enum('status', ['draft', 'published'])->defaultValue('draft'); + $books->integer('author_id')->nullable(true); + $books->index(['title']); + $books->foreignKey(['author_id'])->references('authors', ['id']); + $books->save(Handler::DO_ALL); + } +} diff --git a/tests/Database/Functional/Driver/MySQL/Schema/BulkIntrospectionTest.php b/tests/Database/Functional/Driver/MySQL/Schema/BulkIntrospectionTest.php new file mode 100644 index 00000000..c6ee1fa9 --- /dev/null +++ b/tests/Database/Functional/Driver/MySQL/Schema/BulkIntrospectionTest.php @@ -0,0 +1,17 @@ +assertSame($driver, $newDb->getDriver(DatabaseInterface::READ)); } + /** + * When the driver's handler does not implement BulkSchemaProviderInterface, Database::getSchemas() + * must fall back to a per-table getSchema() loop, applying the database prefix. + */ + public function testGetSchemasFallsBackToPerTableWhenHandlerIsNotBulkProvider(): void + { + $tableA = $this->createMock(AbstractTable::class); + $tableB = $this->createMock(AbstractTable::class); + + $handler = $this->createMock(HandlerInterface::class); + $handler->method('getSchema')->willReturnMap([ + ['a', 'pre_', $tableA], + ['b', 'pre_', $tableB], + ]); + + $driver = $this->createMock(DriverInterface::class); + $driver->method('getSchemaHandler')->willReturn($handler); + + $database = new Database('default', 'pre_', $driver); + + $this->assertSame(['a' => $tableA, 'b' => $tableB], $database->getSchemas(['a', 'b'])); + } + private function readProperty(object $object, string $property): mixed { $fn = function () use ($property) { diff --git a/tests/Database/Unit/Driver/ReadonlyHandlerTest.php b/tests/Database/Unit/Driver/ReadonlyHandlerTest.php new file mode 100644 index 00000000..1c20ab9c --- /dev/null +++ b/tests/Database/Unit/Driver/ReadonlyHandlerTest.php @@ -0,0 +1,37 @@ +createMock(AbstractTable::class); + $tableB = $this->createMock(AbstractTable::class); + + $parent = $this->createMock(HandlerInterface::class); + $parent->method('getSchema')->willReturnMap([ + ['a', 'pre_', $tableA], + ['b', 'pre_', $tableB], + ]); + + $handler = new ReadonlyHandler($parent); + + $this->assertSame( + ['a' => $tableA, 'b' => $tableB], + $handler->getSchemas(['a', 'b'], 'pre_'), + ); + } +}