From 173011a086ab3a5df2053f3b284e2d15892341c2 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 14:58:07 +0400 Subject: [PATCH 1/9] feat(Schema): add bulk introspection API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce BulkSchemaProviderInterface::getSchemas(array $tables, ?string $prefix) so a set of tables can be introspected together instead of one full introspection per table. The table list is mandatory by design — the queries must never read catalog rows of tables the caller did not ask for; the "whole database" case is the explicit Database::getSchemas(null) path that first resolves names via getTableNames(). The base Handler ships a per-table-loop implementation (correct for every driver) that batching drivers override; ReadonlyHandler delegates. AbstractTable gains an internal prefetch construction path: when raw rows are supplied it derives existence from their presence (no hasTable() query) and drops the payload after the initial load so any later re-introspection still hits the database. Assisted-By: Claude Opus 4.8 (1M context) --- src/Database.php | 39 ++++++++++++++++++++ src/Driver/BulkSchemaProviderInterface.php | 42 ++++++++++++++++++++++ src/Driver/Handler.php | 22 +++++++++++- src/Driver/ReadonlyHandler.php | 22 +++++++++++- src/Schema/AbstractTable.php | 29 +++++++++++++-- src/Schema/PrefetchedIntrospection.php | 38 ++++++++++++++++++++ 6 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 src/Driver/BulkSchemaProviderInterface.php create mode 100644 src/Schema/PrefetchedIntrospection.php diff --git a/src/Database.php b/src/Database.php index 7839a407..41921096 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 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..e6ee64e2 --- /dev/null +++ b/src/Driver/BulkSchemaProviderInterface.php @@ -0,0 +1,42 @@ + 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/ReadonlyHandler.php b/src/Driver/ReadonlyHandler.php index 490f166a..3b4753c7 100644 --- a/src/Driver/ReadonlyHandler.php +++ b/src/Driver/ReadonlyHandler.php @@ -16,12 +16,32 @@ use Cycle\Database\Schema\AbstractIndex; use Cycle\Database\Schema\AbstractTable; -final class ReadonlyHandler implements HandlerInterface +final class ReadonlyHandler implements HandlerInterface, BulkSchemaProviderInterface { public function __construct( private HandlerInterface $parent, ) {} + /** + * @param non-empty-string[] $tables + * + * @return array + */ + #[\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/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, + ) {} +} From 2d7a6208f4e883a22025bc7d41af8f558b98f586 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 14:58:20 +0400 Subject: [PATCH 2/9] perf(Postgres): batch bulk schema introspection Override getSchemas() to read the whole 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, grouped back per table in PHP and injected into PostgresTable via the prefetch path. Because the SQL and the createInstance factories are unchanged, the bulk result is identical to getSchema() by construction. The pair list is chunked so the bound parameter count stays bounded on any set size. Assisted-By: Claude Opus 4.8 (1M context) --- src/Driver/Postgres/PostgresHandler.php | 356 +++++++++++++++++++ src/Driver/Postgres/Schema/PostgresTable.php | 113 +++--- 2 files changed, 421 insertions(+), 48 deletions(-) diff --git a/src/Driver/Postgres/PostgresHandler.php b/src/Driver/Postgres/PostgresHandler.php index 5e9bfbc8..651441a9 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,282 @@ 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'], + ]; + } + } + } + + if ($types === []) { + return []; + } + + $result = []; + foreach (\array_chunk($types, self::BULK_CHUNK, true) as $chunk) { + $placeholders = \implode(', ', \array_fill(0, \count($chunk), '(?, ?)')); + $parameters = \array_merge(...\array_values($chunk)); + + $query = <<driver->query($query, $parameters) as $row) { + $result[$row['nspname'] . '.' . $row['typname']][] = $row['enumlabel']; + } + } + + return $result; + } + + /** + * (schema, table) pairs of the tables that carry a char column with a size — the only ones for + * which CHECK constraints must be resolved. + * + * @param array $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 = << Date: Fri, 14 Aug 2026 14:58:20 +0400 Subject: [PATCH 3/9] perf(SQLServer): batch bulk schema introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override getSchemas() to introspect the whole set with a constant number of queries: the per-table `table_name = ?` filters become `table_name IN (...)`, and DEFAULT/CHECK constraints are read once for all object ids (their keys are database-wide unique). Foreign keys — normally one sp_fkeys call per table, which cannot be batched — are read for the whole set from sys.foreign_keys in a shape that matches the sp_fkeys rows SQLServerForeignKey consumes, with the referential action mapped back to sp_fkeys semantics (0 = CASCADE, non-zero = NO ACTION). Assisted-By: Claude Opus 4.8 (1M context) --- src/Driver/SQLServer/SQLServerHandler.php | 329 ++++++++++++++++++ .../SQLServer/Schema/SQLServerTable.php | 61 ++-- 2 files changed, 370 insertions(+), 20 deletions(-) diff --git a/src/Driver/SQLServer/SQLServerHandler.php b/src/Driver/SQLServer/SQLServerHandler.php index 8b81e11e..8304ccc4 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,264 @@ 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})"); + } + + /** + * @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" From 6e9eddca8a251ff4064495b11366cea72498482d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 14:58:31 +0400 Subject: [PATCH 4/9] test(Schema): cover bulk introspection across all drivers Assert, per driver, that getSchemas() returns a schema identical to the per-table getSchema() (via assertSameAsInDB), that non-existent tables come back as empty STATUS_NEW schemas rather than being skipped, that the prefix is honored, and that Database::getSchemas() works both with an explicit list and for the whole database. Composite primary keys, foreign keys and indexes are covered specifically because that is where the batched queries rely on ORDER BY to reproduce the per-table column order. ReadonlyHandler delegation is covered too. Batching drivers (Postgres, SQLServer) additionally assert the query count does not grow with the number of tables; loop drivers (MySQL, SQLite) skip that check. Assisted-By: Claude Opus 4.8 (1M context) --- src/Driver/BulkSchemaProviderInterface.php | 3 +- src/Driver/HandlerInterface.php | 5 +- .../Common/Schema/BulkIntrospectionTest.php | 250 ++++++++++++++++++ .../MySQL/Schema/BulkIntrospectionTest.php | 17 ++ .../Postgres/Schema/BulkIntrospectionTest.php | 22 ++ .../Schema/BulkIntrospectionTest.php | 22 ++ .../SQLite/Schema/BulkIntrospectionTest.php | 17 ++ 7 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php create mode 100644 tests/Database/Functional/Driver/MySQL/Schema/BulkIntrospectionTest.php create mode 100644 tests/Database/Functional/Driver/Postgres/Schema/BulkIntrospectionTest.php create mode 100644 tests/Database/Functional/Driver/SQLServer/Schema/BulkIntrospectionTest.php create mode 100644 tests/Database/Functional/Driver/SQLite/Schema/BulkIntrospectionTest.php diff --git a/src/Driver/BulkSchemaProviderInterface.php b/src/Driver/BulkSchemaProviderInterface.php index e6ee64e2..aa406713 100644 --- a/src/Driver/BulkSchemaProviderInterface.php +++ b/src/Driver/BulkSchemaProviderInterface.php @@ -35,8 +35,7 @@ interface BulkSchemaProviderInterface * @param non-empty-string[] $tables Table names WITHOUT the database prefix. * @param string|null $prefix Database specific table prefix applied to every table. * - * @return array Keyed by the input table name, in the input - * order. + * @return array Keyed by the input table name, in the input order. */ public function getSchemas(array $tables, ?string $prefix = null): array; } 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/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php b/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php new file mode 100644 index 00000000..15d92aa7 --- /dev/null +++ b/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php @@ -0,0 +1,250 @@ +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']); + } + + 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 @@ + Date: Fri, 14 Aug 2026 19:34:37 +0400 Subject: [PATCH 5/9] fix(Schema): deterministic row order in bulk column introspection Without an explicit ORDER BY, Postgres and SQL Server return catalog rows in planner-dependent order once several tables are fetched in one query, so bulk-introspected tables could get a column order different from the per-table path (varchar columns drifting to the end). Order columns by ordinal position and Postgres index rows by name. Assisted-By: Claude Fable 5 --- src/Driver/Postgres/PostgresHandler.php | 2 ++ src/Driver/SQLServer/SQLServerHandler.php | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Driver/Postgres/PostgresHandler.php b/src/Driver/Postgres/PostgresHandler.php index 651441a9..c766389a 100644 --- a/src/Driver/Postgres/PostgresHandler.php +++ b/src/Driver/Postgres/PostgresHandler.php @@ -357,6 +357,7 @@ private function fetchBulkColumns(array $pairs): array ON (pg_description.objoid = pg_statio_all_tables.relid AND pg_description.objsubid = columns.ordinal_position) WHERE (columns.table_schema, columns.table_name) IN ({$in}) + ORDER BY columns.table_schema, columns.table_name, columns.ordinal_position SQL); } @@ -395,6 +396,7 @@ private function fetchBulkIndexRows(array $pairs): array ON c.conname = i.indexname AND c.connamespace = ns.oid WHERE (i.schemaname, i.tablename) IN ({$in}) + ORDER BY i.schemaname, i.tablename, i.indexname SQL); } diff --git a/src/Driver/SQLServer/SQLServerHandler.php b/src/Driver/SQLServer/SQLServerHandler.php index 8304ccc4..7b13bfbd 100644 --- a/src/Driver/SQLServer/SQLServerHandler.php +++ b/src/Driver/SQLServer/SQLServerHandler.php @@ -299,7 +299,8 @@ private function fetchBulkColumns(array $names): array . '[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})"); + . "WHERE [table_name] IN ({$in}) " + . 'ORDER BY [table_name], [ORDINAL_POSITION]'); } /** From 97c4c681ba8e1ee0162c67475209c1057d0c5680 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 19:39:11 +0400 Subject: [PATCH 6/9] test(Schema): assert bulk introspection preserves column order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertSameAsInDB matches columns by name, so the ordering regression fixed in the previous commit was invisible to the existing identity tests. The new case introspects 30 tables with interleaved column types — enough rows for the Postgres planner to prefer a hash join, which is what scrambled the order — and compares the exact column order against the per-table path. Assisted-By: Claude Fable 5 --- .../Common/Schema/BulkIntrospectionTest.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php b/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php index 15d92aa7..aaad7726 100644 --- a/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php +++ b/tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php @@ -145,6 +145,43 @@ public function testCompositeKeysMatchPerTable(): void $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(); From 6c3b4abe37acb0bb74e0cbce4bbc3ecd3eca6887 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 20:39:01 +0400 Subject: [PATCH 7/9] test(Driver): cover getSchemas per-table fallback branches Add DB-free unit tests for the two defensive fallbacks that the functional suite cannot reach, since every built-in handler implements BulkSchemaProviderInterface: ReadonlyHandler and Database::getSchemas() both loop over getSchema() when the wrapped handler does not support bulk introspection. Mocking a plain HandlerInterface (which is not a BulkSchemaProviderInterface) exercises exactly those branches. Assisted-By: Claude Opus 4.8 (1M context) --- tests/Database/Unit/DatabaseTest.php | 25 +++++++++++++ .../Unit/Driver/ReadonlyHandlerTest.php | 37 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tests/Database/Unit/Driver/ReadonlyHandlerTest.php diff --git a/tests/Database/Unit/DatabaseTest.php b/tests/Database/Unit/DatabaseTest.php index 229de1b0..3fa52741 100644 --- a/tests/Database/Unit/DatabaseTest.php +++ b/tests/Database/Unit/DatabaseTest.php @@ -8,6 +8,8 @@ use Cycle\Database\DatabaseInterface; use Cycle\Database\Driver\Driver; use Cycle\Database\Driver\DriverInterface; +use Cycle\Database\Driver\HandlerInterface; +use Cycle\Database\Schema\AbstractTable; use PHPUnit\Framework\TestCase; final class DatabaseTest extends TestCase @@ -110,6 +112,29 @@ public function testWithoutCacheWithSameDriversAndWithoutMethod(): void $this->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_'), + ); + } +} From 78db9694f1760024dbd23b85e917f80fe69d2e5b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 17:27:06 +0400 Subject: [PATCH 8/9] docs(Driver): fix whole-database entry point reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The getSchemas() docblock named Database::getTables() as the "whole database" path, but that method still returns Table objects and does not batch — the batched whole-database path is Database::getSchemas() called with no list. Spotted in review. Assisted-By: Claude Opus 4.8 (1M context) --- src/Driver/BulkSchemaProviderInterface.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Driver/BulkSchemaProviderInterface.php b/src/Driver/BulkSchemaProviderInterface.php index aa406713..a43bf8ff 100644 --- a/src/Driver/BulkSchemaProviderInterface.php +++ b/src/Driver/BulkSchemaProviderInterface.php @@ -20,8 +20,8 @@ * The list of tables is mandatory: there is deliberately no "no list means the whole database" * mode, so that the queries never read catalog rows of tables the caller did not ask for. The * "all tables of the database" scenario is an explicit path in - * {@see \Cycle\Database\Database::getTables()} that first resolves the names via - * {@see HandlerInterface::getTableNames()} and then passes them here. + * {@see \Cycle\Database\Database::getSchemas()} called with no list, which first resolves the names + * via {@see HandlerInterface::getTableNames()} and then passes them here. */ interface BulkSchemaProviderInterface { From c74243af8e0edecf5dff57b251ce98d2636b0b27 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 19:39:59 +0400 Subject: [PATCH 9/9] refactor(Postgres): reuse fetchByPairs in bulk enum introspection docs(Schema): name the batched-introspection drivers in getSchemas docblock Assisted-By: Claude Fable 5 (1M context) --- src/Database.php | 8 +++--- src/Driver/Postgres/PostgresHandler.php | 33 +++++++++---------------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/src/Database.php b/src/Database.php index 41921096..b100b6fc 100644 --- a/src/Database.php +++ b/src/Database.php @@ -120,10 +120,10 @@ public function table(string $name): Table } /** - * Introspect several tables at once. When the driver supports batched introspection 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. + * 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 diff --git a/src/Driver/Postgres/PostgresHandler.php b/src/Driver/Postgres/PostgresHandler.php index c766389a..59df92be 100644 --- a/src/Driver/Postgres/PostgresHandler.php +++ b/src/Driver/Postgres/PostgresHandler.php @@ -462,29 +462,20 @@ private function fetchBulkEnumValues(array $columns): array } } - if ($types === []) { - return []; - } + $rows = $this->fetchByPairs(\array_values($types), static fn(string $in): string => <<driver->query($query, $parameters) as $row) { - $result[$row['nspname'] . '.' . $row['typname']][] = $row['enumlabel']; - } + foreach ($rows as $row) { + $result[$row['nspname'] . '.' . $row['typname']][] = $row['enumlabel']; } return $result;