Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/DbSchema/SchemaHasherPgsql.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace staabm\PHPStanDba\DbSchema;

use PDO;
use PHPStan\ShouldNotHappenException;
use staabm\PHPStanDba\DbaException;
use staabm\PHPStanDba\QueryReflection\GlobalTransaction;

final class SchemaHasherPgsql implements SchemaHasher
{
private PDO $connection;

private ?string $hash = null;

public function __construct(PDO $connection)
{
$this->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;
}
}
4 changes: 4 additions & 0 deletions src/QueryReflection/QueryReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
}
Expand Down
85 changes: 85 additions & 0 deletions tests/default/SchemaHasherPgsqlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace staabm\PHPStanDba\Tests;

use PDO;
use PHPUnit\Framework\TestCase;
use staabm\PHPStanDba\QueryReflection\QueryReflection;

final class SchemaHasherPgsqlTest extends TestCase
{
private const TABLE_NAME = 'phpstan_dba_schema_hash_test';

public function testSchemaHashChangesWithTableColumns(): void
{
if ('pdo-pgsql' !== getenv('DBA_REFLECTOR')) {
self::markTestSkipped('PostgreSQL reflector required.');
}

$pdo = self::createPdo();
$pdo->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;
}
}