From a46d8540a06efa9ea056b557036a8cc6829a7e57 Mon Sep 17 00:00:00 2001 From: Artem Goutsoul Date: Sun, 23 Aug 2026 14:38:31 +0300 Subject: [PATCH 1/3] Sort inside GROUP_CONCAT so the schema hash is deterministic SchemaHasherMysql sorted the columns in a derived table and aggregated outside it. MySQL 8 merges that derived table and drops its ORDER BY -- EXPLAIN FORMAT=TREE shows no sort node -- so GROUP_CONCAT consumes the rows in whatever order the data dictionary scan produces. Whether the ORDER BY survives depends on the plan, so the same schema hashes differently on different servers: on MySQL 8.0.32 a 369-table schema hashed to 819813065b5deb6c55991fa0a4f92f68 (dictionary order) where sorting gives ef6b72fe18377aec70b07772190d7575. DbSchemaResultCacheMetaExtension reports that value to PHPStan, which discards its whole result cache when the metadata differs from the cached run's. A cache written on one machine and restored on another is therefore thrown away, and everything is re-analysed. GROUP_CONCAT takes its own ORDER BY, which is guaranteed where a merged derived table's is not, so the sort moves inside the aggregate and the derived table and its GROUP BY are no longer needed. --- src/DbSchema/SchemaHasherMysql.php | 31 ++--- tests/default/SchemaHasherMysqlTest.php | 143 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 tests/default/SchemaHasherMysqlTest.php diff --git a/src/DbSchema/SchemaHasherMysql.php b/src/DbSchema/SchemaHasherMysql.php index 8ad0ebba..4bc91c9b 100644 --- a/src/DbSchema/SchemaHasherMysql.php +++ b/src/DbSchema/SchemaHasherMysql.php @@ -48,26 +48,19 @@ public function hashDb(): string SELECT MD5( GROUP_CONCAT( - InnerSelect.columns + CONCAT( + COALESCE(COLUMN_NAME, ''), + COALESCE(EXTRA, ''), + COLUMN_TYPE, + IS_NULLABLE + ) + ORDER BY TABLE_NAME, COLUMN_NAME ) - ) AS dbsignature, - 1 AS grouper - FROM ( - SELECT - CONCAT( - COALESCE(COLUMN_NAME, ''), - COALESCE(EXTRA, ''), - COLUMN_TYPE, - IS_NULLABLE - ) as columns - FROM - information_schema.columns - WHERE - table_schema = DATABASE() - ORDER BY table_name, column_name - ) as InnerSelect - GROUP BY - grouper"; + ) AS dbsignature + FROM + information_schema.columns + WHERE + table_schema = DATABASE()"; $hash = ''; if ($this->connection instanceof PDO) { diff --git a/tests/default/SchemaHasherMysqlTest.php b/tests/default/SchemaHasherMysqlTest.php new file mode 100644 index 00000000..fe8cffb0 --- /dev/null +++ b/tests/default/SchemaHasherMysqlTest.php @@ -0,0 +1,143 @@ +connection = self::createPdo(null); + $this->connection->exec('DROP DATABASE IF EXISTS ' . self::DATABASE_NAME); + $this->connection->exec('CREATE DATABASE ' . self::DATABASE_NAME); + $this->connection->exec('USE ' . self::DATABASE_NAME); + } + + protected function tearDown(): void + { + if (null !== $this->connection) { + $this->connection->exec('DROP DATABASE IF EXISTS ' . self::DATABASE_NAME); + $this->connection = null; + } + } + + public function testSchemaHashIgnoresTheOrderColumnsAreDeclaredIn(): void + { + $this->exec('CREATE TABLE t (zebra int NOT NULL, apple varchar(10) NULL)'); + $hash = self::hashDb(); + self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $hash); + + $this->exec('DROP TABLE t'); + $this->exec('CREATE TABLE t (apple varchar(10) NULL, zebra int NOT NULL)'); + + self::assertSame($hash, self::hashDb()); + } + + public function testSchemaHashIgnoresTheOrderTablesAreCreatedIn(): void + { + $this->exec('CREATE TABLE zebra (id int NOT NULL)'); + $this->exec('CREATE TABLE apple (name varchar(10) NULL)'); + $hash = self::hashDb(); + + $this->exec('DROP TABLE zebra'); + $this->exec('CREATE TABLE zebra (id int NOT NULL)'); + + self::assertSame($hash, self::hashDb()); + } + + public function testSchemaHashChangesWithTheSchema(): void + { + $this->exec('CREATE TABLE t (id int NOT NULL)'); + $initialHash = self::hashDb(); + self::assertSame($initialHash, self::hashDb()); + + $this->exec('ALTER TABLE t RENAME COLUMN id TO record_id'); + $renamedColumnHash = self::hashDb(); + self::assertNotSame($initialHash, $renamedColumnHash); + + $this->exec('ALTER TABLE t MODIFY record_id bigint NOT NULL'); + $changedTypeHash = self::hashDb(); + self::assertNotSame($renamedColumnHash, $changedTypeHash); + + $this->exec('ALTER TABLE t MODIFY record_id bigint NULL'); + $nullableHash = self::hashDb(); + self::assertNotSame($changedTypeHash, $nullableHash); + + $this->exec('CREATE TABLE t2 (id int NOT NULL)'); + self::assertNotSame($nullableHash, self::hashDb()); + } + + private function exec(string $statement): void + { + if (null === $this->connection) { + self::fail('No connection.'); + } + + $this->connection->exec($statement); + } + + /** + * A fresh connection per hash: DDL of another connection is invisible within + * the transaction the hasher runs in. + */ + private static function hashDb(): string + { + return (new SchemaHasherMysql(self::createPdo(self::DATABASE_NAME)))->hashDb(); + } + + private static function createPdo(?string $database): PDO + { + $host = self::env('DBA_HOST', '127.0.0.1'); + $port = ''; + if (false !== strpos($host, ':')) { + [$host, $port] = explode(':', $host, 2); + $port = ';port=' . $port; + } + + $dsn = sprintf('mysql:host=%s', $host) . $port; + if (null !== $database) { + $dsn .= ';dbname=' . $database; + } + + return new PDO( + $dsn, + self::env('DBA_USER', 'root'), + self::env('DBA_PASSWORD', 'root'), + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ] + ); + } + + private static function env(string $name, string $default): string + { + $value = getenv($name); + if (false === $value) { + $value = $_ENV[$name] ?? $default; + } + + return \is_string($value) ? $value : $default; + } +} From 7424e3cf9cd69083fe65ab8ce157daa6878c1071 Mon Sep 17 00:00:00 2001 From: Artem Goutsoul Date: Sun, 23 Aug 2026 15:29:58 +0300 Subject: [PATCH 2/3] Cover the column order in the schema signature The order a table's columns are declared in decides the shape of a SELECT * row, so a reordering has to invalidate the result cache. Before sorting inside GROUP_CONCAT that happened by accident, on servers where the rows arrived in data dictionary order; the signature itself never described the order. Add ORDINAL_POSITION to the concatenated column description, so it does. The test now asserts the whole signature of a two-column table instead of comparing two schema states, which pins the order the columns are concatenated in, and covers the schema changes that have to be visible in a data provider. --- src/DbSchema/SchemaHasherMysql.php | 3 +- tests/default/SchemaHasherMysqlTest.php | 123 +++++++++++------------- 2 files changed, 58 insertions(+), 68 deletions(-) diff --git a/src/DbSchema/SchemaHasherMysql.php b/src/DbSchema/SchemaHasherMysql.php index 4bc91c9b..aa80144f 100644 --- a/src/DbSchema/SchemaHasherMysql.php +++ b/src/DbSchema/SchemaHasherMysql.php @@ -52,7 +52,8 @@ public function hashDb(): string COALESCE(COLUMN_NAME, ''), COALESCE(EXTRA, ''), COLUMN_TYPE, - IS_NULLABLE + IS_NULLABLE, + ORDINAL_POSITION ) ORDER BY TABLE_NAME, COLUMN_NAME ) diff --git a/tests/default/SchemaHasherMysqlTest.php b/tests/default/SchemaHasherMysqlTest.php index fe8cffb0..da3eb6fd 100644 --- a/tests/default/SchemaHasherMysqlTest.php +++ b/tests/default/SchemaHasherMysqlTest.php @@ -7,107 +7,101 @@ use PDO; use PHPUnit\Framework\TestCase; use staabm\PHPStanDba\DbSchema\SchemaHasherMysql; +use function getenv; -final class SchemaHasherMysqlTest extends TestCase +class SchemaHasherMysqlTest extends TestCase { + /** + * A database of our own, so the hash covers a schema this test controls entirely. + */ private const DATABASE_NAME = 'phpstan_dba_schema_hash_test'; - private ?PDO $connection = null; + private PDO $connection; protected function setUp(): void { - if (! \in_array(self::env('DBA_REFLECTOR', ''), ['mysqli', 'pdo-mysql'], true)) { + if (! \in_array(getenv('DBA_REFLECTOR'), ['mysqli', 'pdo-mysql'], true)) { self::markTestSkipped('MySQL reflector required.'); } - if (false === strpos(self::env('DBA_MODE', ''), 'recording')) { - self::markTestSkipped('Recording mode required, hashing a schema needs a database connection.'); + if (! \in_array(getenv('DBA_MODE'), [ReflectorFactory::MODE_RECORDING, ReflectorFactory::MODE_REPLAY_AND_RECORDING], true)) { + self::markTestSkipped('Hashing a schema requires a database connection.'); } - if (! \extension_loaded('pdo_mysql')) { - self::markTestSkipped('ext-pdo_mysql required.'); - } - - // hash a database of our own, so the tests are independent of the schema the other tests use - $this->connection = self::createPdo(null); - $this->connection->exec('DROP DATABASE IF EXISTS ' . self::DATABASE_NAME); - $this->connection->exec('CREATE DATABASE ' . self::DATABASE_NAME); - $this->connection->exec('USE ' . self::DATABASE_NAME); + $this->connection = self::connect(null); + $this->exec('DROP DATABASE IF EXISTS ' . self::DATABASE_NAME); + $this->exec('CREATE DATABASE ' . self::DATABASE_NAME); + $this->exec('USE ' . self::DATABASE_NAME); } protected function tearDown(): void { - if (null !== $this->connection) { - $this->connection->exec('DROP DATABASE IF EXISTS ' . self::DATABASE_NAME); - $this->connection = null; + if (isset($this->connection)) { + $this->exec('DROP DATABASE IF EXISTS ' . self::DATABASE_NAME); } } - public function testSchemaHashIgnoresTheOrderColumnsAreDeclaredIn(): void + /** + * Regression: the columns were sorted in a derived table and aggregated + * outside it. MySQL 8 merges that derived table and drops its ORDER BY, so + * GROUP_CONCAT consumed the rows in data dictionary order (by ordinal + * position) instead - a property of the server rather than of the schema. + */ + public function testSchemaHashIsTheColumnSignatureSortedByName(): void { - $this->exec('CREATE TABLE t (zebra int NOT NULL, apple varchar(10) NULL)'); - $hash = self::hashDb(); - self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $hash); - - $this->exec('DROP TABLE t'); - $this->exec('CREATE TABLE t (apple varchar(10) NULL, zebra int NOT NULL)'); + $this->exec('CREATE TABLE t (zebra varchar(10) NOT NULL, apple date NULL)'); - self::assertSame($hash, self::hashDb()); + self::assertSame(md5('appledateYES2,zebravarchar(10)NO1'), $this->hashDb()); } - public function testSchemaHashIgnoresTheOrderTablesAreCreatedIn(): void + public function testSchemaHashIsStable(): void { - $this->exec('CREATE TABLE zebra (id int NOT NULL)'); - $this->exec('CREATE TABLE apple (name varchar(10) NULL)'); - $hash = self::hashDb(); - - $this->exec('DROP TABLE zebra'); - $this->exec('CREATE TABLE zebra (id int NOT NULL)'); + $this->exec('CREATE TABLE t (id int NOT NULL)'); - self::assertSame($hash, self::hashDb()); + self::assertSame($this->hashDb(), $this->hashDb()); } - public function testSchemaHashChangesWithTheSchema(): void + /** + * @dataProvider provideSchemaChanges + */ + public function testSchemaHashChangesWithTheSchema(string $schemaChange): void { - $this->exec('CREATE TABLE t (id int NOT NULL)'); - $initialHash = self::hashDb(); - self::assertSame($initialHash, self::hashDb()); - - $this->exec('ALTER TABLE t RENAME COLUMN id TO record_id'); - $renamedColumnHash = self::hashDb(); - self::assertNotSame($initialHash, $renamedColumnHash); + $this->exec('CREATE TABLE t (id int NOT NULL, name varchar(10) NULL)'); + $hash = $this->hashDb(); - $this->exec('ALTER TABLE t MODIFY record_id bigint NOT NULL'); - $changedTypeHash = self::hashDb(); - self::assertNotSame($renamedColumnHash, $changedTypeHash); + $this->exec($schemaChange); - $this->exec('ALTER TABLE t MODIFY record_id bigint NULL'); - $nullableHash = self::hashDb(); - self::assertNotSame($changedTypeHash, $nullableHash); + self::assertNotSame($hash, $this->hashDb()); + } - $this->exec('CREATE TABLE t2 (id int NOT NULL)'); - self::assertNotSame($nullableHash, self::hashDb()); + /** + * @return iterable + */ + public function provideSchemaChanges(): iterable + { + yield 'renamed column' => ['ALTER TABLE t CHANGE id record_id int NOT NULL']; + yield 'changed column type' => ['ALTER TABLE t MODIFY id bigint NOT NULL']; + yield 'column turned nullable' => ['ALTER TABLE t MODIFY id int NULL']; + yield 'reordered columns' => ['ALTER TABLE t MODIFY name varchar(10) NULL FIRST']; + yield 'added column' => ['ALTER TABLE t ADD extra int NULL']; + yield 'added table' => ['CREATE TABLE t2 (id int NOT NULL)']; } private function exec(string $statement): void { - if (null === $this->connection) { - self::fail('No connection.'); - } - $this->connection->exec($statement); } /** - * A fresh connection per hash: DDL of another connection is invisible within - * the transaction the hasher runs in. + * A connection of its own per hash: DDL of another connection is invisible + * within the transaction the hasher runs in. */ - private static function hashDb(): string + private function hashDb(): string { - return (new SchemaHasherMysql(self::createPdo(self::DATABASE_NAME)))->hashDb(); + return (new SchemaHasherMysql(self::connect(self::DATABASE_NAME)))->hashDb(); } - private static function createPdo(?string $database): PDO + private static function connect(?string $database): PDO { $host = self::env('DBA_HOST', '127.0.0.1'); $port = ''; @@ -116,19 +110,14 @@ private static function createPdo(?string $database): PDO $port = ';port=' . $port; } - $dsn = sprintf('mysql:host=%s', $host) . $port; + $dsn = 'mysql:host=' . $host . $port; if (null !== $database) { $dsn .= ';dbname=' . $database; } - return new PDO( - $dsn, - self::env('DBA_USER', 'root'), - self::env('DBA_PASSWORD', 'root'), - [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - ] - ); + return new PDO($dsn, self::env('DBA_USER', 'root'), self::env('DBA_PASSWORD', 'root'), [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); } private static function env(string $name, string $default): string From 8903f300d7ae7dbf58778f7a43618010f9cb2c82 Mon Sep 17 00:00:00 2001 From: Artem Goutsoul Date: Sun, 23 Aug 2026 15:31:29 +0300 Subject: [PATCH 3/3] State the regression as plan-dependent, not MySQL 8 specific --- tests/default/SchemaHasherMysqlTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/default/SchemaHasherMysqlTest.php b/tests/default/SchemaHasherMysqlTest.php index da3eb6fd..05270093 100644 --- a/tests/default/SchemaHasherMysqlTest.php +++ b/tests/default/SchemaHasherMysqlTest.php @@ -43,9 +43,10 @@ protected function tearDown(): void /** * Regression: the columns were sorted in a derived table and aggregated - * outside it. MySQL 8 merges that derived table and drops its ORDER BY, so - * GROUP_CONCAT consumed the rows in data dictionary order (by ordinal - * position) instead - a property of the server rather than of the schema. + * outside it. Whether that ORDER BY survives is up to the optimizer - when + * the derived table is merged the sort is dropped and GROUP_CONCAT consumes + * the rows in data dictionary order instead, which is a property of the + * server and not of the schema. */ public function testSchemaHashIsTheColumnSignatureSortedByName(): void {