From 11feff173c058d6161a116344280fc83f700d4e4 Mon Sep 17 00:00:00 2001 From: "Joey@macstudio" <4296411@qq.com> Date: Thu, 16 Jul 2026 00:40:58 +0800 Subject: [PATCH] Add PostgreSQL schema hashing --- src/DbSchema/SchemaHasherPgsql.php | 72 +++++++++++++++++++++ src/QueryReflection/QueryReflection.php | 4 ++ tests/default/SchemaHasherPgsqlTest.php | 85 +++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 src/DbSchema/SchemaHasherPgsql.php create mode 100644 tests/default/SchemaHasherPgsqlTest.php diff --git a/src/DbSchema/SchemaHasherPgsql.php b/src/DbSchema/SchemaHasherPgsql.php new file mode 100644 index 00000000..f200aa66 --- /dev/null +++ b/src/DbSchema/SchemaHasherPgsql.php @@ -0,0 +1,72 @@ +connection = $connection; + } + + public function hashDb(): string + { + if (null !== $this->hash) { + return $this->hash; + } + + GlobalTransaction::ensureStarted($this->connection); + + $query = <<<'SQL' + SELECT MD5( + COALESCE( + STRING_AGG( + CONCAT_WS( + CHR(31), + namespace.nspname, + relation.relname, + attribute.attname, + FORMAT_TYPE(attribute.atttypid, attribute.atttypmod), + attribute.attnotnull::text + ), + CHR(30) + ORDER BY namespace.nspname, relation.relname, attribute.attnum + ), + '' + ) + ) AS dbsignature + FROM pg_catalog.pg_attribute attribute + INNER JOIN pg_catalog.pg_class relation ON relation.oid = attribute.attrelid + INNER JOIN pg_catalog.pg_namespace namespace ON namespace.oid = relation.relnamespace + WHERE relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND attribute.attnum > 0 + AND NOT attribute.attisdropped + AND namespace.nspname NOT IN ('pg_catalog', 'information_schema') + AND namespace.nspname !~ '^pg_(toast|temp)' + SQL; + + try { + $stmt = $this->connection->query($query); + $hash = $stmt->fetchColumn(); + } catch (\Throwable $e) { + throw new DbaException('Failed to hash PostgreSQL schema', 0, $e); + } + + if (! \is_string($hash) || '' === $hash) { + throw new ShouldNotHappenException(); + } + + return $this->hash = $hash; + } +} diff --git a/src/QueryReflection/QueryReflection.php b/src/QueryReflection/QueryReflection.php index 92ecc1df..9b749804 100644 --- a/src/QueryReflection/QueryReflection.php +++ b/src/QueryReflection/QueryReflection.php @@ -31,6 +31,7 @@ use staabm\PHPStanDba\DbSchema\LazySchemaHasher; use staabm\PHPStanDba\DbSchema\SchemaHasher; use staabm\PHPStanDba\DbSchema\SchemaHasherMysql; +use staabm\PHPStanDba\DbSchema\SchemaHasherPgsql; use staabm\PHPStanDba\DbSchema\SchemaHasherString; use staabm\PHPStanDba\Error; use staabm\PHPStanDba\PhpDoc\PhpDocUtil; @@ -731,6 +732,9 @@ public function getSchemaHasher(): SchemaHasher if (null === $ds) { return new SchemaHasherString('unknown-fixed-hash'); } + if ($ds instanceof \PDO && 'pgsql' === $ds->getAttribute(\PDO::ATTR_DRIVER_NAME)) { + return new SchemaHasherPgsql($ds); + } return new SchemaHasherMysql($ds); }); } diff --git a/tests/default/SchemaHasherPgsqlTest.php b/tests/default/SchemaHasherPgsqlTest.php new file mode 100644 index 00000000..9b31631b --- /dev/null +++ b/tests/default/SchemaHasherPgsqlTest.php @@ -0,0 +1,85 @@ +exec('DROP TABLE IF EXISTS ' . self::TABLE_NAME); + $pdo->exec('CREATE TABLE ' . self::TABLE_NAME . ' (id integer NOT NULL)'); + + try { + $initialHash = self::hashDb(); + self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $initialHash); + self::assertSame($initialHash, self::hashDb()); + + $pdo->exec('ALTER TABLE ' . self::TABLE_NAME . ' RENAME COLUMN id TO record_id'); + $renamedColumnHash = self::hashDb(); + self::assertNotSame($initialHash, $renamedColumnHash); + + $pdo->exec('ALTER TABLE ' . self::TABLE_NAME . ' ALTER COLUMN record_id TYPE bigint'); + $changedTypeHash = self::hashDb(); + self::assertNotSame($renamedColumnHash, $changedTypeHash); + + $pdo->exec('ALTER TABLE ' . self::TABLE_NAME . ' ALTER COLUMN record_id DROP NOT NULL'); + self::assertNotSame($changedTypeHash, self::hashDb()); + } finally { + $pdo->exec('DROP TABLE IF EXISTS ' . self::TABLE_NAME); + } + } + + private static function hashDb(): string + { + return (new QueryReflection())->getSchemaHasher()->hashDb(); + } + + private static function createPdo(): PDO + { + $host = self::env('DBA_HOST', '127.0.0.1'); + $user = self::env('DBA_USER'); + $password = self::env('DBA_PASSWORD'); + $database = self::env('DBA_DATABASE'); + + $port = ''; + if (str_contains($host, ':')) { + [$host, $port] = explode(':', $host, 2); + $port = ';port=' . $port; + } + + return new PDO( + sprintf('pgsql:dbname=%s;host=%s', $database, $host) . $port, + $user, + $password, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ] + ); + } + + private static function env(string $name, ?string $default = null): string + { + $value = getenv($name); + if (false === $value) { + $value = $_ENV[$name] ?? $default; + } + if (! \is_string($value)) { + throw new \RuntimeException(sprintf('Missing environment variable %s.', $name)); + } + + return $value; + } +}