From 610936376b047238414294ccc9de4a5e3cdd948b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 11 Aug 2026 12:39:24 +0400 Subject: [PATCH 1/2] perf(Schema): batch table introspection queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(Postgres): resolve native enum values by type schema — an enum type outside the search_path resolved to a same-named type instead Introspection issued a query per column and per constraint, so a wide table cost dozens of round trips. Constraints are now fetched once per table and dispatched in PHP, and the index query is shared between fetchIndexes() and fetchPrimaryKeys(); AbstractTable::resetIntrospectionCache() drops that state before a read and after DDL. The per-column queries stay as a fallback: the new createInstance() arguments are optional, so third-party callers are unaffected. Assisted-By: Claude Opus 5 (1M context) --- src/Driver/MySQL/Schema/MySQLTable.php | 65 ++++-- src/Driver/Postgres/Schema/PostgresColumn.php | 63 ++++-- src/Driver/Postgres/Schema/PostgresTable.php | 195 +++++++++++++---- .../SQLServer/Schema/SQLServerColumn.php | 47 +++-- .../SQLServer/Schema/SQLServerTable.php | 98 ++++++++- src/Driver/SQLite/Schema/SQLiteTable.php | 31 ++- src/Schema/AbstractTable.php | 25 +++ .../Schema/IntrospectionQueryCountTest.php | 199 ++++++++++++++++++ .../Schema/IntrospectionQueryCountTest.php | 17 ++ .../Postgres/Schema/EnumIntrospectionTest.php | 137 ++++++++++++ .../Schema/IntrospectionQueryCountTest.php | 17 ++ .../Schema/CrossSchemaIntrospectionTest.php | 79 +++++++ .../Schema/IntrospectionQueryCountTest.php | 17 ++ .../Schema/IntrospectionQueryCountTest.php | 96 +++++++++ tests/Database/Utils/QueryCounter.php | 56 +++++ 15 files changed, 1057 insertions(+), 85 deletions(-) create mode 100644 tests/Database/Functional/Driver/Common/Schema/IntrospectionQueryCountTest.php create mode 100644 tests/Database/Functional/Driver/MySQL/Schema/IntrospectionQueryCountTest.php create mode 100644 tests/Database/Functional/Driver/Postgres/Schema/EnumIntrospectionTest.php create mode 100644 tests/Database/Functional/Driver/Postgres/Schema/IntrospectionQueryCountTest.php create mode 100644 tests/Database/Functional/Driver/SQLServer/Schema/CrossSchemaIntrospectionTest.php create mode 100644 tests/Database/Functional/Driver/SQLServer/Schema/IntrospectionQueryCountTest.php create mode 100644 tests/Database/Functional/Driver/SQLite/Schema/IntrospectionQueryCountTest.php create mode 100644 tests/Database/Utils/QueryCounter.php diff --git a/src/Driver/MySQL/Schema/MySQLTable.php b/src/Driver/MySQL/Schema/MySQLTable.php index a945a630..93de8547 100644 --- a/src/Driver/MySQL/Schema/MySQLTable.php +++ b/src/Driver/MySQL/Schema/MySQLTable.php @@ -38,6 +38,12 @@ class MySQLTable extends AbstractTable */ private ?string $version = null; + /** + * Memoized `SHOW INDEXES` result, shared between {@see fetchIndexes()} and + * {@see fetchPrimaryKeys()}. + */ + private ?array $indexRows = null; + /** * Change table engine. Such operation will be applied only at moment of table creation. * @@ -67,6 +73,7 @@ public function getEngine(): string /** * Populate table schema with values from database. */ + #[\Override] protected function initSchema(State $state): void { parent::initSchema($state); @@ -80,6 +87,7 @@ protected function initSchema(State $state): void )->fetch()['Engine']; } + #[\Override] protected function isIndexColumnSortingSupported(): bool { if (!$this->version) { @@ -93,6 +101,7 @@ protected function isIndexColumnSortingSupported(): bool return \version_compare($this->version, '8.0', '>='); } + #[\Override] protected function fetchColumns(): array { $query = "SHOW FULL COLUMNS FROM {$this->driver->identifier($this->getFullName())}"; @@ -109,13 +118,18 @@ protected function fetchColumns(): array return $result; } - protected function fetchIndexes(): array + #[\Override] + protected function resetIntrospectionCache(): void { - $query = "SHOW INDEXES FROM {$this->driver->identifier($this->getFullName())}"; + $this->indexRows = null; + } + #[\Override] + protected function fetchIndexes(): array + { //Gluing all index definitions together $schemas = []; - foreach ($this->driver->query($query) as $index) { + foreach ($this->indexRows() as $index) { if ($index['Key_name'] === 'PRIMARY') { //Skipping PRIMARY index continue; @@ -132,26 +146,38 @@ protected function fetchIndexes(): array return $result; } + #[\Override] protected function fetchReferences(): array { $references = $this->driver->query( 'SELECT * FROM `information_schema`.`referential_constraints` WHERE `constraint_schema` = ? AND `table_name` = ?', [$this->driver->getSource(), $this->getFullName()], + )->fetchAll(); + + if ($references === []) { + return []; + } + + // `key_column_usage` is an expensive view, it must not be queried per constraint. + $usage = []; + $rows = $this->driver->query( + 'SELECT * FROM `information_schema`.`key_column_usage` + WHERE `table_schema` = ? AND `table_name` = ? AND `referenced_table_name` IS NOT NULL + ORDER BY `constraint_name`, `ordinal_position`', + [$this->driver->getSource(), $this->getFullName()], ); + foreach ($rows as $row) { + $usage[$row['CONSTRAINT_NAME']][] = $row; + } + $result = []; foreach ($references as $schema) { - $columns = $this->driver->query( - 'SELECT * FROM `information_schema`.`key_column_usage` - WHERE `constraint_name` = ? AND `table_schema` = ? AND `table_name` = ?', - [$schema['CONSTRAINT_NAME'], $this->driver->getSource(), $this->getFullName()], - )->fetchAll(); - $schema['COLUMN_NAME'] = []; $schema['REFERENCED_COLUMN_NAME'] = []; - foreach ($columns as $column) { + foreach ($usage[$schema['CONSTRAINT_NAME']] ?? [] as $column) { $schema['COLUMN_NAME'][] = $column['COLUMN_NAME']; $schema['REFERENCED_COLUMN_NAME'][] = $column['REFERENCED_COLUMN_NAME']; } @@ -169,12 +195,11 @@ protected function fetchReferences(): array /** * Fetching primary keys from table. */ + #[\Override] protected function fetchPrimaryKeys(): array { - $query = "SHOW INDEXES FROM {$this->driver->identifier($this->getFullName())}"; - $primaryKeys = []; - foreach ($this->driver->query($query) as $index) { + foreach ($this->indexRows() as $index) { if ($index['Key_name'] === 'PRIMARY') { $primaryKeys[] = $index['Column_name']; } @@ -186,6 +211,7 @@ protected function fetchPrimaryKeys(): array /** * @psalm-param non-empty-string $name */ + #[\Override] protected function createColumn(string $name): AbstractColumn { return new MySQLColumn($this->getFullName(), $name, $this->driver->getTimezone()); @@ -194,6 +220,7 @@ protected function createColumn(string $name): AbstractColumn /** * @psalm-param non-empty-string $name */ + #[\Override] protected function createIndex(string $name): AbstractIndex { return new MySQLIndex($this->getFullName(), $name); @@ -202,8 +229,20 @@ protected function createIndex(string $name): AbstractIndex /** * @psalm-param non-empty-string $name */ + #[\Override] protected function createForeign(string $name): AbstractForeignKey { return new MySQLForeignKey($this->getFullName(), $this->getPrefix(), $name); } + + /** + * `SHOW INDEXES` carries both the secondary indexes and the primary key, so it is executed once + * per introspection and split in PHP. + */ + private function indexRows(): array + { + return $this->indexRows ??= $this->driver + ->query("SHOW INDEXES FROM {$this->driver->identifier($this->getFullName())}") + ->fetchAll(); + } } diff --git a/src/Driver/Postgres/Schema/PostgresColumn.php b/src/Driver/Postgres/Schema/PostgresColumn.php index 1075f275..56bffef6 100644 --- a/src/Driver/Postgres/Schema/PostgresColumn.php +++ b/src/Driver/Postgres/Schema/PostgresColumn.php @@ -305,12 +305,20 @@ class PostgresColumn extends AbstractColumn /** * @param DriverInterface $driver Postgres columns are bit more complex. + * @param array|null $checkConstraints Pre-fetched CHECK constraints of the whole table keyed by + * the textual `conkey` value. When `null` the constraints are resolved with a dedicated + * query (requires `tableOID` in the `$schema`). + * @param array|null $enumValues Pre-fetched native enum ranges keyed as `.`. + * When `null` the range is resolved with a dedicated query. + * * @psalm-param non-empty-string $table Table name. */ public static function createInstance( string $table, array $schema, DriverInterface $driver, + ?array $checkConstraints = null, + ?array $enumValues = null, ): self { $column = new self($table, $schema['column_name'], $driver->getTimezone()); @@ -365,7 +373,7 @@ public static function createInstance( * Attention, this is not default enum type emulated via CHECK. * This is real Postgres enum type. */ - self::resolveEnum($driver, $column); + self::resolveEnum($driver, $schema, $column, $enumValues); } if ($column->type === 'timestamp' || $column->type === 'time' || $column->type === 'interval') { @@ -382,7 +390,7 @@ public static function createInstance( if (!empty($column->size) && \str_contains($column->type, 'char')) { //Potential enum with manually created constraint (check in) - self::resolveConstrains($driver, $schema, $column); + self::resolveConstrains($driver, $schema, $column, $checkConstraints); } if ($column->type === 'interval' && \is_string($schema['interval_type'])) { @@ -404,6 +412,7 @@ public static function createInstance( return $column; } + #[\Override] public function getConstraints(): array { $constraints = parent::getConstraints(); @@ -418,6 +427,7 @@ public function getConstraints(): array /** * @psalm-return non-empty-string */ + #[\Override] public function getAbstractType(): string { return !empty($this->enumValues) ? 'enum' : parent::getAbstractType(); @@ -459,6 +469,7 @@ public function bigPrimary(): AbstractColumn return $this->type('bigPrimary'); } + #[\Override] public function enum(string|array $values): AbstractColumn { $this->enumValues = \array_map('strval', \is_array($values) ? $values : \func_get_args()); @@ -487,6 +498,7 @@ public function interval(int $size = 6, ?string $intervalType = null): AbstractC /** * @psalm-return non-empty-string */ + #[\Override] public function sqlStatement(DriverInterface $driver): string { $statement = [$driver->identifier($this->name), $this->type]; @@ -626,6 +638,7 @@ public function alterOperations(DriverInterface $driver, AbstractColumn $initial return $operations; } + #[\Override] public function compare(AbstractColumn $initial): bool { if (parent::compare($initial)) { @@ -638,6 +651,7 @@ public function compare(AbstractColumn $initial): bool ); } + #[\Override] public function getComment(): string { return $this->comment; @@ -654,6 +668,7 @@ public function createComment(DriverInterface $driver): string return "COMMENT ON COLUMN {$tableName}.{$identifier} IS " . $driver->quote($this->comment); } + #[\Override] protected static function isJson(AbstractColumn $column): bool { return $column->getAbstractType() === 'json' || $column->getAbstractType() === 'jsonb'; @@ -662,6 +677,7 @@ protected static function isJson(AbstractColumn $column): bool /** * @psalm-return non-empty-string */ + #[\Override] protected function quoteEnum(DriverInterface $driver): string { //Postgres enums are just constrained strings @@ -675,17 +691,24 @@ private static function resolveConstrains( DriverInterface $driver, array $schema, self $column, + ?array $checkConstraints = null, ): void { - $query = "SELECT conname, pg_get_constraintdef(oid) as consrc FROM pg_constraint - WHERE conrelid = ? AND contype = 'c' AND conkey = ?"; - - $constraints = $driver->query( - $query, - [ - $schema['tableOID'], - '{' . $schema['dtd_identifier'] . '}', - ], - ); + $conkey = '{' . $schema['dtd_identifier'] . '}'; + + if ($checkConstraints !== null) { + $constraints = $checkConstraints[$conkey] ?? []; + } else { + $query = "SELECT conname, pg_get_constraintdef(oid) as consrc FROM pg_constraint + WHERE conrelid = ? AND contype = 'c' AND conkey = ?"; + + $constraints = $driver->query( + $query, + [ + $schema['tableOID'], + $conkey, + ], + ); + } foreach ($constraints as $constraint) { $values = static::parseEnumValues($constraint['consrc']); @@ -701,11 +724,19 @@ private static function resolveConstrains( /** * Resolve native ENUM type if presented. */ - private static function resolveEnum(DriverInterface $driver, self $column): void - { - $range = $driver->query('SELECT enum_range(NULL::' . $column->type . ')')->fetchColumn(0); + private static function resolveEnum( + DriverInterface $driver, + array $schema, + self $column, + ?array $enumValues = null, + ): void { + if ($enumValues !== null) { + $column->enumValues = $enumValues[$schema['udt_schema'] . '.' . $schema['udt_name']] ?? []; + } else { + $range = $driver->query('SELECT enum_range(NULL::' . $column->type . ')')->fetchColumn(0); - $column->enumValues = \explode(',', \substr($range, 1, -1)); + $column->enumValues = \explode(',', \substr($range, 1, -1)); + } if (!empty($column->defaultValue)) { //In database: 'value'::enumType diff --git a/src/Driver/Postgres/Schema/PostgresTable.php b/src/Driver/Postgres/Schema/PostgresTable.php index 676ec899..0e9a2424 100644 --- a/src/Driver/Postgres/Schema/PostgresTable.php +++ b/src/Driver/Postgres/Schema/PostgresTable.php @@ -30,6 +30,12 @@ class PostgresTable extends AbstractTable */ private array $sequences = []; + /** + * Memoized result of the index introspection query, shared between {@see fetchIndexes()} + * and {@see fetchPrimaryKeys()}. + */ + private ?array $indexRows = null; + /** * Sequence object name usually defined only for primary keys and required by ORM to correctly * resolve inserted row id. @@ -45,6 +51,7 @@ public function getSequence(): ?string return $this->primarySequence; } + #[\Override] public function getName(): string { return $this->removeSchemaFromTableName($this->getFullName()); @@ -53,6 +60,7 @@ public function getName(): string /** * SQLServer will reload schemas after successful save. */ + #[\Override] public function save(int $operation = HandlerInterface::DO_ALL, bool $reset = true): void { parent::save($operation, $reset); @@ -68,6 +76,7 @@ public function save(int $operation = HandlerInterface::DO_ALL, bool $reset = tr } } + #[\Override] public function getDependencies(): array { $tables = []; @@ -79,21 +88,17 @@ public function getDependencies(): array return $tables; } + #[\Override] + protected function resetIntrospectionCache(): void + { + $this->indexRows = null; + } + + #[\Override] protected function fetchColumns(): array { [$tableSchema, $tableName] = $this->driver->parseSchemaAndTable($this->getFullName()); - //Required for constraints fetch - $tableOID = $this->driver->query( - 'SELECT pgc.oid - FROM pg_class as pgc - JOIN pg_namespace as pgn - ON (pgn.oid = pgc.relnamespace) - WHERE pgn.nspname = ? - AND pgc.relname = ?', - [$tableSchema, $tableName], - )->fetchColumn(); - $query = $this->driver->query( 'SELECT columns.*, pg_type.*, pg_description.description FROM information_schema.columns @@ -126,8 +131,13 @@ protected function fetchColumns(): array [$tableSchema, $tableName], )->fetchAll(), 'column_name'); + $schemas = $query->fetchAll(); + + $checkConstraints = $this->fetchCheckConstraints($tableSchema, $tableName, $schemas); + $enumValues = $this->fetchEnumValues($schemas); + $result = []; - foreach ($query->fetchAll() as $schema) { + foreach ($schemas as $schema) { $name = $schema['column_name']; if ( \is_string($schema['column_default']) @@ -145,31 +155,23 @@ protected function fetchColumns(): array $result[] = PostgresColumn::createInstance( $tableSchema . '.' . $tableName, - $schema + ['tableOID' => $tableOID], + $schema, $this->driver, + $checkConstraints, + $enumValues, ); } return $result; } + #[\Override] protected function fetchIndexes(bool $all = false): array { [$tableSchema, $tableName] = $this->driver->parseSchemaAndTable($this->getFullName()); - $query = <<driver->query($query, [$tableSchema, $tableName]) as $schema) { + foreach ($this->indexRows() as $schema) { if ($schema['contype'] === 'p') { //Skipping primary keys continue; @@ -180,6 +182,7 @@ protected function fetchIndexes(bool $all = false): array return $result; } + #[\Override] protected function fetchReferences(): array { [$tableSchema, $tableName] = $this->driver->parseSchemaAndTable($this->getFullName()); @@ -222,23 +225,16 @@ protected function fetchReferences(): array return $result; } + #[\Override] protected function fetchPrimaryKeys(): array { [$tableSchema, $tableName] = $this->driver->parseSchemaAndTable($this->getFullName()); - $query = <<indexRows() as $schema) { + if ($schema['contype'] !== 'p') { + continue; + } - foreach ($this->driver->query($query, [$tableSchema, $tableName]) as $schema) { //To simplify definitions $index = PostgresIndex::createInstance($tableSchema . '.' . $tableName, $schema); @@ -260,6 +256,7 @@ protected function fetchPrimaryKeys(): array /** * @psalm-param non-empty-string $name */ + #[\Override] protected function createColumn(string $name): AbstractColumn { return new PostgresColumn( @@ -272,6 +269,7 @@ protected function createColumn(string $name): AbstractColumn /** * @psalm-param non-empty-string $name */ + #[\Override] protected function createIndex(string $name): AbstractIndex { return new PostgresIndex( @@ -283,6 +281,7 @@ protected function createIndex(string $name): AbstractIndex /** * @psalm-param non-empty-string $name */ + #[\Override] protected function createForeign(string $name): AbstractForeignKey { return new PostgresForeignKey( @@ -295,6 +294,7 @@ protected function createForeign(string $name): AbstractForeignKey /** * @psalm-param non-empty-string $name */ + #[\Override] protected function prefixTableName(string $name): string { [$schema, $name] = $this->driver->parseSchemaAndTable($name); @@ -325,4 +325,125 @@ protected function removeSchemaFromTableName(string $name): string return $name; } + + /** + * Both {@see fetchIndexes()} and {@see fetchPrimaryKeys()} are based on the same data set, + * so it is fetched once and split by the constraint type in PHP. + */ + private function indexRows(): array + { + if ($this->indexRows !== null) { + return $this->indexRows; + } + + [$tableSchema, $tableName] = $this->driver->parseSchemaAndTable($this->getFullName()); + + $query = <<indexRows = $this->driver->query($query, [$tableSchema, $tableName])->fetchAll(); + } + + /** + * Fetch all single-column CHECK constraints of the table at once (they are used to detect + * enums emulated via CHECK), keyed by the textual representation of {@see pg_constraint.conkey}. + * + * @param array $schemas Rows of the column introspection query. + * + * @return array> + */ + private function fetchCheckConstraints(string $tableSchema, string $tableName, array $schemas): array + { + if (!$this->hasConstrainedColumns($schemas)) { + return []; + } + + $query = <<driver->query($query, [$tableSchema, $tableName]) as $constraint) { + $result[(string) $constraint['conkey']][] = $constraint; + } + + return $result; + } + + /** + * Only `character`-like columns with a size may carry an emulated enum constraint, + * see {@see PostgresColumn::createInstance()}. + */ + private function hasConstrainedColumns(array $schemas): bool + { + foreach ($schemas as $schema) { + if ( + $schema['character_maximum_length'] !== null + && \str_contains((string) $schema['data_type'], 'char') + ) { + return true; + } + } + + return false; + } + + /** + * Fetch value ranges of all native enum types used by the table with a single query. + * + * @param array $schemas Rows of the column introspection query. + * + * @return array> Keyed as `.`. + */ + private function fetchEnumValues(array $schemas): array + { + $types = []; + foreach ($schemas 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 []; + } + + $placeholders = \implode(', ', \array_fill(0, \count($types), '(?, ?)')); + $parameters = \array_merge(...\array_values($types)); + + $query = <<driver->query($query, $parameters) as $row) { + $result[$row['nspname'] . '.' . $row['typname']][] = $row['enumlabel']; + } + + return $result; + } } diff --git a/src/Driver/SQLServer/Schema/SQLServerColumn.php b/src/Driver/SQLServer/Schema/SQLServerColumn.php index 42c097c4..90d0d66d 100644 --- a/src/Driver/SQLServer/Schema/SQLServerColumn.php +++ b/src/Driver/SQLServer/Schema/SQLServerColumn.php @@ -146,12 +146,19 @@ class SQLServerColumn extends AbstractColumn /** * @param DriverInterface $driver SQLServer columns are bit more complex. + * @param array|null $defaultConstraints Pre-fetched DEFAULT constraint names of the whole table + * keyed by the constraint object id. When `null` the name is resolved with a dedicated query. + * @param array|null $checkConstraints Pre-fetched CHECK constraints of the whole table keyed as + * `:`. When `null` they are resolved with a dedicated query. + * * @psalm-param non-empty-string $table Table name. */ public static function createInstance( string $table, array $schema, DriverInterface $driver, + ?array $defaultConstraints = null, + ?array $checkConstraints = null, ): self { $column = new self($table, $schema['COLUMN_NAME'], $driver->getTimezone()); @@ -185,12 +192,14 @@ public static function createInstance( if (!empty($schema['default_object_id'])) { //Looking for default constrain id - $column->defaultConstraint = $driver->query( - 'SELECT [name] FROM [sys].[default_constraints] WHERE [object_id] = ?', - [ - $schema['default_object_id'], - ], - )->fetchColumn(); + $column->defaultConstraint = $defaultConstraints !== null + ? ($defaultConstraints[(string) $schema['default_object_id']] ?? '') + : $driver->query( + 'SELECT [name] FROM [sys].[default_constraints] WHERE [object_id] = ?', + [ + $schema['default_object_id'], + ], + )->fetchColumn(); if (!empty($column->defaultConstraint)) { $column->constrainedDefault = true; @@ -199,12 +208,13 @@ public static function createInstance( //Potential enum if ($column->type === 'varchar' && !empty($column->size)) { - self::resolveEnum($driver, $schema, $column); + self::resolveEnum($driver, $schema, $column, $checkConstraints); } return $column; } + #[\Override] public function getConstraints(): array { $constraints = parent::getConstraints(); @@ -220,11 +230,13 @@ public function getConstraints(): array return $constraints; } + #[\Override] public function getAbstractType(): string { return !empty($this->enumValues) ? 'enum' : parent::getAbstractType(); } + #[\Override] public function enum(mixed $values): AbstractColumn { $this->enumValues = \array_map('strval', \is_array($values) ? $values : \func_get_args()); @@ -238,6 +250,7 @@ public function enum(mixed $values): AbstractColumn return $this; } + #[\Override] public function datetime(int $size = 0, mixed ...$attributes): self { $size === 0 ? $this->type('datetime') : $this->type('datetime2'); @@ -257,6 +270,7 @@ public function datetime(int $size = 0, mixed ...$attributes): self * * @psalm-return non-empty-string */ + #[\Override] public function sqlStatement(DriverInterface $driver, bool $withEnum = true): string { if ($withEnum && $this->getAbstractType() === 'enum') { @@ -352,6 +366,7 @@ public function alterOperations(DriverInterface $driver, AbstractColumn $initial return $operations; } + #[\Override] protected static function isJson(AbstractColumn $column): ?bool { // In SQL Server, we cannot determine if a column has a JSON type. @@ -361,6 +376,7 @@ protected static function isJson(AbstractColumn $column): ?bool /** * @psalm-return non-empty-string */ + #[\Override] protected function quoteDefault(DriverInterface $driver): string { $defaultValue = parent::quoteDefault($driver); @@ -402,13 +418,18 @@ private static function resolveEnum( DriverInterface $driver, array $schema, self $column, + ?array $checkConstraints = null, ): void { - $query = 'SELECT object_definition([o].[object_id]) AS [definition], ' - . "OBJECT_NAME([o].[object_id]) AS [name]\nFROM [sys].[objects] AS [o]\n" - . "JOIN [sys].[sysconstraints] AS [c] ON [o].[object_id] = [c].[constid]\n" - . "WHERE [type_desc] = 'CHECK_CONSTRAINT' AND [parent_object_id] = ? AND [c].[colid] = ?"; - - $constraints = $driver->query($query, [$schema['object_id'], $schema['column_id']]); + if ($checkConstraints !== null) { + $constraints = $checkConstraints[$schema['object_id'] . ':' . $schema['column_id']] ?? []; + } else { + $query = 'SELECT object_definition([o].[object_id]) AS [definition], ' + . "OBJECT_NAME([o].[object_id]) AS [name]\nFROM [sys].[objects] AS [o]\n" + . "JOIN [sys].[sysconstraints] AS [c] ON [o].[object_id] = [c].[constid]\n" + . "WHERE [type_desc] = 'CHECK_CONSTRAINT' AND [parent_object_id] = ? AND [c].[colid] = ?"; + + $constraints = $driver->query($query, [$schema['object_id'], $schema['column_id']]); + } foreach ($constraints as $constraint) { $column->enumConstraint = $constraint['name']; diff --git a/src/Driver/SQLServer/Schema/SQLServerTable.php b/src/Driver/SQLServer/Schema/SQLServerTable.php index 872a6c68..148bd5be 100644 --- a/src/Driver/SQLServer/Schema/SQLServerTable.php +++ b/src/Driver/SQLServer/Schema/SQLServerTable.php @@ -24,6 +24,7 @@ class SQLServerTable extends AbstractTable * * SQLServer will reload schemas after successful savw. */ + #[\Override] public function save(int $operation = HandlerInterface::DO_ALL, bool $reset = true): void { parent::save($operation, $reset); @@ -39,25 +40,47 @@ 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] = ?'; + $schemas = $this->driver->query($query, [$this->getFullName()])->fetchAll(); + + 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 ($this->driver->query($query, [$this->getFullName()]) as $schema) { + foreach ($schemas as $schema) { //Column initialization needs driver to properly resolve enum type $result[] = SQLServerColumn::createInstance( $this->getFullName(), $schema, $this->driver, + $defaultConstraints, + $checkConstraints, ); } return $result; } + #[\Override] protected function fetchIndexes(): array { $query = 'SELECT [indexes].[name] AS [indexName], ' @@ -87,6 +110,7 @@ protected function fetchIndexes(): array return $result; } + #[\Override] protected function fetchReferences(): array { $query = $this->driver->query('sp_fkeys @fktable_name = ?', [$this->getFullName()]); @@ -117,6 +141,7 @@ protected function fetchReferences(): array return $result; } + #[\Override] protected function fetchPrimaryKeys(): array { $query = "SELECT [indexes].[name] AS [indexName], [cl].[name] AS [columnName]\n" @@ -138,18 +163,89 @@ protected function fetchPrimaryKeys(): array return $result; } + #[\Override] protected function createColumn(string $name): AbstractColumn { return new SQLServerColumn($this->getFullName(), $name, $this->driver->getTimezone()); } + #[\Override] protected function createIndex(string $name): AbstractIndex { return new SQLServerIndex($this->getFullName(), $name); } + #[\Override] protected function createForeign(string $name): AbstractForeignKey { return new SQlServerForeignKey($this->getFullName(), $this->getPrefix(), $name); } + + /** + * Fetch names of all DEFAULT constraints of the given tables at once, keyed by the constraint + * object id (it is unique database-wide). + * + * @return array + */ + private function fetchDefaultConstraints(array $objectIds, array $schemas): array + { + $required = false; + foreach ($schemas as $schema) { + if (!empty($schema['default_object_id'])) { + $required = true; + break; + } + } + + if (!$required) { + return []; + } + + $placeholders = \implode(', ', \array_fill(0, \count($objectIds), '?')); + $query = "SELECT [object_id], [name] FROM [sys].[default_constraints] + WHERE [parent_object_id] IN ({$placeholders})"; + + $result = []; + foreach ($this->driver->query($query, $objectIds) as $constraint) { + $result[(string) $constraint['object_id']] = $constraint['name']; + } + + return $result; + } + + /** + * Fetch all CHECK constraints of the given tables at once (they are used to detect emulated + * enums), keyed as `
:` — the column id alone is ambiguous when + * the rows belong to more than one table. + * + * @return array> + */ + private function fetchCheckConstraints(array $objectIds, array $schemas): array + { + $required = false; + foreach ($schemas as $schema) { + if ($schema['DATA_TYPE'] === 'varchar' && !empty($schema['CHARACTER_MAXIMUM_LENGTH'])) { + $required = true; + break; + } + } + + if (!$required) { + return []; + } + + $placeholders = \implode(', ', \array_fill(0, \count($objectIds), '?')); + $query = '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]\n" + . "FROM [sys].[objects] AS [o]\n" + . "JOIN [sys].[sysconstraints] AS [c] ON [o].[object_id] = [c].[constid]\n" + . "WHERE [type_desc] = 'CHECK_CONSTRAINT' AND [parent_object_id] IN ({$placeholders})"; + + $result = []; + foreach ($this->driver->query($query, $objectIds) as $constraint) { + $result[$constraint['parentId'] . ':' . $constraint['colid']][] = $constraint; + } + + return $result; + } } diff --git a/src/Driver/SQLite/Schema/SQLiteTable.php b/src/Driver/SQLite/Schema/SQLiteTable.php index 8c2ccc57..817f20a4 100644 --- a/src/Driver/SQLite/Schema/SQLiteTable.php +++ b/src/Driver/SQLite/Schema/SQLiteTable.php @@ -18,6 +18,19 @@ class SQLiteTable extends AbstractTable { + /** + * Memoized `PRAGMA TABLE_INFO` result. It is requested by {@see fetchColumns()} and + * {@see fetchPrimaryKeys()}, and the latter is called more than once per introspection. + */ + private ?array $tableInfo = null; + + #[\Override] + protected function resetIntrospectionCache(): void + { + $this->tableInfo = null; + } + + #[\Override] protected function fetchColumns(): array { /** @@ -52,6 +65,7 @@ protected function fetchColumns(): array return $result; } + #[\Override] protected function fetchIndexes(): array { $primaryKeys = $this->fetchPrimaryKeys(); @@ -84,6 +98,7 @@ protected function fetchIndexes(): array return $result; } + #[\Override] protected function fetchReferences(): array { $query = "PRAGMA foreign_key_list({$this->driver->quote($this->getFullName())})"; @@ -116,8 +131,8 @@ protected function fetchReferences(): array /** * Fetching primary keys from table. - * */ + #[\Override] protected function fetchPrimaryKeys(): array { $primaryKeys = []; @@ -130,16 +145,19 @@ protected function fetchPrimaryKeys(): array return $primaryKeys; } + #[\Override] protected function createColumn(string $name): AbstractColumn { return new SQLiteColumn($this->getFullName(), $name, $this->driver->getTimezone()); } + #[\Override] protected function createIndex(string $name): AbstractIndex { return new SQLiteIndex($this->getFullName(), $name); } + #[\Override] protected function createForeign(string $name): AbstractForeignKey { return new SQLiteForeignKey($this->getFullName(), $this->getPrefix(), $name); @@ -147,17 +165,20 @@ protected function createForeign(string $name): AbstractForeignKey /** * @param array $include Include following parameters into each line. - * */ private function columnSchemas(array $include = []): array { - $columns = $this->driver->query( + $this->tableInfo ??= $this->driver->query( 'PRAGMA TABLE_INFO(' . $this->driver->quote($this->getFullName()) . ')', - ); + )->fetchAll(); + + if ($include === []) { + return $this->tableInfo; + } $result = []; - foreach ($columns as $column) { + foreach ($this->tableInfo as $column) { $result[] = $column + $include; } diff --git a/src/Schema/AbstractTable.php b/src/Schema/AbstractTable.php index cbb1f276..a7b68a8d 100644 --- a/src/Schema/AbstractTable.php +++ b/src/Schema/AbstractTable.php @@ -145,6 +145,7 @@ public function getComparator(): ComparatorInterface return new Comparator($this->initial, $this->current); } + #[\Override] public function exists(): bool { // Declared as dropped != actually dropped @@ -176,6 +177,7 @@ public function setName(string $name): string /** * @psalm-return non-empty-string */ + #[\Override] public function getName(): string { return $this->getFullName(); @@ -184,6 +186,7 @@ public function getName(): string /** * @psalm-return non-empty-string */ + #[\Override] public function getFullName(): string { return $this->current->getName(); @@ -228,11 +231,13 @@ public function setPrimaryKeys(array $columns): self return $this; } + #[\Override] public function getPrimaryKeys(): array { return $this->current->getPrimaryKeys(); } + #[\Override] public function hasColumn(string $name): bool { return $this->current->hasColumn($name); @@ -241,11 +246,13 @@ public function hasColumn(string $name): bool /** * @return AbstractColumn[] */ + #[\Override] public function getColumns(): array { return $this->current->getColumns(); } + #[\Override] public function hasIndex(array $columns = []): bool { return $this->current->hasIndex($columns); @@ -254,11 +261,13 @@ public function hasIndex(array $columns = []): bool /** * @return AbstractIndex[] */ + #[\Override] public function getIndexes(): array { return $this->current->getIndexes(); } + #[\Override] public function hasForeignKey(array $columns): bool { return $this->current->hasForeignKey($columns); @@ -267,11 +276,13 @@ public function hasForeignKey(array $columns): bool /** * @return AbstractForeignKey[] */ + #[\Override] public function getForeignKeys(): array { return $this->current->getForeignKeys(); } + #[\Override] public function getDependencies(): array { $tables = []; @@ -586,6 +597,9 @@ public function save(int $operation = HandlerInterface::DO_ALL, bool $reset = tr } } + // Introspection results are not valid anymore + $this->resetIntrospectionCache(); + // Syncing our schemas if ($reset) { $this->status = self::STATUS_EXISTS; @@ -760,6 +774,8 @@ protected function normalizeSchema(bool $withForeignKeys = true): self */ protected function initSchema(State $state): void { + $this->resetIntrospectionCache(); + foreach ($this->fetchColumns() as $column) { $state->registerColumn($column); } @@ -781,6 +797,15 @@ protected function isIndexColumnSortingSupported(): bool return true; } + /** + * Drop driver specific introspection results memoized for the duration of a single + * introspection pass. Called before the schema is read and after it has been modified. + */ + protected function resetIntrospectionCache(): void + { + // Nothing to do by default. + } + /** * Fetch index declarations from database. * diff --git a/tests/Database/Functional/Driver/Common/Schema/IntrospectionQueryCountTest.php b/tests/Database/Functional/Driver/Common/Schema/IntrospectionQueryCountTest.php new file mode 100644 index 00000000..83f23f11 --- /dev/null +++ b/tests/Database/Functional/Driver/Common/Schema/IntrospectionQueryCountTest.php @@ -0,0 +1,199 @@ +makeParents(1); + + $this->makeTable('narrow', columns: 2, indexes: 1, foreignKeys: 1); + $this->makeTable('wide', columns: 40, indexes: 1, foreignKeys: 1); + + $narrow = $this->countIntrospectionQueries('narrow'); + $wide = $this->countIntrospectionQueries('wide'); + + $this->assertSame( + $narrow[0], + $wide[0], + \sprintf( + "Introspection of a 40 column table took %d queries instead of %d.\n\nNarrow:\n%s\n\nWide:\n%s", + $wide[0], + $narrow[0], + $narrow[1], + $wide[1], + ), + ); + } + + public function testForeignKeyCountDoesNotAffectQueryCount(): void + { + $this->makeParents(5); + + $this->makeTable('single', columns: 2, indexes: 0, foreignKeys: 1); + $this->makeTable('many', columns: 2, indexes: 0, foreignKeys: 5); + + $single = $this->countIntrospectionQueries('single'); + $many = $this->countIntrospectionQueries('many'); + + $this->assertSame( + $single[0], + $many[0], + \sprintf( + "Introspection of a table with 5 foreign keys took %d queries instead of %d.\n\n" + . "Single:\n%s\n\nMany:\n%s", + $many[0], + $single[0], + $single[1], + $many[1], + ), + ); + } + + public function testIndexCountDoesNotAffectQueryCount(): void + { + $this->makeTable('single', columns: 5, indexes: 1, foreignKeys: 0); + $this->makeTable('many', columns: 5, indexes: 5, foreignKeys: 0); + + $single = $this->countIntrospectionQueries('single'); + $many = $this->countIntrospectionQueries('many'); + + $this->assertSame( + $single[0], + $many[0], + \sprintf( + "Introspection of a table with 5 indexes took %d queries instead of %d.\n\n" + . "Single:\n%s\n\nMany:\n%s", + $many[0], + $single[0], + $single[1], + $many[1], + ), + ); + } + + /** + * Wide table introspection must not degrade into dozens of round trips even in absolute numbers. + */ + public function testWideTableIntrospectionIsCheap(): void + { + $this->makeParents(static::WIDE_FOREIGN_KEYS); + $this->makeTable( + 'wide', + columns: static::WIDE_COLUMNS, + indexes: static::WIDE_INDEXES, + foreignKeys: static::WIDE_FOREIGN_KEYS, + ); + + [$count, $queries] = $this->countIntrospectionQueries('wide'); + + $this->assertLessThanOrEqual( + $this->getIntrospectionQueryLimit(), + $count, + "Too many introspection queries:\n{$queries}", + ); + } + + public function setUp(): void + { + parent::setUp(); + + $this->counter = new QueryCounter(); + } + + public function tearDown(): void + { + $this->database->getDriver()->setLogger(static::$logger); + + parent::tearDown(); + } + + /** + * Maximum number of queries a single table introspection is allowed to make. + */ + protected function getIntrospectionQueryLimit(): int + { + return 10; + } + + /** + * @return array{0: int, 1: QueryCounter} + */ + protected function countIntrospectionQueries(string $table): array + { + $driver = $this->database->getDriver(); + + $this->counter->reset(); + $driver->setLogger($this->counter); + + try { + $schema = $this->schema($table); + $this->assertTrue($schema->exists()); + } finally { + $driver->setLogger(static::$logger); + } + + return [$this->counter->count(), clone $this->counter]; + } + + protected function makeParents(int $count): void + { + for ($i = 0; $i < $count; $i++) { + $schema = $this->schema("parent_{$i}"); + $schema->primary('id'); + $schema->save(Handler::DO_ALL); + } + } + + protected function makeTable(string $name, int $columns, int $indexes, int $foreignKeys): AbstractTable + { + $schema = $this->schema($name); + $schema->primary('id'); + + // `string` with a size and a default value is the worst case: it triggers both the CHECK + // constraint lookup (emulated enums) and the DEFAULT constraint lookup. + for ($i = 0; $i < $columns; $i++) { + $schema->string("column_{$i}", 64)->defaultValue("value_{$i}"); + } + + // A native enum is resolved separately from the emulated ones. + $schema->enum('status', ['active', 'disabled'])->defaultValue('active'); + + for ($i = 0; $i < $indexes; $i++) { + $schema->integer("indexed_{$i}")->defaultValue(0); + $schema->index(["indexed_{$i}"]); + } + + for ($i = 0; $i < $foreignKeys; $i++) { + $schema->integer("parent_{$i}_id")->nullable(true); + $schema->foreignKey(["parent_{$i}_id"])->references("parent_{$i}", ['id']); + } + + $schema->save(Handler::DO_ALL); + + return $schema; + } +} diff --git a/tests/Database/Functional/Driver/MySQL/Schema/IntrospectionQueryCountTest.php b/tests/Database/Functional/Driver/MySQL/Schema/IntrospectionQueryCountTest.php new file mode 100644 index 00000000..3649307e --- /dev/null +++ b/tests/Database/Functional/Driver/MySQL/Schema/IntrospectionQueryCountTest.php @@ -0,0 +1,17 @@ +database->getDriver(); + + $driver->execute("CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')"); + $driver->execute("CREATE TYPE weather AS ENUM ('rain', 'sun')"); + $driver->execute( + 'CREATE TABLE mixed_enums ( + id serial NOT NULL, + name text, + current_mood mood, + forecast weather, + plain_string character varying(64), + status character varying(16), + CONSTRAINT mixed_enums_status_check CHECK (status IN (\'active\', \'disabled\')), + CONSTRAINT mixed_enums_pkey PRIMARY KEY (id) + )', + ); + + $schema = $driver->getSchema('mixed_enums'); + + // Two different native enum types + $this->assertSame('enum', $schema->column('current_mood')->getAbstractType()); + $this->assertSame(['sad', 'ok', 'happy'], $schema->column('current_mood')->getEnumValues()); + + $this->assertSame('enum', $schema->column('forecast')->getAbstractType()); + $this->assertSame(['rain', 'sun'], $schema->column('forecast')->getEnumValues()); + + // Enum emulated via a CHECK constraint + $this->assertSame('enum', $schema->column('status')->getAbstractType()); + $this->assertSame(['active', 'disabled'], $schema->column('status')->getEnumValues()); + + // A varchar without a constraint must stay a plain string + $this->assertSame('string', $schema->column('plain_string')->getAbstractType()); + $this->assertSame([], $schema->column('plain_string')->getEnumValues()); + + $this->assertSame(['id'], $schema->getPrimaryKeys()); + } + + /** + * The enum type of a column must be resolved by both the type name and the type schema: + * same-named enum types from other schemas must not interfere, and a type outside of the + * `search_path` must still be resolvable. + */ + public function testSameNamedEnumsInDifferentSchemas(): void + { + $driver = $this->database->getDriver(); + + $driver->execute('CREATE SCHEMA enum_intro'); + $driver->execute("CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')"); + $driver->execute("CREATE TYPE enum_intro.mood AS ENUM ('angry', 'calm')"); + $driver->execute( + 'CREATE TABLE mixed_enums ( + id serial NOT NULL, + public_mood mood, + foreign_mood enum_intro.mood, + CONSTRAINT mixed_enums_pkey PRIMARY KEY (id) + )', + ); + + $schema = $driver->getSchema('mixed_enums'); + + $this->assertSame('enum', $schema->column('public_mood')->getAbstractType()); + $this->assertSame(['sad', 'ok', 'happy'], $schema->column('public_mood')->getEnumValues()); + + $this->assertSame('enum', $schema->column('foreign_mood')->getAbstractType()); + $this->assertSame(['angry', 'calm'], $schema->column('foreign_mood')->getEnumValues()); + } + + public function testCompositePrimaryKeyWithEmulatedEnum(): void + { + $driver = $this->database->getDriver(); + + $driver->execute( + 'CREATE TABLE mixed_enums ( + left_id integer NOT NULL, + right_id integer NOT NULL, + status character varying(16), + CONSTRAINT mixed_enums_status_check CHECK (status IN (\'active\', \'disabled\')), + CONSTRAINT mixed_enums_pkey PRIMARY KEY (left_id, right_id) + )', + ); + + $schema = $driver->getSchema('mixed_enums'); + + $this->assertSame(['left_id', 'right_id'], $schema->getPrimaryKeys()); + $this->assertSame(['active', 'disabled'], $schema->column('status')->getEnumValues()); + // The composite PK constraint must not be reported as a regular index + $this->assertSame([], $schema->getIndexes()); + } + + public function tearDown(): void + { + $driver = $this->database->getDriver(); + + foreach (['mixed_enums'] as $table) { + try { + $driver->execute("DROP TABLE IF EXISTS {$table}"); + } catch (StatementException) { + } + } + + foreach (['mood', 'weather'] as $type) { + try { + $driver->execute("DROP TYPE IF EXISTS {$type}"); + } catch (StatementException) { + } + } + + try { + $driver->execute('DROP SCHEMA IF EXISTS enum_intro CASCADE'); + } catch (StatementException) { + } + + parent::tearDown(); + } +} diff --git a/tests/Database/Functional/Driver/Postgres/Schema/IntrospectionQueryCountTest.php b/tests/Database/Functional/Driver/Postgres/Schema/IntrospectionQueryCountTest.php new file mode 100644 index 00000000..10297573 --- /dev/null +++ b/tests/Database/Functional/Driver/Postgres/Schema/IntrospectionQueryCountTest.php @@ -0,0 +1,17 @@ +database->getDriver(); + + $driver->execute('CREATE SCHEMA [intro_other]'); + $driver->execute( + "CREATE TABLE [dbo].[intro_dup] ( + [id] int NOT NULL, + [status] varchar(16) NOT NULL CONSTRAINT [intro_dup_status_default] DEFAULT 'active', + CONSTRAINT [intro_dup_status_check] CHECK ([status] IN ('active', 'disabled')) + )", + ); + // The padding columns shift [mode] to a column id different from the one [status] has + // in [dbo].[intro_dup]. + $driver->execute( + "CREATE TABLE [intro_other].[intro_dup] ( + [id] int NOT NULL, + [padding_a] int, + [padding_b] int, + [mode] varchar(8) NOT NULL CONSTRAINT [intro_dup_mode_default] DEFAULT 'x', + CONSTRAINT [intro_dup_mode_check] CHECK ([mode] IN ('x', 'y')) + )", + ); + + $schema = $driver->getSchema('intro_dup'); + + $status = $schema->column('status'); + $this->assertSame('enum', $status->getAbstractType()); + $this->assertSame(['active', 'disabled'], $status->getEnumValues()); + $this->assertContains('intro_dup_status_default', $status->getConstraints()); + $this->assertContains('intro_dup_status_check', $status->getConstraints()); + + $mode = $schema->column('mode'); + $this->assertSame('enum', $mode->getAbstractType()); + $this->assertSame(['x', 'y'], $mode->getEnumValues()); + $this->assertContains('intro_dup_mode_default', $mode->getConstraints()); + $this->assertContains('intro_dup_mode_check', $mode->getConstraints()); + } + + public function tearDown(): void + { + $driver = $this->database->getDriver(); + + foreach (['[dbo].[intro_dup]', '[intro_other].[intro_dup]'] as $table) { + try { + $driver->execute("DROP TABLE IF EXISTS {$table}"); + } catch (StatementException) { + } + } + + try { + $driver->execute('DROP SCHEMA IF EXISTS [intro_other]'); + } catch (StatementException) { + } + + parent::tearDown(); + } +} diff --git a/tests/Database/Functional/Driver/SQLServer/Schema/IntrospectionQueryCountTest.php b/tests/Database/Functional/Driver/SQLServer/Schema/IntrospectionQueryCountTest.php new file mode 100644 index 00000000..8518d2ba --- /dev/null +++ b/tests/Database/Functional/Driver/SQLServer/Schema/IntrospectionQueryCountTest.php @@ -0,0 +1,17 @@ +makeTable('single', columns: 5, indexes: $singleIndexes, foreignKeys: 0); + $this->makeTable('many', columns: 5, indexes: $manyIndexes, foreignKeys: 0); + + [$single] = $this->countIntrospectionQueries('single'); + [$many] = $this->countIntrospectionQueries('many'); + + $this->assertSame(self::QUERIES_PER_INDEX * ($manyIndexes - $singleIndexes), $many - $single); + } + + /** + * Every foreign key implies an index over its columns, so this case degrades into the per-index + * cost described in {@see testIndexCountDoesNotAffectQueryCount()}. The foreign keys themselves + * are still read with a single `PRAGMA foreign_key_list`. + */ + public function testForeignKeyCountDoesNotAffectQueryCount(): void + { + $singleForeignKeys = 1; + $manyForeignKeys = 5; + + $this->makeParents($manyForeignKeys); + + $this->makeTable('single', columns: 2, indexes: 0, foreignKeys: $singleForeignKeys); + $this->makeTable('many', columns: 2, indexes: 0, foreignKeys: $manyForeignKeys); + + [$single, $singleQueries] = $this->countIntrospectionQueries('single'); + [$many, $manyQueries] = $this->countIntrospectionQueries('many'); + + $this->assertSame( + self::QUERIES_PER_INDEX * ($manyForeignKeys - $singleForeignKeys), + $many - $single, + ); + $this->assertSame(1, $this->countMatching($singleQueries, 'foreign_key_list')); + $this->assertSame(1, $this->countMatching($manyQueries, 'foreign_key_list')); + } + + /** + * The sample table carries an index per every explicit index and foreign key. + */ + protected function getIntrospectionQueryLimit(): int + { + return self::QUERIES_PER_TABLE + + self::QUERIES_PER_INDEX * (static::WIDE_INDEXES + static::WIDE_FOREIGN_KEYS); + } + + private function countMatching(QueryCounter $counter, string $needle): int + { + $count = 0; + foreach ($counter->getQueries() as $query) { + if (\str_contains($query, $needle)) { + $count++; + } + } + + return $count; + } +} diff --git a/tests/Database/Utils/QueryCounter.php b/tests/Database/Utils/QueryCounter.php new file mode 100644 index 00000000..cfe58a7e --- /dev/null +++ b/tests/Database/Utils/QueryCounter.php @@ -0,0 +1,56 @@ + */ + private array $queries = []; + + public function log($level, $message, array $context = []): void + { + if (!\array_key_exists('elapsed', $context)) { + return; + } + + $this->queries[] = (string) $message; + } + + public function reset(): void + { + $this->queries = []; + } + + public function count(): int + { + return \count($this->queries); + } + + /** + * @return list + */ + public function getQueries(): array + { + return $this->queries; + } + + public function __toString(): string + { + $result = []; + foreach ($this->queries as $i => $query) { + $result[] = \sprintf('%d. %s', $i + 1, \preg_replace('/\s+/', ' ', $query)); + } + + return \implode("\n", $result); + } +} From 9648039d368e68421dfec2c3b2f2a1175449d1aa Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 11 Aug 2026 13:25:22 +0400 Subject: [PATCH 2/2] test(Postgres): pin enum labels with special characters and empty enum types The batched pg_enum lookup returns labels with commas, quotes and spaces verbatim, and reports a label-less enum column as a non-enum; the former enum_range() text parsing mangled both cases. Assisted-By: Claude Fable 5 (1M context) --- .../Postgres/Schema/EnumIntrospectionTest.php | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/Database/Functional/Driver/Postgres/Schema/EnumIntrospectionTest.php b/tests/Database/Functional/Driver/Postgres/Schema/EnumIntrospectionTest.php index 0acc28fa..2dc6ddd4 100644 --- a/tests/Database/Functional/Driver/Postgres/Schema/EnumIntrospectionTest.php +++ b/tests/Database/Functional/Driver/Postgres/Schema/EnumIntrospectionTest.php @@ -87,6 +87,58 @@ public function testSameNamedEnumsInDifferentSchemas(): void $this->assertSame(['angry', 'calm'], $schema->column('foreign_mood')->getEnumValues()); } + /** + * Labels are read from `pg_enum` one row per label, so values with commas, quotes or spaces + * are returned verbatim. The former `enum_range()` parsing split the textual range by a comma + * and mangled such labels. + */ + public function testLabelsWithSpecialCharacters(): void + { + $driver = $this->database->getDriver(); + + $driver->execute( + "CREATE TYPE dirty_labels AS ENUM ('it''s', 'a,b', 'with \"quotes\"', ' spaced ')", + ); + $driver->execute( + 'CREATE TABLE mixed_enums ( + id serial NOT NULL, + label dirty_labels, + CONSTRAINT mixed_enums_pkey PRIMARY KEY (id) + )', + ); + + $schema = $driver->getSchema('mixed_enums'); + + $this->assertSame('enum', $schema->column('label')->getAbstractType()); + $this->assertSame( + ["it's", 'a,b', 'with "quotes"', ' spaced '], + $schema->column('label')->getEnumValues(), + ); + } + + /** + * An enum type without labels has no values, so the column must not be reported as an enum. + * The former `enum_range()` parsing produced a single empty-string value for it. + */ + public function testEmptyEnumType(): void + { + $driver = $this->database->getDriver(); + + $driver->execute('CREATE TYPE empty_enum AS ENUM ()'); + $driver->execute( + 'CREATE TABLE mixed_enums ( + id serial NOT NULL, + hollow empty_enum, + CONSTRAINT mixed_enums_pkey PRIMARY KEY (id) + )', + ); + + $schema = $driver->getSchema('mixed_enums'); + + $this->assertNotSame('enum', $schema->column('hollow')->getAbstractType()); + $this->assertSame([], $schema->column('hollow')->getEnumValues()); + } + public function testCompositePrimaryKeyWithEmulatedEnum(): void { $driver = $this->database->getDriver(); @@ -120,7 +172,7 @@ public function tearDown(): void } } - foreach (['mood', 'weather'] as $type) { + foreach (['mood', 'weather', 'dirty_labels', 'empty_enum'] as $type) { try { $driver->execute("DROP TYPE IF EXISTS {$type}"); } catch (StatementException) {