true, 'verboseOutput' => true ]); enum LibPgsqlColor: string { case Red = 'あか'; } // 例外を受け取る(SQLSTATE・メッセージを確認する為) $catch = function(callable $fn): ?\Throwable { try { $fn(); } catch (\Throwable $e) { return $e; } return null; }; // 偽物と本物の両方のサーバーで同じ結果になるはずのテスト $common = function(Tester $test, callable $info) use ($catch): void { $open = function(array $o = []) use ($info): Pgsql { $pg = new Pgsql($info($o)); $pg->connect(); return $pg; }; $one = fn(Pgsql $pg, string $sql) => array_values($pg->query($sql)['rows'][0] ?? [])[0] ?? null; // プリペアドステートメントで1回だけ実行する $run = function(Pgsql $pg, string $sql, array $params = []): array { $id = $pg->prepare($sql); try { return $pg->execute($id, $params); } finally { $pg->demolish($id); } }; $test->it('接続して切断出来るはず', function($test) use ($open, $one, $info): void { $pg = $open(); $test->assertTrue($pg->isConnected()); $test->assertTrue($pg->connect()); // 2回目は何もしない $si = $pg->getServerInfo(); $test->assertSame('UTF8', $si['client_encoding']); $test->assertSame('LittleBeast', $si['application_name']); $test->assertTrue($si['server_version'] !== ''); $test->assertSame($si['pid'], $one($pg, 'SELECT pg_backend_pid() AS pid')); $test->assertSame($info()['username'], $one($pg, 'SELECT current_user AS u')); $test->assertSame($info()['dbname'], $one($pg, 'SELECT current_database() AS d')); $test->assertTrue($pg->ping()); $test->assertFalse($pg->inTransaction()); $pg->close(); $test->assertFalse($pg->isConnected()); $test->assertFalse($pg->ping()); $pg->close(); // 2回目は何もしない }); $test->it('テーブルを作って、データを入れて読めるはず', function($test) use ($open, $run): void { $pg = $open(); $pg->query('DROP TABLE IF EXISTS lb_test_items'); $res = $pg->query('CREATE TABLE lb_test_items (id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, n INT8, f FLOAT8, b BOOLEAN, d BYTEA)'); $test->assertSame('CREATE TABLE', $res['command']); $test->assertSame(0, $res['affectedRows']); $test->assertSame([], $res['rows']); $bin = "\x00\xff\\x'\"\n"; $id = $pg->prepare('INSERT INTO lb_test_items (name, n, f, b, d) VALUES (?, ?, ?, ?, ?) RETURNING id'); $res = $pg->execute($id, ['諏訪子', PHP_INT_MAX, 1.5, true, $bin]); $test->assertSame('INSERT 0 1', $res['command']); $test->assertSame(1, $res['affectedRows']); $test->assertSame(0, $res['insertId']); $test->assertSame([['id' => 1]], $res['rows']); $test->assertSame(Pgsql::OID_INT4, $res['fields'][0]['type']); $test->assertSame([['id' => 2]], $pg->execute($id, ['神奈子', null, -0.25, false, ''])['rows']); $test->assertSame([['id' => 3]], $pg->execute($id, ['早苗', -7, null, null, null])['rows']); $pg->demolish($id); $res = $pg->query('SELECT * FROM lb_test_items ORDER BY id'); $test->assertSame('SELECT 3', $res['command']); $test->assertSame(3, $res['affectedRows']); $test->assertSame(['id', 'name', 'n', 'f', 'b', 'd'], array_column($res['fields'], 'name')); $test->assertSame([Pgsql::OID_INT4, Pgsql::OID_TEXT, Pgsql::OID_INT8, Pgsql::OID_FLOAT8, Pgsql::OID_BOOL, Pgsql::OID_BYTEA], array_column($res['fields'], 'type')); $test->assertSame([ ['id' => 1, 'name' => '諏訪子', 'n' => PHP_INT_MAX, 'f' => 1.5, 'b' => true, 'd' => $bin], ['id' => 2, 'name' => '神奈子', 'n' => null, 'f' => -0.25, 'b' => false, 'd' => ''], ['id' => 3, 'name' => '早苗', 'n' => -7, 'f' => null, 'b' => null, 'd' => null], ], $res['rows']); // 拡張クエリの結果も同じ $test->assertSame($res['rows'], $run($pg, 'SELECT * FROM lb_test_items WHERE id > ? ORDER BY id', [0])['rows']); $test->assertSame([['name' => '神奈子']], $run($pg, 'SELECT name FROM lb_test_items WHERE b = ?', [false])['rows']); $test->assertSame([], $run($pg, 'SELECT name FROM lb_test_items WHERE id = ?', [99])['rows']); $res = $run($pg, 'UPDATE lb_test_items SET n = ? WHERE id > ?', [5, 1]); $test->assertSame('UPDATE 2', $res['command']); $test->assertSame(2, $res['affectedRows']); $test->assertSame([], $res['fields']); $test->assertSame(1, $run($pg, 'DELETE FROM lb_test_items WHERE id = ?', [3])['affectedRows']); }); $test->it('「?」のプレースホルダは文字列の中で変えないはず', function($test) use ($open, $run): void { $pg = $open(); $res = $run($pg, "SELECT name FROM lb_test_items WHERE name = ? AND 'a?' <> ? -- ?\n", ['諏訪子', 'b']); $test->assertSame([['name' => '諏訪子']], $res['rows']); $res = $run($pg, 'SELECT name FROM lb_test_items WHERE id = $1 OR id = $1', [2]); $test->assertSame([['name' => '神奈子']], $res['rows']); }); $test->it('PHPの色々な型をパラメータとして送れるはず', function($test) use ($open, $run, $catch): void { $pg = $open(); $pg->query('DROP TABLE IF EXISTS lb_test_params'); $pg->query('CREATE TABLE lb_test_params (id SERIAL PRIMARY KEY, s TEXT, i INT8, f FLOAT8)'); $ins = $pg->prepare('INSERT INTO lb_test_params (s, i, f) VALUES (?, ?, ?) RETURNING id'); $get = $pg->prepare('SELECT s, i, f FROM lb_test_params WHERE id = ?'); $stringable = new class { public function __toString(): string { return '文字列'; } }; $cases = [ [[42, 42, 42], ['42', 42, 42.0]], [['', PHP_INT_MIN, -0.1], ['', PHP_INT_MIN, -0.1]], [[1.25, true, 1e100], ['1.25', 1, 1.0E+100]], [[false, false, NAN], ['f', 0, NAN]], [[new \DateTimeImmutable('2026-11-12 10:20:30.5', new \DateTimeZone('Asia/Tokyo')), null, INF], ['2026-11-12 10:20:30.500000+09:00', null, INF]], [[LibPgsqlColor::Red, null, -INF], ['あか', null, -INF]], [[$stringable, null, null], ['文字列', null, null]], ]; foreach ($cases as [$in, $out]) { $id = $pg->execute($ins, $in)['rows'][0]['id']; $row = array_values($pg->execute($get, [$id])['rows'][0]); $label = 'パラメータ:'.var_export($in[0], true); $test->assertSame($out[0], $row[0], $label); $test->assertSame($out[1], $row[1], $label); is_float($out[2]) && is_nan($out[2]) ? $test->assertTrue(is_nan($row[2]), $label) : $test->assertSame($out[2], $row[2], $label); } // 連想配列でも順番通り $id = $pg->execute($ins, ['x' => 'あ', 'y' => 1, 'z' => 2])['rows'][0]['id']; $test->assertSame(['s' => 'あ', 'i' => 1, 'f' => 2.0], $pg->execute($get, [$id])['rows'][0]); $e = $catch(fn() => $pg->execute($ins, [[1, 2], 1, 1])); $test->assertTrue($e instanceof DbException); $test->assertStringContains('パラメータ0の型は未対応です: array', $e->getMessage()); $e = $catch(fn() => $pg->execute($ins, ['足りない'])); $test->assertStringContains('パラメータ数が一致しません: 期待 3, 取得 1', $e->getMessage()); $test->assertTrue($pg->demolish($ins)); $test->assertFalse($pg->demolish($ins)); $e = $catch(fn() => $pg->execute($ins, ['x', 1, 1])); $test->assertStringContains("不正なステートメントID: {$ins}", $e->getMessage()); $test->assertTrue($pg->ping()); }); $test->it('SQLのエラーは例外で、その後も使えるはず', function($test) use ($open, $one, $catch): void { $pg = $open(); $e = $catch(fn() => $pg->query('SELECT * FROM lb_test_nope')); $test->assertTrue($e instanceof DbException); $test->assertSame('42P01', $e->sqlState); $test->assertSame('クエリに失敗: 42P01 - relation "lb_test_nope" does not exist', $e->getMessage()); $e = $catch(fn() => $pg->query('SELEC 1')); $test->assertSame('42601', $e->sqlState); $test->assertStringContains('syntax error at or near "SELEC"', $e->getMessage()); $e = $catch(fn() => $pg->prepare('SELECT * FROM lb_test_nope WHERE id = ?')); $test->assertStringContains('準備に失敗: 42P01', $e->getMessage()); $id = $pg->prepare('INSERT INTO lb_test_items (name) VALUES (?)'); $e = $catch(fn() => $pg->execute($id, ['諏訪子'])); $test->assertSame('23505', $e->sqlState); $test->assertStringContains('実行に失敗: 23505 - duplicate key value violates unique constraint "lb_test_items_name_key"', $e->getMessage()); $test->assertStringContains('Key (name)', $e->getMessage()); $e = $catch(fn() => $pg->execute($id, [null])); $test->assertSame('23502', $e->sqlState); $test->assertTrue($pg->isConnected()); $test->assertFalse($pg->inTransaction()); $test->assertSame(2, $one($pg, 'SELECT count(*) AS c FROM lb_test_items')); }); $test->it('複数の文の結果を全部受け取れるはず', function($test) use ($open): void { $pg = $open(); $res = $pg->query("SELECT 1 AS a; UPDATE lb_test_items SET n = 6; SELECT '二' AS b;"); $test->assertSame([['a' => 1]], $res['rows']); $test->assertCount(2, $res['more']); $test->assertSame('UPDATE 2', $res['more'][0]['command']); $test->assertSame(2, $res['more'][0]['affectedRows']); $test->assertSame([['b' => '二']], $res['more'][1]['rows']); $test->assertFalse(isset($res['more'][1]['more'])); // 空のクエリ $res = $pg->query(''); $test->assertSame('', $res['command']); $test->assertSame([], $res['rows']); }); $test->it('通知を受け取れるはず', function($test) use ($open): void { $pg = $open(); $res = $pg->query('DROP TABLE IF EXISTS lb_test_none'); $test->assertSame(1, $res['warningCount']); $test->assertSame([['severity' => 'NOTICE', 'code' => '00000', 'message' => 'table "lb_test_none" does not exist, skipping']], $pg->getNotices()); $pg->query('SELECT 1'); $test->assertSame([], $pg->getNotices()); $pg->query('COMMIT'); $test->assertSame('25P01', $pg->getNotices()[0]['code']); }); $test->it('トランザクションを使えるはず', function($test) use ($open, $one, $catch): void { $pg = $open(); $count = fn(): int => $one($pg, 'SELECT count(*) AS c FROM lb_test_items'); $pg->beginTransaction(); $test->assertTrue($pg->inTransaction()); $pg->query("INSERT INTO lb_test_items (name) VALUES ('あ')"); $pg->rollback(); $test->assertFalse($pg->inTransaction()); $test->assertSame(2, $count()); $pg->beginTransaction(); $pg->query("INSERT INTO lb_test_items (name) VALUES ('い')"); $test->assertTrue($pg->commit()); $test->assertSame(3, $count()); // エラーの後は失敗したトランザクション:COMMITはロールバックになるので例外 $pg->beginTransaction(); $pg->query("INSERT INTO lb_test_items (name) VALUES ('う')"); $catch(fn() => $pg->query('SELECT * FROM lb_test_nope')); $test->assertTrue($pg->inTransaction()); $e = $catch(fn() => $pg->query('SELECT 1')); $test->assertSame('25P02', $e->sqlState); $e = $catch(fn() => $pg->commit()); $test->assertTrue($e instanceof DbException); $test->assertSame('25P02', $e->sqlState); $test->assertStringContains('ロールバックしました', $e->getMessage()); $test->assertFalse($pg->inTransaction()); $test->assertSame(3, $count()); }); $test->it('COPYは断るはず(接続はそのまま)', function($test) use ($open, $one, $catch): void { $pg = $open(); $e = $catch(fn() => $pg->query('COPY lb_test_items (name) FROM STDIN')); $test->assertTrue($e instanceof DbException); $test->assertSame('57014', $e->sqlState); $test->assertStringContains('COPYは未対応です', $e->getMessage()); $test->assertSame(1, $one($pg, 'SELECT 1 AS x')); }); $test->it('長い値を送受信出来るはず', function($test) use ($open, $run): void { $pg = $open(); $pg->query('DROP TABLE IF EXISTS lb_test_big'); $pg->query('CREATE TABLE lb_test_big (id SERIAL PRIMARY KEY, t TEXT, d BYTEA)'); $text = str_repeat('あいう', 400000); $bin = random_bytes(1500000); $id = $run($pg, 'INSERT INTO lb_test_big (t, d) VALUES (?, ?) RETURNING id', [$text, $bin])['rows'][0]['id']; $row = $run($pg, 'SELECT t, d FROM lb_test_big WHERE id = ?', [$id])['rows'][0]; $test->assertSame(strlen($text), strlen($row['t'])); $test->assertTrue($row['t'] === $text && $row['d'] === $bin, '値が違う'); $test->assertTrue($pg->query("SELECT d FROM lb_test_big WHERE id = {$id}")['rows'][0]['d'] === $bin, '値が違う(単純クエリ)'); }); $test->it('Db経由で使えるはず', function($test) use ($info, $catch): void { $db = new Db(['driver' => 'pgsql'] + $info()); $test->assertSame('pgsql', $db->driverName); $test->assertSame('"a""b"', $db->quoteIdentifier('a"b')); $test->assertSame([['c' => 2]], $db->query('SELECT count(*) AS c FROM lb_test_items WHERE id > ? AND id < ?', [0, 3])['rows']); $db->transaction(fn(Db $db) => $db->query('UPDATE lb_test_items SET n = ? WHERE id = ?', [100, 1])); $test->assertSame(100, $db->query('SELECT n FROM lb_test_items WHERE id = 1')['rows'][0]['n']); $e = $catch(fn() => $db->transaction(function(Db $db): void { $db->query('UPDATE lb_test_items SET n = ? WHERE id = ?', [200, 1]); throw new \RuntimeException('やめる'); })); $test->assertSame('やめる', $e->getMessage()); $test->assertSame(100, $db->query('SELECT n FROM lb_test_items WHERE id = ?', [1])['rows'][0]['n']); // query()のパラメータ付きはステートメントを解放する(失敗しても) $e = $catch(fn() => $db->query('SELECT * FROM lb_test_nope WHERE id = ?', [1])); $test->assertSame('42P01', $e->sqlState); $test->assertTrue($db->ping()); $db->query('DROP TABLE lb_test_items'); $db->query('DROP TABLE lb_test_params'); $db->query('DROP TABLE lb_test_big'); $db->close(); $test->assertFalse($db->isConnected()); }); }; $test->describe('Pgsql(サーバーなし)', function($test) use ($catch): void { $test->it('DB_ENABLEDが無効なら設定なしで作れないはず', function($test): void { if (defined('DB_ENABLED') && DB_ENABLED) { $test->assertNotNull(new Pgsql()); return; } $test->assertThrows(fn() => new Pgsql(), \Exception::class); }); $test->it('Dbでドライバを選べるはず', function($test): void { foreach (['pgsql', 'postgres', 'PostgreSQL'] as $name) { $test->assertTrue((new Db(['driver' => $name]))->driver() instanceof Pgsql, $name); $test->assertTrue(Db::isSupported($name), $name); } }); $test->it('不正なchannel_bindingは例外のはず', function($test): void { $test->assertThrows(fn() => new Pgsql(['channel_binding' => 'always']), \InvalidArgumentException::class); $test->assertNotNull(new Pgsql(['channel_binding' => 'REQUIRE'])); }); $test->it('識別子を囲めるはず', function($test): void { $pg = new Pgsql([]); $test->assertSame('"users"', $pg->quoteIdentifier('users')); $test->assertSame('"a""b"', $pg->quoteIdentifier('a"b')); }); $test->it('接続していなければ例外のはず', function($test) use ($catch): void { $pg = new Pgsql([]); foreach ([fn() => $pg->query('SELECT 1'), fn() => $pg->prepare('SELECT 1'), fn() => $pg->execute(1), fn() => $pg->beginTransaction()] as $fn) { $e = $catch($fn); $test->assertTrue($e instanceof DbException); $test->assertStringContains('接続していません', $e->getMessage()); } $test->assertFalse($pg->ping()); $test->assertFalse($pg->inTransaction()); $test->assertFalse($pg->demolish(1)); }); $test->it('サーバーが無ければ接続に失敗するはず', function($test) use ($catch): void { $probe = stream_socket_server('tcp://127.0.0.1:0'); $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1); fclose($probe); $e = $catch(fn() => (new Pgsql(['port' => $port, 'timeout' => 2]))->connect()); $test->assertTrue($e instanceof DbException); $test->assertStringContains('ソケットに接続に失敗', $e->getMessage()); $e = $catch(fn() => (new Pgsql(['socket' => sys_get_temp_dir().'/lb-no-such-dir']))->connect()); $test->assertStringContains('ソケットに接続に失敗', $e->getMessage()); }); $test->it('「?」を「$1」等に変えるはず', function($test): void { $cases = [ 'SELECT 1' => 'SELECT 1', 'SELECT ?, ?' => 'SELECT $1, $2', "SELECT '?', ?, 'it''s ?', ?" => "SELECT '?', \$1, 'it''s ?', \$2", 'SELECT "a?""b", ?' => 'SELECT "a?""b", $1', "SELECT E'\\'?', ?" => "SELECT E'\\'?', \$1", "SELECT e'\\\\', ?" => "SELECT e'\\\\', \$1", "SELECT x'?', ?" => "SELECT x'?', \$1", 'SELECT $$?$$, $tag$ ? $$ ? $tag$, ?' => 'SELECT $$?$$, $tag$ ? $$ ? $tag$, $1', "SELECT ? -- ?\n, ?" => "SELECT \$1 -- ?\n, \$2", 'SELECT /* ? /* ? */ ? */ ?' => 'SELECT /* ? /* ? */ ? */ $1', "SELECT j ?? 'a', ?" => "SELECT j ? 'a', \$1", 'SELECT a$1, ?' => 'SELECT a$1, $1', "SELECT 'unterminated ?" => "SELECT 'unterminated ?", 'SELECT 名前?' => 'SELECT 名前$1', ]; foreach ($cases as $in => $out) $test->assertSame($out, Pgsql::placeholders($in), $in); }); $test->it('配列と型を変換出来るはず', function($test): void { $test->assertSame([1, 2, null], Pgsql::parseArray('{1,2,NULL}', Pgsql::OID_INT4)); $test->assertSame([['a', 'b,c'], ['d"e', '\\']], Pgsql::parseArray('{{a,"b,c"},{"d\\"e","\\\\"}}')); $test->assertSame(['NULL', null, ''], Pgsql::parseArray('{"NULL",null,""}')); $test->assertSame([1, 2], Pgsql::parseArray('[0:1]={1,2}', Pgsql::OID_INT4)); $test->assertSame([], Pgsql::parseArray('{}')); $test->assertNull(Pgsql::parseArray('{1,2')); $test->assertNull(Pgsql::parseArray('{1}x')); $test->assertNull(Pgsql::parseArray('1,2')); $test->assertSame(-5, Pgsql::castText('-5', Pgsql::OID_INT2)); $test->assertSame('99999999999999999999', Pgsql::castText('99999999999999999999', Pgsql::OID_INT8)); $test->assertSame(INF, Pgsql::castText('Infinity', Pgsql::OID_FLOAT4)); $test->assertSame(1.0E-5, Pgsql::castText('1e-05', Pgsql::OID_FLOAT8)); $test->assertSame(false, Pgsql::castText('f', Pgsql::OID_BOOL)); $test->assertSame("\x00\xff", Pgsql::castText('\\x00ff', Pgsql::OID_BYTEA)); $test->assertSame("\x00\xff\\A", Pgsql::castText('\\000\\377\\\\A', Pgsql::OID_BYTEA)); $test->assertSame('1.50', Pgsql::castText('1.50', Pgsql::OID_NUMERIC)); $test->assertSame([true, false], Pgsql::castText('{t,f}', 1000)); $test->assertSame('{bad', Pgsql::castText('{bad', 1009)); }); }); // TLS用の証明書(RSAの鍵、CAが発行した物) $cert = TestCert::get('rsa'); $test->describe('Pgsql(偽物のPostgreSQLサーバー)', function($test) use ($catch, $cert, $common): void { $srv = MockServer::start('pg-server.php', [$cert['cert'], $cert['key']]); if ($srv === null) { $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効、又はpdo_sqliteが無い?)'); return; } $info = fn(array $o = []): array => $o + [ 'host' => '127.0.0.1', 'port' => $srv->port, 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb', 'timeout' => 5, ]; $tls = ['cafile' => $cert['ca'], 'peer_name' => 'localhost']; $open = function(array $o = []) use ($info): Pgsql { $pg = new Pgsql($info($o)); $pg->connect(); return $pg; }; $one = fn(Pgsql $pg, string $sql) => array_values($pg->query($sql)['rows'][0] ?? [])[0] ?? null; $common($test, $info); $test->it('SCRAM-SHA-256で接続出来るはず', function($test) use ($open, $one, $info, $catch): void { $pg = $open(); $test->assertSame('SCRAM-SHA-256', $pg->getServerInfo()['authMethod']); $test->assertFalse($pg->getServerInfo()['tls']); $test->assertSame('none', $one($pg, 'SELECT lb_cb() AS cb')); foreach ([['password' => 'ちがう'], ['username' => 'nobody'], ['password' => '']] as $o) { $pg = new Pgsql($info($o)); $e = $catch(fn() => $pg->connect()); $test->assertTrue($e instanceof DbException); $test->assertSame('28P01', $e->sqlState); $test->assertStringContains('接続に失敗: 28P01 - password authentication failed', $e->getMessage()); $test->assertFalse($pg->isConnected()); } }); $test->it('MD5・trustで接続出来るはず', function($test) use ($open, $one, $catch): void { $pg = $open(['username' => 'md5user', 'password' => 'md5ひみつ']); $test->assertSame('md5', $pg->getServerInfo()['authMethod']); $test->assertSame('md5user', $one($pg, 'SELECT current_user AS u')); $e = $catch(fn() => $open(['username' => 'md5user', 'password' => 'md5'])); $test->assertSame('28P01', $e->sqlState); $pg = $open(['username' => 'trust', 'password' => '']); $test->assertSame('trust', $pg->getServerInfo()['authMethod']); }); $test->it('TLSなしでパスワードをそのまま送らないはず', function($test) use ($open, $one, $catch, $tls): void { $e = $catch(fn() => $open(['username' => 'clear', 'password' => 'clearひみつ'])); $test->assertTrue($e instanceof DbException); $test->assertStringContains('allow_cleartext', $e->getMessage()); $test->assertSame('password', $one($open(['username' => 'clear', 'password' => 'clearひみつ', 'allow_cleartext' => true]), 'SELECT lb_auth() AS a')); $pg = $open(['username' => 'clear', 'password' => 'clearひみつ', 'tls' => $tls]); $test->assertSame('password', $pg->getServerInfo()['authMethod']); $test->assertTrue($pg->getServerInfo()['tls']); }); $test->it('TLSとチャネルバインディング(SCRAM-SHA-256-PLUS)で接続出来るはず', function($test) use ($open, $one, $catch, $tls): void { $pg = $open(['tls' => $tls]); $test->assertSame('SCRAM-SHA-256-PLUS', $pg->getServerInfo()['authMethod']); $test->assertTrue($pg->getServerInfo()['tls']); $test->assertSame(1, $one($pg, 'SELECT lb_tls() AS t')); $test->assertSame('tls-server-end-point', $one($pg, 'SELECT lb_cb() AS cb')); $test->assertSame('諏訪子', $one($pg, "SELECT '諏訪子' AS n")); $pg = $open(['tls' => $tls, 'channel_binding' => 'disable']); $test->assertSame('SCRAM-SHA-256', $pg->getServerInfo()['authMethod']); $test->assertSame('none', $one($pg, 'SELECT lb_cb() AS cb')); $pg = $open(['username' => 'tlsonly', 'password' => 'tlsひみつ', 'tls' => $tls, 'channel_binding' => 'require']); $test->assertSame('tls-server-end-point', $one($pg, 'SELECT lb_cb() AS cb')); $e = $catch(fn() => $open(['username' => 'tlsonly', 'password' => 'tlsひみつ'])); $test->assertSame('28000', $e->sqlState); $test->assertStringContains('no encryption', $e->getMessage()); // TLSなしでチャネルバインディングを求める $e = $catch(fn() => $open(['channel_binding' => 'require'])); $test->assertStringContains('チャネルバインディングが使えません', $e->getMessage()); // 信頼していない証明書 $e = $catch(fn() => $open(['tls' => true])); $test->assertTrue($e instanceof DbException); $test->assertStringContains('TLSの開始に失敗', $e->getMessage()); }); $test->it('未対応の認証方式・無いデータベースは例外のはず', function($test) use ($open, $catch): void { $e = $catch(fn() => $open(['username' => 'gss'])); $test->assertStringContains('未対応の認証方式: 7', $e->getMessage()); $e = $catch(fn() => $open(['dbname' => 'nodb'])); $test->assertSame('3D000', $e->sqlState); $test->assertSame('接続に失敗: 3D000 - database "nodb" does not exist', $e->getMessage()); }); $test->it('色々な型を変換するはず', function($test) use ($open): void { $row = $open()->query('SELECT lb_types()')['rows'][0]; // NANは同じにならないので別に $test->assertTrue(is_nan($row['nan'])); $test->assertSame(1.5, $row['floats'][0]); $test->assertTrue(is_nan($row['floats'][1])); unset($row['nan'], $row['floats']); $test->assertSame([ 'i2' => -5, 'i4' => 42, 'i8' => PHP_INT_MAX, 'num' => '12345678901234567890.5', 'f4' => 2.5, 'ninf' => -INF, 'big' => 1.0E+100, 't' => true, 'f' => false, 'bin' => "\x00\xff\\", 'txts' => ['a,b', 'c"d', null, 'NULL', '', ' x '], 'ints' => [[1, 2], [3, null]], 'ranged' => [1, 2], 'bools' => [true, false], 'bins' => ["\x01"], 'j' => '{"a": [1, "x"]}', 'jb' => '{"b": 2}', 'd' => '2026-01-02', 'ts' => '2026-01-02 03:04:05.123456+09', 'u' => 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'o' => 1259, 'n' => null, 'v' => '日本語', 'i8s' => 2147483648, 'empty' => [], 'esc' => "\x00\xff\\A", 'big8' => '99999999999999999999', ], $row); }); $test->it('結果の途中の非同期のメッセージを処理するはず', function($test) use ($open, $one): void { $pg = $open(); $test->assertSame('Asia/Tokyo', $pg->getServerInfo()['TimeZone']); $test->assertSame('ok', $one($pg, 'SELECT lb_async()')); $test->assertSame('UTC', $pg->getServerInfo()['TimeZone']); $pg->query("SET TimeZone = 'Europe/Amsterdam'"); $test->assertSame('Europe/Amsterdam', $pg->getServerInfo()['TimeZone']); }); $test->it('COPY TO STDOUTのデータは無視するはず', function($test) use ($open): void { $res = $open()->query('COPY lb_test TO STDOUT'); $test->assertSame('COPY 1', $res['command']); $test->assertSame(1, $res['affectedRows']); $test->assertSame([], $res['rows']); }); $test->it('FATAL・切断・タイムアウトで接続を閉じるはず', function($test) use ($open, $catch): void { $pg = $open(); $pg->beginTransaction(); $e = $catch(fn() => $pg->query('SELECT lb_fatal()')); $test->assertSame('57P01', $e->sqlState); $test->assertFalse($pg->isConnected()); $test->assertFalse($pg->inTransaction()); $pg = $open(); $e = $catch(fn() => $pg->query('SELECT lb_disconnect()')); $test->assertSame('サーバーとの接続が切れました', $e->getMessage()); $test->assertFalse($pg->isConnected()); // 再接続 $test->assertTrue($pg->connect()); $test->assertTrue($pg->ping()); $pg = $open(['timeout' => 1]); $start = microtime(true); $e = $catch(fn() => $pg->query('SELECT lb_sleep(2.5) AS z')); $test->assertStringContains('タイムアウト(1秒)', $e->getMessage()); $test->assertTrue(microtime(true) - $start < 2.3, 'タイムアウトが遅い'); $test->assertFalse($pg->isConnected()); }); $test->it('デバッグでメッセージを記録するはず', function($test) use ($open): void { $pg = $open(); $pg->setDebug(true); ob_start(); $pg->query('SELECT 1 AS a'); $out = ob_get_clean(); $pg->setDebug(false); $test->assertStringContains('=== SEND Q', $out); $log = $pg->getPacketLog(); $test->assertSame(['SEND', 'Q', "SELECT 1 AS a\0"], [$log[0]['direction'], $log[0]['type'], $log[0]['data']]); $test->assertSame(['T', 'D', 'C', 'Z'], array_column(array_slice($log, 1), 'type')); }); $srv->stop(); }); $test->describe('Pgsql(偽物の悪いサーバー)', function($test) use ($catch, $cert): void { $tls = ['cafile' => $cert['ca'], 'peer_name' => 'localhost']; // 接続して、出来たらSQLの結果、出来なかったら例外 $connect = function(array $args, array $o = [], string $sql = 'SELECT 1 AS x'): mixed { $srv = MockServer::start('pg-server.php', $args); if ($srv === null) return new \RuntimeException('起動出来ない'); try { $pg = new Pgsql($o + ['host' => '127.0.0.1', 'port' => $srv->port, 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb', 'timeout' => 5]); $pg->connect(); return array_values($pg->query($sql)['rows'][0])[0]; } catch (\Throwable $e) { return $e; } finally { $srv->stop(); } }; $test->it('TLSに対応していないサーバーでTLSを求めたら例外のはず', function($test) use ($connect): void { $e = $connect([], ['tls' => true]); $test->assertTrue($e instanceof DbException); $test->assertStringContains('サーバーがTLSに対応していません', $e->getMessage()); }); $test->it('サーバーの署名・nonceが不正なら例外のはず', function($test) use ($connect): void { $e = $connect(['', '', 'badsig']); $test->assertTrue($e instanceof DbException); $test->assertStringContains('サーバーの署名が不正です', $e->getMessage()); $e = $connect(['', '', 'badnonce']); $test->assertStringContains('不正なSCRAMのメッセージ', $e->getMessage()); $e = $connect(['', '', 'nofinal']); $test->assertStringContains('サーバーがSCRAMを完了しませんでした', $e->getMessage()); $test->assertSame(1, $connect(['', '', 'nofinal'], ['username' => 'trust'])); }); $test->it('チャネルバインディングを出さないTLSのサーバー', function($test) use ($connect, $cert, $tls): void { // preferなら「y」(クライアントは対応している)で接続する $test->assertSame('y', $connect([$cert['cert'], $cert['key'], 'nocb'], ['tls' => $tls], 'SELECT lb_cb() AS cb')); $e = $connect([$cert['cert'], $cert['key'], 'nocb'], ['tls' => $tls, 'channel_binding' => 'require']); $test->assertStringContains('チャネルバインディングが使えません', $e->getMessage()); }); }); $test->describe('Pgsql(本物のサーバー、LB_TEST_PGSQL)', function($test) use ($common): void { $url = (string)getenv('LB_TEST_PGSQL'); $u = $url === '' ? false : parse_url($url); if ($u === false) { $test->skipAll('LB_TEST_PGSQL(pgsql://ユーザー:パスワード@ホスト:ポート/データベース)が無い'); return; } parse_str($u['query'] ?? '', $q); $info = fn(array $o = []): array => $o + [ 'host' => $u['host'] ?? '127.0.0.1', 'port' => $u['port'] ?? 5432, 'username' => urldecode($u['user'] ?? ''), 'password' => urldecode($u['pass'] ?? ''), 'dbname' => ltrim(urldecode($u['path'] ?? ''), '/'), 'tls' => isset($q['cafile']) ? ['cafile' => $q['cafile']] : false, 'timeout' => 10, ]; $common($test, $info); }); $test->printSummary();