PostgreSQLドライバー

守矢諏訪子 <suwako@076.moe>、2026-11-12 14:52
コミット f9c291946edaf7476f047001ba33f473d4ef4eda ・親 b5a37f2 ・ファイルを見る

7個のファイル、+2695 -21

変更 README-JP.md
@@ -207,25 +207,29 @@
207207
208208 テンプレートやコードでは、URLは `ass_url('article/a.png')`、ディスク上のファイルは `ROOT.ass_path('article/a.png')` を使います。
209209
210-### データベース(MySQL・MariaDB)
211-Little Beast 自体はデータベースを使いませんが、必要な場合(既存のアプリの移植等)は `Std\Lib\Db` で繋げます。MySQL のドライバは PHP だけで書かれているので、mysqli・pdo_mysql・sockets は不要です。\
212-`config/config.php` で `DB_ENABLED` を `true` にして、`DBINFO` を設定して下さい(`driver` は `mysql`、MariaDB も同じ。`socket` を指定すると `host`・`port` の代わりに UNIX ソケットを使う。`tls` は `true`、又は `['cafile' => '/etc/ssl/ca.pem']` 等の SSL コンテキストオプションの配列)。
210+### データベース(MySQL・MariaDB、PostgreSQL)
211+Little Beast 自体はデータベースを使いませんが、必要な場合(既存のアプリの移植等)は `Std\Lib\Db` で繋げます。ドライバは PHP だけで書かれているので、mysqli・pdo_mysql・pgsql・pdo_pgsql・sockets は不要です。\
212+`config/config.php` で `DB_ENABLED` を `true` にして、`DBINFO` を設定して下さい(`driver` は `mysql`(MariaDB も同じ)、又は PostgreSQL の `pgsql`(ポートは普通5432)。`socket` を指定すると `host`・`port` の代わりに UNIX ソケットを使う、PostgreSQL ではソケットのファイル又は `.s.PGSQL.5432` が有るディレクトリ(例:`/tmp`)。`tls` は `true`、又は `['cafile' => '/etc/ssl/ca.pem']` 等の SSL コンテキストオプションの配列)。
213213 ```php
214214 $db = new \Std\Lib\Db();
215215 $rows = $db->query('SELECT * FROM users WHERE id = ?', [1])['rows'];
216-$id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId'];
216+$id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId']; // MySQL
217+$id = $db->query('INSERT INTO users (name) VALUES (?) RETURNING id', ['諏訪子'])['rows'][0]['id']; // PostgreSQL
217218 $db->transaction(function(\Std\Lib\Db $db): void {
218219 $db->query('UPDATE users SET points = points - ? WHERE id = ?', [100, 1]);
219220 $db->query('UPDATE users SET points = points + ? WHERE id = ?', [100, 2]);
220221 });
221222 ```
222223 * 最初のクエリで接続します。パラメータ付きのクエリはプリペアドステートメントで実行するので、値をエスケープする必要はありません。
223-* 整数の列は `int`、`FLOAT`・`DOUBLE` は `float`、`NULL` は `null`、その他(`DECIMAL`、日付、`BLOB` 等)は文字列で返します。
224-* エラーは `Std\Lib\Db\DbException`(`getCode()` はサーバーのエラーコード、例:重複は1062、`sqlState` は SQLSTATE)を投げ、`log/db_log.txt` に書きます。
225-* 認証:`mysql_native_password`、`caching_sha2_password`(MySQL 8 の既定)と `sha256_password`(TLS でない TCP では、パスワードをサーバーの RSA の鍵で暗号化する)、`mysql_clear_password`(TLS か UNIX ソケットのみ)。MariaDB の `client_ed25519`、圧縮、`LOAD DATA LOCAL INFILE` は未対応です。
224+* 整数の列は `int`、`FLOAT`・`DOUBLE`(PostgreSQL は `real`・`double precision`)は `float`、`NULL` は `null`、その他(`DECIMAL`・`numeric`、日付、JSON、`BLOB` 等)は文字列で返します。PostgreSQL の `boolean` は `bool`、`bytea` は生のバイト、配列は PHP の配列で返します。
225+* エラーは `Std\Lib\Db\DbException`(`getCode()` は MySQL のエラーコード、例:重複は1062、`sqlState` は SQLSTATE、例:PostgreSQL の重複は23505)を投げ、`log/db_log.txt` に書きます。
226+* MySQL の認証:`mysql_native_password`、`caching_sha2_password`(MySQL 8 の既定)と `sha256_password`(TLS でない TCP では、パスワードをサーバーの RSA の鍵で暗号化する)、`mysql_clear_password`(TLS か UNIX ソケットのみ)。MariaDB の `client_ed25519`、圧縮、`LOAD DATA LOCAL INFILE` は未対応です。
227+* PostgreSQL の認証:SCRAM-SHA-256(TLS ならチャネルバインディング付きの SCRAM-SHA-256-PLUS、オプションの `channel_binding` は `prefer`・`require`・`disable`)、MD5、`password`(`allow_cleartext` を `true` にしない限り、TLS か UNIX ソケットのみ)、`trust`。GSSAPI・SSPI は未対応です。
228+* PostgreSQL:`?` のプレースホルダは `$1`、`$2`… にします(文字列・識別子・コメントの外のみ。`?` そのもの(jsonb の演算子等)は `??` と書く)。`$1` もそのまま使えます。挿入した ID は無いので、`RETURNING` を使って下さい。トランザクションの中でエラーが起きた後の `COMMIT` は、PostgreSQL がロールバックするので `DbException`(SQLSTATE 25P02)を投げます。`COPY`、`LISTEN` の通知、実行中のクエリの取り消しは未対応です。PostgreSQL 16 で確認しました。
229+* テストは偽物のサーバーを使います。PostgreSQL のテストを本物のサーバーでも実行するには、`LB_TEST_PGSQL=pgsql://ユーザー:パスワード@ホスト:ポート/データベース` を設定して下さい(TLS は `?cafile=/path/ca.pem` を追加)。名前が `lb_test_` で始まるテーブルを作って消します。
226230
227231 ### TLS 1.3・1.2(php_openssl は不要)
228-HTTPS のリクエスト(`Curl`、ActivityPub)、SMTP の STARTTLS(`Mailer`)、MySQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント `Std\Lib\Tls`(`php_gmp` が必要)を使うので、`php_openssl` は不要です。RSA の鍵と署名(ActivityPub)は `Std\Lib\Crypto\Rsa` を使い、今までの PEM の鍵もそのまま使えます。
232+HTTPS のリクエスト(`Curl`、ActivityPub)、SMTP の STARTTLS(`Mailer`)、MySQL・PostgreSQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント `Std\Lib\Tls`(`php_gmp` が必要)を使うので、`php_openssl` は不要です。RSA の鍵と署名(ActivityPub)は `Std\Lib\Crypto\Rsa` を使い、今までの PEM の鍵もそのまま使えます。
229233 * TLS 1.3 を優先し、TLS 1.2 にしか対応していないサーバーとは安全な暗号スイートだけで TLS 1.2 を使います(ECDHE と AES-GCM 又は ChaCha20-Poly1305、サーバーが対応していれば Extended Master Secret、再ネゴシエーションはしない)。CBC、RSA の鍵交換、DHE、TLS 1.1 以前は未対応です。TLS 1.3 に対応しているサーバーとの通信を攻撃者が TLS 1.2 に下げた場合は分かります。TLS 1.2 を拒否するには、オプションの `'min_version' => '1.3'` を使って下さい。
230234 * 証明書は `config/config.php` の `TLS_CAFILE`(信頼する CA の PEM ファイル)で確認します。空なら、普通のシステムの場所を探します(OpenBSD は `/etc/ssl/cert.pem`)。httpd・php-fpm の chroot の中では `/var/www/etc/ssl/cert.pem` になるので、システムのファイルをそこにコピーして下さい。Windows にはこのファイルが無いので、`TLS_CAFILE` を設定して下さい(例:curl の `cacert.pem`)。
231235 * 暗号スイート:TLS 1.3 は `TLS_CHACHA20_POLY1305_SHA256`、`TLS_AES_128_GCM_SHA256`、`TLS_AES_256_GCM_SHA384`、TLS 1.2 は `TLS_ECDHE_{ECDSA,RSA}_WITH_{CHACHA20_POLY1305_SHA256,AES_128_GCM_SHA256,AES_256_GCM_SHA384}`。鍵交換:x25519、secp256r1、secp384r1。署名:RSA-PSS、ECDSA(P-256、P-384、P-521)、RSA PKCS#1 v1.5(証明書と TLS 1.2 のみ)。セッションの再開、0-RTT、クライアント証明書、失効の確認は未対応です。
変更 README.md
@@ -205,25 +205,29 @@
205205
206206 In templates and code, use `ass_url('article/a.png')` for the URL and `ROOT.ass_path('article/a.png')` for the file on disk.
207207
208-### Database (MySQL/MariaDB)
209-Little Beast itself needs no database, but `Std\Lib\Db` can talk to one when a site has to (for example, when porting an existing application). The MySQL driver is written in pure PHP, so mysqli, pdo_mysql and sockets are not needed.\
210-Set `DB_ENABLED` to `true` and fill in `DBINFO` in `config/config.php` (`driver` is `mysql`, which also works with MariaDB; `socket` uses a UNIX socket instead of `host` and `port`; `tls` is `true` or an array of SSL context options such as `['cafile' => '/etc/ssl/ca.pem']`).
208+### Database (MySQL/MariaDB, PostgreSQL)
209+Little Beast itself needs no database, but `Std\Lib\Db` can talk to one when a site has to (for example, when porting an existing application). The drivers are written in pure PHP, so mysqli, pdo_mysql, pgsql, pdo_pgsql and sockets are not needed.\
210+Set `DB_ENABLED` to `true` and fill in `DBINFO` in `config/config.php` (`driver` is `mysql`, which also works with MariaDB, or `pgsql` for PostgreSQL, whose port is usually 5432; `socket` uses a UNIX socket instead of `host` and `port`, for PostgreSQL either the socket file or the directory that has `.s.PGSQL.5432`, such as `/tmp`; `tls` is `true` or an array of SSL context options such as `['cafile' => '/etc/ssl/ca.pem']`).
211211 ```php
212212 $db = new \Std\Lib\Db();
213213 $rows = $db->query('SELECT * FROM users WHERE id = ?', [1])['rows'];
214-$id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId'];
214+$id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId']; // MySQL
215+$id = $db->query('INSERT INTO users (name) VALUES (?) RETURNING id', ['諏訪子'])['rows'][0]['id']; // PostgreSQL
215216 $db->transaction(function(\Std\Lib\Db $db): void {
216217 $db->query('UPDATE users SET points = points - ? WHERE id = ?', [100, 1]);
217218 $db->query('UPDATE users SET points = points + ? WHERE id = ?', [100, 2]);
218219 });
219220 ```
220221 * The connection is opened on the first query. Queries with parameters use prepared statements, so values never need escaping.
221-* Integer columns come back as `int`, `FLOAT`/`DOUBLE` as `float`, `NULL` as `null`, and everything else (including `DECIMAL`, dates and `BLOB`) as strings.
222-* Errors throw `Std\Lib\Db\DbException` (`getCode()` is the server's error code, for example 1062 for a duplicate key, and `sqlState` the SQLSTATE) and are written to `log/db_log.txt`.
223-* Authentication: `mysql_native_password`, `caching_sha2_password` (the MySQL 8 default) and `sha256_password` (over plain TCP the password is encrypted with the server's RSA key), and `mysql_clear_password` (TLS or UNIX socket only). MariaDB's `client_ed25519`, compression and `LOAD DATA LOCAL INFILE` are not supported.
222+* Integer columns come back as `int`, `FLOAT`/`DOUBLE` (PostgreSQL: `real`/`double precision`) as `float`, `NULL` as `null`, and everything else (including `DECIMAL`/`numeric`, dates, JSON and `BLOB`) as strings. PostgreSQL's `boolean` comes back as `bool`, `bytea` as the raw bytes and arrays as PHP arrays.
223+* Errors throw `Std\Lib\Db\DbException` (`getCode()` is MySQL's error code, for example 1062 for a duplicate key, and `sqlState` the SQLSTATE, for example 23505 for a duplicate key in PostgreSQL) and are written to `log/db_log.txt`.
224+* MySQL authentication: `mysql_native_password`, `caching_sha2_password` (the MySQL 8 default) and `sha256_password` (over plain TCP the password is encrypted with the server's RSA key), and `mysql_clear_password` (TLS or UNIX socket only). MariaDB's `client_ed25519`, compression and `LOAD DATA LOCAL INFILE` are not supported.
225+* PostgreSQL authentication: SCRAM-SHA-256 (over TLS with channel binding, SCRAM-SHA-256-PLUS; the option `channel_binding` is `prefer`, `require` or `disable`), MD5, `password` (TLS or UNIX socket only, unless `allow_cleartext` is `true`) and `trust`. GSSAPI and SSPI are not supported.
226+* PostgreSQL: `?` placeholders become `$1`, `$2`… (outside strings, identifiers and comments; write `??` for a literal `?`, such as the jsonb operator), and `$1` can be used directly too. There is no insert ID, so use `RETURNING`. A `COMMIT` after an error in the transaction throws `DbException` (SQLSTATE 25P02), because PostgreSQL rolls the transaction back. `COPY`, `LISTEN` notifications and cancelling a running query are not supported. Tested with PostgreSQL 16.
227+* The tests use a mock server. To run the PostgreSQL tests against a real server as well, set `LB_TEST_PGSQL=pgsql://user:password@host:port/database` (add `?cafile=/path/ca.pem` for TLS); they create and drop tables whose names start with `lb_test_`.
224228
225229 ### TLS 1.3 and 1.2 (no php_openssl)
226-HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working.
230+HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL and PostgreSQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working.
227231 * TLS 1.3 is preferred. Servers that only speak TLS 1.2 get TLS 1.2 with secure cipher suites only (ECDHE with AES-GCM or ChaCha20-Poly1305, Extended Master Secret when the server supports it, no renegotiation); CBC, RSA key exchange and DHE are not supported, and neither is TLS 1.1 or older. A TLS 1.3 server that is pushed down to TLS 1.2 by an attacker is detected. Use the option `'min_version' => '1.3'` to refuse TLS 1.2.
228232 * Certificates are checked against `TLS_CAFILE` in `config/config.php` (a PEM file of trusted CAs). When it is empty, the usual system locations are tried (`/etc/ssl/cert.pem` on OpenBSD). Inside the httpd/php-fpm chroot that path is `/var/www/etc/ssl/cert.pem`, so copy the system file there. Windows has no such file, so set `TLS_CAFILE` (for example to curl's `cacert.pem`).
229233 * Cipher suites: TLS 1.3 `TLS_CHACHA20_POLY1305_SHA256`, `TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`; TLS 1.2 `TLS_ECDHE_{ECDSA,RSA}_WITH_{CHACHA20_POLY1305_SHA256,AES_128_GCM_SHA256,AES_256_GCM_SHA384}`. Key exchange: x25519, secp256r1, secp384r1. Signatures: RSA-PSS, ECDSA (P-256, P-384, P-521) and RSA PKCS#1 v1.5 (certificates and TLS 1.2 only). Session resumption, 0-RTT, client certificates and revocation checks are not supported.
@@ -76,10 +76,11 @@
7676 'pubkey' => ROOT.'/public/static/pub.pem',
7777 'privkey' => ROOT.'/data/priv.pem',
7878 ]);
79-// データベース(DB_ENABLEDが必要)。driver:mysql(MariaDBも)
80-// socket:UNIXソケットのパス(指定するとhost・portの代わりに使う、例:/var/run/mysql/mysql.sock)
79+// データベース(DB_ENABLEDが必要)。driver:mysql(MariaDBも)、pgsql(PostgreSQL、portは普通5432、charsetはUTF8)
80+// socket:UNIXソケットのパス(指定するとhost・portの代わりに使う、例:/var/run/mysql/mysql.sock、PostgreSQLはディレクトリも可:/tmp)
8181 // tls:trueで証明書を確認するTLS、又はsslのコンテキストオプションの配列(例:['cafile' => '/etc/ssl/ca.pem'])
8282 // debug:送受信したパケットを出力する
83+// PostgreSQLのみ:channel_binding(prefer、require、disable)、allow_cleartext(TLSでなくてもパスワードをそのまま送る)
8384 define('DBINFO', [
8485 'driver' => 'mysql',
8586 'host' => '127.0.0.1',
@@ -39,6 +39,7 @@
3939
4040 use Std\Lib\Db\DbInterface;
4141 use Std\Lib\Db\Mysql;
42+use Std\Lib\Db\Pgsql;
4243
4344 /**
4445 * データベース
@@ -48,7 +49,7 @@
4849 * 使い方:
4950 * $db = new Db();
5051 * $users = $db->query('SELECT * FROM users WHERE role > ?', [0])['rows'];
51- * $id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId'];
52+ * $id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId']; // MySQL(PostgreSQLはRETURNINGを使う)
5253 * $db->transaction(function(Db $db): void {
5354 * $db->query('UPDATE users SET points = points - ? WHERE id = ?', [100, 1]);
5455 * $db->query('UPDATE users SET points = points + ? WHERE id = ?', [100, 2]);
@@ -56,8 +57,11 @@
5657 */
5758 class Db {
5859 private const SUPPORTED = [
59- 'mysql' => Mysql::class,
60- 'mariadb' => Mysql::class,
60+ 'mysql' => Mysql::class,
61+ 'mariadb' => Mysql::class,
62+ 'pgsql' => Pgsql::class,
63+ 'postgres' => Pgsql::class,
64+ 'postgresql' => Pgsql::class,
6165 ];
6266
6367 public string $driverName;
@@ -0,0 +1,1006 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib\Db;
39+
40+use Std\Lib\Tls;
41+use Std\Lib\Tls\TlsException;
42+
43+/**
44+ * PHP純正のPostgreSQLドライバ(プロトコル3.0)。pgsql・pdo_pgsql・opensslのモジュールが不要。
45+ *
46+ * 対応:TCP・UNIXソケット、TLS(Std\Lib\Tls)、認証(SCRAM-SHA-256、TLSならチャネルバインディング付きの
47+ * SCRAM-SHA-256-PLUS、MD5、パスワード、trust)、単純・拡張クエリ、複数の結果、トランザクション、通知(NOTICE)、
48+ * 型の変換(整数・浮動小数点数・真偽値・bytea・配列)
49+ * 未対応:COPY(データは無視する)、LISTEN・NOTIFYの待ち受け、GSSAPI・SSPI・Kerberos、取り消し要求
50+ *
51+ * プレースホルダは「?」($1、$2…に変換する、「??」は「?」そのもの)又は「$1」。
52+ * PostgreSQLにはAUTO_INCREMENTのIDが無いので、insertIdは常に0(RETURNINGを使って下さい)。
53+ *
54+ * 普通はStd\Lib\Db経由で使う。直接使う場合:
55+ * $pg = new Pgsql(['host' => '127.0.0.1', 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb']);
56+ * $pg->connect();
57+ * $id = $pg->prepare('SELECT * FROM users WHERE id = ?');
58+ * $res = $pg->execute($id, [1]); // $res['rows']
59+ * $pg->demolish($id);
60+ */
61+class Pgsql implements DbInterface {
62+ // 型のOID
63+ public const OID_BOOL = 16;
64+ public const OID_BYTEA = 17;
65+ public const OID_INT8 = 20;
66+ public const OID_INT2 = 21;
67+ public const OID_INT4 = 23;
68+ public const OID_TEXT = 25;
69+ public const OID_OID = 26;
70+ public const OID_JSON = 114;
71+ public const OID_FLOAT4 = 700;
72+ public const OID_FLOAT8 = 701;
73+ public const OID_VARCHAR = 1043;
74+ public const OID_DATE = 1082;
75+ public const OID_TIMESTAMP = 1114;
76+ public const OID_TIMESTAMPTZ = 1184;
77+ public const OID_NUMERIC = 1700;
78+ public const OID_UUID = 2950;
79+ public const OID_JSONB = 3802;
80+
81+ // 配列の型 => 要素の型
82+ private const ARRAYS = [
83+ 1000 => 16, 1001 => 17, 1005 => 21, 1007 => 23, 1016 => 20, 1009 => 25, 1015 => 1043, 1014 => 1042,
84+ 1021 => 700, 1022 => 701, 1231 => 1700, 1182 => 1082, 1115 => 1114, 1185 => 1184, 2951 => 2950, 199 => 114, 3807 => 3802,
85+ ];
86+
87+ private const PROTOCOL = 196608; // 3.0
88+ private const SSL_REQUEST = 80877103;
89+
90+ /** @var resource|null */
91+ private $socket = null;
92+ /** @var resource|null TLSの下のソケット(タイムアウトの確認用) */
93+ private $raw = null;
94+ private bool $connected = false;
95+ private bool $secure = false;
96+ private bool $debug = false;
97+ private array $packetLog = [];
98+ private string $out = '';
99+ public string $username;
100+ private string $password;
101+ public string $dbname;
102+ public string $host;
103+ private int $port;
104+ private string $socketPath;
105+ private string $charset;
106+ private bool|array $tls;
107+ private string $channelBinding;
108+ private bool $allowCleartext;
109+ private int $timeout;
110+
111+ private string $status = '';
112+ private array $params = [];
113+ private int $pid = 0;
114+ private string $authMethod = '';
115+ private array $notices = [];
116+ private array $prepared = [];
117+ private int $nextStmt = 1;
118+
119+ /**
120+ * コンストラクタ
121+ *
122+ * 接続に必要な設定を初期化します。$infoを省略するとDBINFOを使います。
123+ *
124+ * @param array|null $info host、port(5432)、socket(UNIXソケットのファイル又はディレクトリ)、username、password、dbname、
125+ * charset(UTF8)、tls(true=証明書を確認するTLS、又はStd\Lib\Tlsのオプションの配列)、
126+ * channel_binding(prefer=TLSなら使う、require、disable)、
127+ * allow_cleartext(TLSでないTCPでもパスワードをそのまま送る、既定false)、timeout(秒、10)、debug
128+ * @throws \Exception $infoを省略し、DB_ENABLEDが無効な場合
129+ */
130+ public function __construct(?array $info = null) {
131+ if ($info === null && !(defined('DB_ENABLED') && DB_ENABLED)) {
132+ throw new \Exception('データベースは無効です。');
133+ }
134+
135+ $info ??= DBINFO;
136+ $this->host = (string)($info['host'] ?? '127.0.0.1');
137+ $this->port = (int)($info['port'] ?? 5432);
138+ $this->socketPath = (string)($info['socket'] ?? '');
139+ $this->username = (string)($info['username'] ?? '');
140+ $this->password = (string)($info['password'] ?? '');
141+ $this->dbname = (string)($info['dbname'] ?? '');
142+ $this->charset = (string)($info['charset'] ?? 'UTF8');
143+ $this->tls = $info['tls'] ?? false;
144+ $this->channelBinding = strtolower((string)($info['channel_binding'] ?? 'prefer'));
145+ $this->allowCleartext = (bool)($info['allow_cleartext'] ?? false);
146+ $this->timeout = max(1, (int)($info['timeout'] ?? 10));
147+ $this->debug = (bool)($info['debug'] ?? false);
148+
149+ if (!in_array($this->channelBinding, ['prefer', 'require', 'disable'], true)) {
150+ throw new \InvalidArgumentException("channel_bindingはprefer、require、disable: {$this->channelBinding}");
151+ }
152+ // PostgreSQLの「utf8mb4」は無いので、MySQLと同じ設定でも動く様に
153+ if (in_array(strtolower($this->charset), ['utf8mb4', 'utf8', 'utf-8'], true)) $this->charset = 'UTF8';
154+ }
155+
156+ public function __destruct() {
157+ $this->close();
158+ }
159+
160+ /**
161+ * デバッギングの有無(有効にすると送受信したメッセージを出力し、ログに残す)
162+ *
163+ * @param bool $debug
164+ * @return Pgsql
165+ */
166+ public function setDebug(bool $debug): Pgsql {
167+ $this->debug = $debug;
168+ return $this;
169+ }
170+
171+ /**
172+ * メッセージのログ
173+ *
174+ * @return array direction、type、length、data、timestamp
175+ */
176+ public function getPacketLog(): array {
177+ return $this->packetLog;
178+ }
179+
180+ /**
181+ * サーバーの情報
182+ *
183+ * @return array サーバーのパラメータ(server_version、client_encoding等)、pid、tls、authMethod
184+ */
185+ public function getServerInfo(): array {
186+ return $this->params + ['pid' => $this->pid, 'tls' => $this->secure && $this->socketPath === '', 'authMethod' => $this->authMethod];
187+ }
188+
189+ /**
190+ * 最後のクエリの通知(NOTICE、WARNING等)
191+ *
192+ * @return array ['severity' => …, 'code' => SQLSTATE, 'message' => …]の配列
193+ */
194+ public function getNotices(): array {
195+ return $this->notices;
196+ }
197+
198+ /**
199+ * PostgreSQLサーバーに接続
200+ *
201+ * @return bool
202+ * @throws DbException 接続または認証に失敗した場合
203+ */
204+ public function connect(): bool {
205+ if ($this->connected) return true;
206+
207+ $unix = $this->socketPath !== '';
208+ if ($unix) {
209+ $path = @is_dir($this->socketPath) ? rtrim($this->socketPath, '/')."/.s.PGSQL.{$this->port}" : $this->socketPath;
210+ $target = "unix://{$path}";
211+ } else {
212+ $target = 'tcp://'.(str_contains($this->host, ':') ? "[{$this->host}]" : $this->host).":{$this->port}";
213+ }
214+ $errno = 0;
215+ $errstr = '';
216+ $socket = @stream_socket_client($target, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, stream_context_create());
217+ if ($socket === false) $this->fail("ソケットに接続に失敗: {$errstr} ({$errno})");
218+ stream_set_timeout($socket, $this->timeout);
219+ $this->socket = $this->raw = $socket;
220+ $this->secure = $unix;
221+
222+ try {
223+ if ($this->tls !== false) $this->startTls();
224+ $this->startup();
225+ $this->connected = true;
226+ } catch (\Throwable $e) {
227+ $this->drop();
228+ throw $e;
229+ }
230+ return true;
231+ }
232+
233+ public function close(): void {
234+ if ($this->socket && $this->connected) {
235+ try {
236+ $this->send('X', ''); // Terminate
237+ $this->flush();
238+ } catch (\Throwable $e) {
239+ // 既に切れている
240+ }
241+ }
242+ $this->drop();
243+ }
244+
245+ public function isConnected(): bool {
246+ return $this->connected;
247+ }
248+
249+ /**
250+ * 接続がまだ生きているか確認する(空のクエリ)
251+ *
252+ * @return bool
253+ */
254+ public function ping(): bool {
255+ if (!$this->connected) return false;
256+ try {
257+ $this->query('');
258+ return true;
259+ } catch (\Throwable $e) {
260+ return false;
261+ }
262+ }
263+
264+ /**
265+ * SQLをそのまま実行する(単純クエリ、セミコロンで区切った複数の文も可)
266+ *
267+ * @param string $query SQL
268+ * @return array fields、rows、affectedRows、insertId(常に0)、warningCount、command(例:「INSERT 0 1」)(、more)
269+ * @throws DbException
270+ */
271+ public function query(string $query): array {
272+ $this->requireConnection();
273+ $this->notices = [];
274+ $this->send('Q', $query."\0");
275+ return $this->collect('クエリに失敗');
276+ }
277+
278+ /**
279+ * プリペアドステートメントの準備
280+ *
281+ * @param string $query プレースホルダ(「?」又は「$1」)付きのSQL
282+ * @return int ステートメントID
283+ * @throws DbException
284+ */
285+ public function prepare(string $query): int {
286+ $this->requireConnection();
287+ $id = $this->nextStmt++;
288+ $name = "lb{$id}";
289+
290+ $this->send('P', $name."\0".self::placeholders($query)."\0\0\0");
291+ $this->send('D', 'S'.$name."\0");
292+ $this->send('S', '');
293+
294+ $params = [];
295+ $error = null;
296+ while (true) {
297+ [$type, $body] = $this->readMessage();
298+ switch ($type) {
299+ case '1': // ParseComplete
300+ case 'n': // NoData
301+ case 'T': // RowDescription(列は実行の時にポータルのDescribeで受け取る)
302+ break;
303+ case 't': // ParameterDescription
304+ $n = unpack('n', $body)[1];
305+ $params = $n > 0 ? array_values(unpack("N{$n}", $body, 2)) : [];
306+ break;
307+ case 'E':
308+ $error = $this->parseError($body);
309+ if ($this->fatal($error)) $this->failError('準備に失敗', $error);
310+ break;
311+ case 'Z':
312+ $this->status = $body;
313+ if ($error) $this->failError('準備に失敗', $error);
314+ $this->prepared[$id] = ['name' => $name, 'params' => $params];
315+ return $id;
316+ default:
317+ $this->other($type, $body);
318+ }
319+ }
320+ }
321+
322+ /**
323+ * プリペアドステートメントの実行
324+ *
325+ * int・float・bool・null・文字列(bytea型には生のバイト)・DateTimeInterface・BackedEnumを送れます。
326+ *
327+ * @param int $statementId ステートメントID
328+ * @param array $params パラメータ(順番通り)
329+ * @return array 結果(query()と同じ形)
330+ * @throws DbException
331+ */
332+ public function execute(int $statementId, array $params = []): array {
333+ $this->requireConnection();
334+ if (!isset($this->prepared[$statementId])) $this->fail("不正なステートメントID: {$statementId}");
335+
336+ $stmt = $this->prepared[$statementId];
337+ $params = array_values($params);
338+ $n = count($stmt['params']);
339+ if (count($params) !== $n) {
340+ $this->fail("パラメータ数が一致しません: 期待 {$n}, 取得 ".count($params));
341+ }
342+
343+ $formats = '';
344+ $values = '';
345+ foreach ($params as $i => $v) {
346+ [$format, $data] = $this->encodeParam($v, $stmt['params'][$i], $i);
347+ $formats .= pack('n', $format);
348+ $values .= $data === null ? pack('N', 0xFFFFFFFF) : pack('N', strlen($data)).$data;
349+ }
350+
351+ $this->notices = [];
352+ $this->send('B', "\0".$stmt['name']."\0".pack('n', $n).$formats.pack('n', $n).$values."\0\0");
353+ $this->send('D', "P\0");
354+ $this->send('E', "\0".pack('N', 0));
355+ $this->send('S', '');
356+ return $this->collect('実行に失敗');
357+ }
358+
359+ /**
360+ * プリペアドステートメントの解放
361+ *
362+ * @param int $statementId
363+ * @return bool
364+ */
365+ public function demolish(int $statementId): bool {
366+ if (!isset($this->prepared[$statementId])) return false;
367+ $name = $this->prepared[$statementId]['name'];
368+ unset($this->prepared[$statementId]);
369+ if (!$this->connected) return false;
370+
371+ $this->send('C', 'S'.$name."\0");
372+ $this->send('S', '');
373+ while (true) {
374+ [$type, $body] = $this->readMessage();
375+ if ($type === 'Z') {
376+ $this->status = $body;
377+ return true;
378+ }
379+ if ($type !== '3' && $type !== 'E') $this->other($type, $body);
380+ }
381+ }
382+
383+ public function beginTransaction(): bool {
384+ $this->query('BEGIN');
385+ return true;
386+ }
387+
388+ /**
389+ * トランザクションをコミットする
390+ *
391+ * 失敗したトランザクション(エラーの後)は、PostgreSQLがCOMMITをROLLBACKにするので、例外にする。
392+ *
393+ * @return bool
394+ * @throws DbException 失敗したトランザクションの場合(SQLSTATEは25P02、ロールバック済み)
395+ */
396+ public function commit(): bool {
397+ $res = $this->query('COMMIT');
398+ if ($res['command'] === 'ROLLBACK') {
399+ $this->fail('コミットに失敗: トランザクションの中でエラーが起きたので、ロールバックしました', 0, '25P02');
400+ }
401+ return true;
402+ }
403+
404+ public function rollback(): bool {
405+ $this->query('ROLLBACK');
406+ return true;
407+ }
408+
409+ /** トランザクション中(失敗したトランザクションも)かどうか */
410+ public function inTransaction(): bool {
411+ return $this->connected && ($this->status === 'T' || $this->status === 'E');
412+ }
413+
414+ public function quoteIdentifier(string $name): string {
415+ return '"'.str_replace('"', '""', $name).'"';
416+ }
417+
418+ /**
419+ * 「?」のプレースホルダを「$1」「$2」…にする
420+ *
421+ * 文字列('…'、E'…')、識別子("…")、$tag$…$tag$、コメントの中は変えない。「??」は「?」そのもの(jsonbの演算子等)。
422+ *
423+ * @param string $sql
424+ * @return string
425+ */
426+ public static function placeholders(string $sql): string {
427+ if (!str_contains($sql, '?')) return $sql;
428+ $out = '';
429+ $n = 0;
430+ $len = strlen($sql);
431+ for ($i = 0; $i < $len; $i++) {
432+ $c = $sql[$i];
433+ $prev = $i > 0 ? $sql[$i - 1] : '';
434+ $word = fn(string $ch): bool => $ch !== '' && (ctype_alnum($ch) || $ch === '_' || ord($ch) >= 0x80);
435+
436+ if ($c === "'") {
437+ $escape = ($prev === 'E' || $prev === 'e') && !$word($i > 1 ? $sql[$i - 2] : '');
438+ $j = $i + 1;
439+ while ($j < $len) {
440+ if ($escape && $sql[$j] === '\\') {
441+ $j += 2;
442+ continue;
443+ }
444+ if ($sql[$j] === "'") {
445+ if (($sql[$j + 1] ?? '') === "'") {
446+ $j += 2;
447+ continue;
448+ }
449+ break;
450+ }
451+ $j++;
452+ }
453+ $out .= substr($sql, $i, $j - $i + 1);
454+ $i = $j;
455+ } else if ($c === '"') {
456+ $j = $i + 1;
457+ while ($j < $len && !($sql[$j] === '"' && ($sql[$j + 1] ?? '') !== '"')) $j += $sql[$j] === '"' ? 2 : 1;
458+ $out .= substr($sql, $i, $j - $i + 1);
459+ $i = $j;
460+ } else if ($c === '$' && !$word($prev) && preg_match('/\G\$([A-Za-z_][A-Za-z0-9_]*)?\$/', $sql, $m, 0, $i)) {
461+ $end = strpos($sql, $m[0], $i + strlen($m[0]));
462+ $j = $end === false ? $len : $end + strlen($m[0]);
463+ $out .= substr($sql, $i, $j - $i);
464+ $i = $j - 1;
465+ } else if ($c === '-' && ($sql[$i + 1] ?? '') === '-') {
466+ $j = strpos($sql, "\n", $i);
467+ $j = $j === false ? $len : $j;
468+ $out .= substr($sql, $i, $j - $i);
469+ $i = $j - 1;
470+ } else if ($c === '/' && ($sql[$i + 1] ?? '') === '*') {
471+ // 入れ子のコメント
472+ $depth = 0;
473+ $j = $i;
474+ while ($j < $len) {
475+ if (substr($sql, $j, 2) === '/*') {
476+ $depth++;
477+ $j += 2;
478+ } else if (substr($sql, $j, 2) === '*/') {
479+ $depth--;
480+ $j += 2;
481+ if ($depth === 0) break;
482+ } else {
483+ $j++;
484+ }
485+ }
486+ $out .= substr($sql, $i, $j - $i);
487+ $i = $j - 1;
488+ } else if ($c === '?') {
489+ if (($sql[$i + 1] ?? '') === '?') {
490+ $out .= '?';
491+ $i++;
492+ } else {
493+ $out .= '$'.(++$n);
494+ }
495+ } else {
496+ $out .= $c;
497+ }
498+ }
499+ return $out;
500+ }
501+
502+ /**
503+ * 配列のテキスト(例:{1,2,NULL}、{{a,b},{"c,d",e}})をPHPの配列にする
504+ *
505+ * @param string $text PostgreSQLの配列の書き方
506+ * @param int $elementOid 要素の型(値の変換に使う)
507+ * @return array|null
508+ */
509+ public static function parseArray(string $text, int $elementOid = self::OID_TEXT): ?array {
510+ // 範囲の指定(例:[0:1]={1,2})は無視する
511+ if ($text !== '' && $text[0] === '[') $text = substr($text, strpos($text, '=') + 1);
512+ $pos = 0;
513+ $res = self::parseArrayLevel($text, $pos, $elementOid);
514+ return $pos === strlen($text) ? $res : null;
515+ }
516+
517+ private static function parseArrayLevel(string $t, int &$pos, int $oid): ?array {
518+ if (($t[$pos] ?? '') !== '{') return null;
519+ $pos++;
520+ $out = [];
521+ $len = strlen($t);
522+ if (($t[$pos] ?? '') === '}') {
523+ $pos++;
524+ return [];
525+ }
526+ while ($pos < $len) {
527+ if ($t[$pos] === '{') {
528+ $out[] = self::parseArrayLevel($t, $pos, $oid);
529+ } else if ($t[$pos] === '"') {
530+ $v = '';
531+ for ($pos++; $pos < $len && $t[$pos] !== '"'; $pos++) {
532+ if ($t[$pos] === '\\') $pos++;
533+ $v .= $t[$pos] ?? '';
534+ }
535+ $pos++;
536+ $out[] = self::castText($v, $oid);
537+ } else {
538+ $start = $pos;
539+ while ($pos < $len && $t[$pos] !== ',' && $t[$pos] !== '}') $pos++;
540+ $v = substr($t, $start, $pos - $start);
541+ $out[] = strtoupper($v) === 'NULL' ? null : self::castText($v, $oid);
542+ }
543+ $c = $t[$pos] ?? '';
544+ $pos++;
545+ if ($c === '}') return $out;
546+ if ($c !== ',') return null;
547+ }
548+ return null;
549+ }
550+
551+ /**
552+ * テキストの値をPHPの型にする
553+ *
554+ * @param string $v 値
555+ * @param int $oid 型
556+ * @return mixed 整数はint、浮動小数点数はfloat、真偽値はbool、byteaはバイト、配列は配列、その他は文字列
557+ */
558+ public static function castText(string $v, int $oid): mixed {
559+ switch ($oid) {
560+ case self::OID_INT2:
561+ case self::OID_INT4:
562+ case self::OID_INT8:
563+ case self::OID_OID:
564+ $i = (int)$v;
565+ return (string)$i === $v ? $i : $v;
566+ case self::OID_FLOAT4:
567+ case self::OID_FLOAT8:
568+ return match ($v) {
569+ 'NaN' => NAN,
570+ 'Infinity' => INF,
571+ '-Infinity' => -INF,
572+ default => (float)$v,
573+ };
574+ case self::OID_BOOL:
575+ return $v === 't';
576+ case self::OID_BYTEA:
577+ if (str_starts_with($v, '\\x')) return (string)hex2bin(substr($v, 2));
578+ // escape形式(bytea_output = escape)
579+ return preg_replace_callback('/\\\\(\\\\|[0-7]{3})/', fn($m) => $m[1] === '\\' ? '\\' : chr(octdec($m[1])), $v);
580+ }
581+ if (isset(self::ARRAYS[$oid])) return self::parseArray($v, self::ARRAYS[$oid]) ?? $v;
582+ return $v;
583+ }
584+
585+ ///////////////
586+ // 接続・認証
587+
588+ /** SSLRequestを送って、TLSを開始する */
589+ private function startTls(): void {
590+ $this->send(null, pack('NN', 8, self::SSL_REQUEST));
591+ $answer = $this->readBytes(1);
592+ if ($answer !== 'S') $this->fail('サーバーがTLSに対応していません');
593+
594+ $opts = is_array($this->tls) ? $this->tls : [];
595+ $opts += ['peer_name' => $this->host];
596+ try {
597+ $this->socket = Tls::enable($this->raw, $opts);
598+ } catch (TlsException $e) {
599+ $this->fail('TLSの開始に失敗: '.$e->getMessage());
600+ }
601+ $this->secure = true;
602+ }
603+
604+ /** StartupMessageと認証、ReadyForQueryまで */
605+ private function startup(): void {
606+ $params = ['user' => $this->username, 'database' => $this->dbname, 'client_encoding' => $this->charset,
607+ 'application_name' => 'LittleBeast', 'DateStyle' => 'ISO, YMD'];
608+ $body = pack('N', self::PROTOCOL);
609+ foreach ($params as $k => $v) {
610+ if ($v !== '') $body .= $k."\0".$v."\0";
611+ }
612+ $this->send(null, pack('N', strlen($body) + 5).$body."\0");
613+
614+ $scram = null;
615+ while (true) {
616+ [$type, $body] = $this->readMessage();
617+ if ($type === 'E') $this->failError('接続に失敗', $this->parseError($body));
618+ if ($type === 'Z') {
619+ $this->status = $body;
620+ return;
621+ }
622+ if ($type !== 'R') {
623+ $this->other($type, $body);
624+ continue;
625+ }
626+
627+ $code = unpack('N', $body)[1];
628+ switch ($code) {
629+ case 0: // AuthenticationOk
630+ if ($scram !== null && empty($scram['verified'])) $this->fail('認証に失敗: サーバーがSCRAMを完了しませんでした');
631+ if ($this->authMethod === '') $this->authMethod = 'trust';
632+ break;
633+ case 3: // パスワードをそのまま
634+ if (!$this->secure && !$this->allowCleartext) {
635+ $this->fail('認証に失敗: サーバーがパスワードをそのまま送る事を求めました(TLS・UNIXソケットでない場合は、allow_cleartextが必要)');
636+ }
637+ $this->authMethod = 'password';
638+ $this->send('p', $this->password."\0");
639+ break;
640+ case 5: // MD5
641+ $this->authMethod = 'md5';
642+ $this->send('p', 'md5'.md5(md5($this->password.$this->username).substr($body, 4, 4))."\0");
643+ break;
644+ case 10: // SASL
645+ $scram = $this->scramStart(array_filter(explode("\0", substr($body, 4))));
646+ break;
647+ case 11: // SASLContinue
648+ if ($scram === null) $this->fail('認証に失敗: 想定外のSASLContinue');
649+ $scram = $this->scramContinue($scram, substr($body, 4));
650+ break;
651+ case 12: // SASLFinal
652+ if ($scram === null) $this->fail('認証に失敗: 想定外のSASLFinal');
653+ $this->scramFinal($scram, substr($body, 4));
654+ $scram['verified'] = true;
655+ break;
656+ default:
657+ $this->fail("未対応の認証方式: {$code}(GSSAPI・SSPI・Kerberosは未対応)");
658+ }
659+ }
660+ }
661+
662+ /** SCRAM-SHA-256(-PLUS)を始める(RFC 5802、RFC 7677) */
663+ private function scramStart(array $mechanisms): array {
664+ $cbData = $this->secure && $this->socketPath === '' && $this->channelBinding !== 'disable' ? $this->channelBindingData() : null;
665+ if (in_array('SCRAM-SHA-256-PLUS', $mechanisms, true) && $cbData !== null) {
666+ $mech = 'SCRAM-SHA-256-PLUS';
667+ $gs2 = 'p=tls-server-end-point,,';
668+ } else if (in_array('SCRAM-SHA-256', $mechanisms, true)) {
669+ if ($this->channelBinding === 'require') $this->fail('認証に失敗: チャネルバインディングが使えません(channel_binding = require)');
670+ $mech = 'SCRAM-SHA-256';
671+ // TLSなのにサーバーが-PLUSを出さない=クライアントは対応している(ダウングレードを見つける為の「y」)
672+ $gs2 = $cbData !== null ? 'y,,' : 'n,,';
673+ $cbData = null;
674+ } else {
675+ $this->fail('未対応のSASLの方式: '.implode(', ', $mechanisms));
676+ }
677+
678+ $nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
679+ $first = 'n=,r='.$nonce; // ユーザー名はStartupMessageの物を使う
680+ $this->authMethod = $mech;
681+ $this->send('p', $mech."\0".pack('N', strlen($gs2.$first)).$gs2.$first);
682+ return ['nonce' => $nonce, 'first' => $first, 'gs2' => $gs2, 'cb' => $cbData];
683+ }
684+
685+ private function scramContinue(array $scram, string $serverFirst): array {
686+ $a = [];
687+ foreach (explode(',', $serverFirst) as $kv) {
688+ if (strlen($kv) > 2 && $kv[1] === '=') $a[$kv[0]] = substr($kv, 2);
689+ }
690+ $salt = base64_decode($a['s'] ?? '', true);
691+ $iter = (int)($a['i'] ?? 0);
692+ if (!isset($a['r']) || !str_starts_with($a['r'], $scram['nonce']) || strlen($a['r']) <= strlen($scram['nonce']) || $salt === false || $iter < 1) {
693+ $this->fail('認証に失敗: 不正なSCRAMのメッセージ');
694+ }
695+
696+ $salted = hash_pbkdf2('sha256', self::saslPrep($this->password), $salt, $iter, 32, true);
697+ $clientKey = hash_hmac('sha256', 'Client Key', $salted, true);
698+ $finalBare = 'c='.base64_encode($scram['gs2'].($scram['cb'] ?? '')).',r='.$a['r'];
699+ $authMessage = $scram['first'].','.$serverFirst.','.$finalBare;
700+ $proof = $clientKey ^ hash_hmac('sha256', $authMessage, hash('sha256', $clientKey, true), true);
701+ $this->send('p', $finalBare.',p='.base64_encode($proof));
702+
703+ return $scram + ['salted' => $salted, 'auth' => $authMessage];
704+ }
705+
706+ /** サーバーの署名を確認する(偽物のサーバーではない事) */
707+ private function scramFinal(array $scram, string $serverFinal): void {
708+ if (str_starts_with($serverFinal, 'e=')) $this->fail('認証に失敗: '.substr($serverFinal, 2));
709+ $expected = hash_hmac('sha256', $scram['auth'], hash_hmac('sha256', 'Server Key', $scram['salted'], true), true);
710+ if (!str_starts_with($serverFinal, 'v=') || !hash_equals($expected, (string)base64_decode(substr($serverFinal, 2), true))) {
711+ $this->fail('認証に失敗: サーバーの署名が不正です');
712+ }
713+ }
714+
715+ /** tls-server-end-point(RFC 5929):サーバーの証明書のハッシュ(MD5・SHA-1ならSHA-256) */
716+ private function channelBindingData(): ?string {
717+ $cert = Tls::info($this->socket)['peer_certificates'][0] ?? null;
718+ if ($cert === null) return null;
719+ $hash = match ($cert->sigAlg) {
720+ '1.2.840.113549.1.1.12', '1.2.840.10045.4.3.3' => 'sha384',
721+ '1.2.840.113549.1.1.13', '1.2.840.10045.4.3.4' => 'sha512',
722+ default => 'sha256',
723+ };
724+ return hash($hash, $cert->der, true);
725+ }
726+
727+ /**
728+ * パスワードの正規化(SASLprep、RFC 4013の簡略版)
729+ *
730+ * intlのNormalizerが有ればNFKC、無ければそのまま(ASCIIと普通の日本語は同じ)。
731+ */
732+ private static function saslPrep(string $pw): string {
733+ if (preg_match('/^[\x20-\x7E]*$/', $pw) || !mb_check_encoding($pw, 'UTF-8')) return $pw;
734+ // 色々なスペースはスペース、ソフトハイフン等は消す
735+ $pw = preg_replace(['/[\x{00A0}\x{1680}\x{2000}-\x{200B}\x{202F}\x{205F}\x{3000}]/u', '/[\x{00AD}\x{034F}\x{1806}\x{180B}-\x{180D}\x{200C}\x{200D}\x{2060}\x{FE00}-\x{FE0F}\x{FEFF}]/u'], [' ', ''], $pw);
736+ return class_exists('Normalizer') ? (\Normalizer::normalize($pw, \Normalizer::FORM_KC) ?: $pw) : $pw;
737+ }
738+
739+ ///////////////
740+ // 結果
741+
742+ /**
743+ * ReadyForQueryまでの結果を集める
744+ *
745+ * @param string $what 失敗した時のメッセージ
746+ * @return array
747+ * @throws DbException
748+ */
749+ private function collect(string $what): array {
750+ $results = [];
751+ $cur = ['fields' => [], 'rows' => []];
752+ $error = null;
753+
754+ while (true) {
755+ [$type, $body] = $this->readMessage();
756+ switch ($type) {
757+ case 'T': // RowDescription
758+ $cur = ['fields' => $this->parseRowDescription($body), 'rows' => []];
759+ break;
760+ case 'D': // DataRow
761+ $cur['rows'][] = $this->parseDataRow($body, $cur['fields']);
762+ break;
763+ case 'C': // CommandComplete
764+ $tag = rtrim($body, "\0");
765+ $results[] = $cur + ['command' => $tag, 'affectedRows' => $this->affected($tag)];
766+ $cur = ['fields' => [], 'rows' => []];
767+ break;
768+ case 'I': // EmptyQueryResponse
769+ $results[] = ['fields' => [], 'rows' => [], 'command' => '', 'affectedRows' => 0];
770+ break;
771+ case 's': // PortalSuspended
772+ $results[] = $cur + ['command' => '', 'affectedRows' => 0];
773+ break;
774+ case '2': // BindComplete
775+ case 'n': // NoData(行を返さない文)
776+ break;
777+ case 'E':
778+ $error = $this->parseError($body);
779+ if ($this->fatal($error)) $this->failError($what, $error);
780+ break;
781+ case 'G': // CopyInResponse:断る
782+ $this->send('f', "COPYは未対応です\0");
783+ break;
784+ case 'H': // CopyOutResponse:データは無視する
785+ case 'd':
786+ case 'c':
787+ break;
788+ case 'Z':
789+ $this->status = $body;
790+ if ($error) $this->failError($what, $error);
791+ if (empty($results)) $results[] = ['fields' => [], 'rows' => [], 'command' => '', 'affectedRows' => 0];
792+ $warnings = count($this->notices);
793+ foreach ($results as &$r) $r += ['insertId' => 0, 'warningCount' => $warnings];
794+ unset($r);
795+ $first = array_shift($results);
796+ if (!empty($results)) $first['more'] = $results;
797+ return $first;
798+ default:
799+ $this->other($type, $body);
800+ }
801+ }
802+ }
803+
804+ /** 「INSERT 0 5」「UPDATE 3」等から行数 */
805+ private function affected(string $tag): int {
806+ return preg_match('/^(?:INSERT \d+|UPDATE|DELETE|SELECT|MOVE|FETCH|COPY|MERGE) (\d+)$/', $tag, $m) ? (int)$m[1] : 0;
807+ }
808+
809+ private function parseRowDescription(string $b): array {
810+ $n = unpack('n', $b)[1];
811+ $pos = 2;
812+ $fields = [];
813+ for ($i = 0; $i < $n; $i++) {
814+ $end = strpos($b, "\0", $pos);
815+ $name = substr($b, $pos, $end - $pos);
816+ $f = unpack('Ntable/ncolumn/Ntype/nsize/Nmodifier/nformat', $b, $end + 1);
817+ $pos = $end + 19;
818+ $fields[] = ['name' => $name, 'table' => $f['table'], 'column' => $f['column'], 'type' => $f['type'],
819+ 'size' => $f['size'] >= 0x8000 ? $f['size'] - 0x10000 : $f['size'],
820+ 'modifier' => $f['modifier'] >= 0x80000000 ? $f['modifier'] - 0x100000000 : $f['modifier'], 'format' => $f['format']];
821+ }
822+ return $fields;
823+ }
824+
825+ private function parseDataRow(string $b, array $fields): array {
826+ $n = unpack('n', $b)[1];
827+ $pos = 2;
828+ $row = [];
829+ for ($i = 0; $i < $n; $i++) {
830+ $len = unpack('N', $b, $pos)[1];
831+ $pos += 4;
832+ $f = $fields[$i] ?? ['name' => (string)$i, 'type' => self::OID_TEXT];
833+ if ($len === 0xFFFFFFFF) {
834+ $row[$f['name']] = null;
835+ continue;
836+ }
837+ $row[$f['name']] = self::castText(substr($b, $pos, $len), $f['type']);
838+ $pos += $len;
839+ }
840+ return $row;
841+ }
842+
843+ /**
844+ * パラメータを送る形にする
845+ *
846+ * @return array [形式(0=テキスト、1=バイナリ), データ(nullならNULL)]
847+ */
848+ private function encodeParam(mixed $v, int $oid, int $i): array {
849+ if ($v instanceof \BackedEnum) $v = $v->value;
850+ if ($v instanceof \DateTimeInterface) $v = $v->format('Y-m-d H:i:s.uP');
851+ if ($v === null) return [0, null];
852+ if (is_bool($v)) {
853+ $int = in_array($oid, [self::OID_INT2, self::OID_INT4, self::OID_INT8], true);
854+ return [0, $int ? ($v ? '1' : '0') : ($v ? 't' : 'f')];
855+ }
856+ if (is_int($v)) return [0, (string)$v];
857+ if (is_float($v)) {
858+ if (is_nan($v)) return [0, 'NaN'];
859+ if (is_infinite($v)) return [0, $v > 0 ? 'Infinity' : '-Infinity'];
860+ return [0, var_export($v, true)];
861+ }
862+ if (is_scalar($v) || $v instanceof \Stringable) {
863+ // byteaには生のバイト(バイナリ形式)を送る(テキストだと「\」が特別な意味になる)
864+ return $oid === self::OID_BYTEA ? [1, (string)$v] : [0, (string)$v];
865+ }
866+ $this->fail("パラメータ{$i}の型は未対応です: ".get_debug_type($v));
867+ }
868+
869+ ///////////////
870+ // メッセージ
871+
872+ /** 接続の処理中に来る、結果と関係の無いメッセージ */
873+ private function other(string $type, string $body): void {
874+ switch ($type) {
875+ case 'S': // ParameterStatus
876+ [$k, $v] = explode("\0", $body) + ['', ''];
877+ $this->params[$k] = $v;
878+ return;
879+ case 'K': // BackendKeyData
880+ $this->pid = unpack('N', $body)[1];
881+ return;
882+ case 'N': // NoticeResponse
883+ $e = $this->parseError($body);
884+ $this->notices[] = ['severity' => $e['V'] ?? $e['S'] ?? '', 'code' => $e['C'] ?? '', 'message' => $e['M'] ?? ''];
885+ return;
886+ case 'A': // NotificationResponse(LISTEN):無視する
887+ return;
888+ }
889+ $this->fail("想定外のメッセージ: {$type}");
890+ }
891+
892+ /** ErrorResponse・NoticeResponseのフィールド(S、V、C、M、D、H等) */
893+ private function parseError(string $b): array {
894+ $out = [];
895+ foreach (explode("\0", rtrim($b, "\0")) as $f) {
896+ if ($f !== '') $out[$f[0]] = substr($f, 1);
897+ }
898+ return $out;
899+ }
900+
901+ /** FATAL・PANICのエラーの後はサーバーが接続を切る */
902+ private function fatal(array $e): bool {
903+ return in_array($e['V'] ?? $e['S'] ?? '', ['FATAL', 'PANIC'], true);
904+ }
905+
906+ /**
907+ * サーバーのエラーで例外を投げる
908+ *
909+ * @return never
910+ */
911+ private function failError(string $what, array $e): never {
912+ if ($this->fatal($e)) $this->drop();
913+ $code = $e['C'] ?? '';
914+ $msg = "{$what}: {$code} - ".($e['M'] ?? '不明なエラー');
915+ if (!empty($e['D'])) $msg .= "({$e['D']})";
916+ $this->fail($msg, 0, $code);
917+ }
918+
919+ private function readMessage(): array {
920+ $head = $this->readBytes(5);
921+ $type = $head[0];
922+ $len = unpack('N', $head, 1)[1];
923+ if ($len < 4 || $len > 0x40000000) {
924+ $this->drop();
925+ $this->fail("不正なメッセージの長さ: {$len}");
926+ }
927+ $body = $len > 4 ? $this->readBytes($len - 4) : '';
928+ if ($this->debug) $this->log('RECV', $type, $body);
929+ return [$type, $body];
930+ }
931+
932+ private function readBytes(int $len): string {
933+ if (!$this->socket) $this->fail('接続していません');
934+ if ($this->out !== '') $this->flush();
935+ $buf = '';
936+ while (strlen($buf) < $len) {
937+ $chunk = @fread($this->socket, min($len - strlen($buf), 1048576));
938+ if ($chunk === false || $chunk === '') {
939+ $timedOut = Tls::timedOut($this->socket) || (bool)(stream_get_meta_data($this->raw ?? $this->socket)['timed_out'] ?? false);
940+ if (!$timedOut && $chunk === '' && !feof($this->socket)) continue;
941+ $this->drop();
942+ $this->fail($timedOut ? "メッセージの読み込みがタイムアウト({$this->timeout}秒)" : 'サーバーとの接続が切れました');
943+ }
944+ $buf .= $chunk;
945+ }
946+ return $buf;
947+ }
948+
949+ /**
950+ * メッセージを送る(バッファに溜めて、次に読む時にまとめて送る)
951+ *
952+ * Parse・Bind・Execute・Syncを別々に送ると、Nagleと遅延ACKで1回40ms程待たされる為。
953+ *
954+ * @param string|null $type 種類(StartupMessage・SSLRequestはnull)
955+ * @param string $body 中身
956+ */
957+ private function send(?string $type, string $body): void {
958+ if (!$this->socket) $this->fail('接続していません');
959+ $this->out .= $type === null ? $body : $type.pack('N', strlen($body) + 4).$body;
960+ if ($this->debug) $this->log('SEND', $type ?? '-', $body);
961+ }
962+
963+ /** 溜めたメッセージを送る */
964+ private function flush(): void {
965+ $data = $this->out;
966+ $this->out = '';
967+ for ($w = 0, $len = strlen($data); $w < $len; ) {
968+ $n = @fwrite($this->socket, substr($data, $w, 1048576));
969+ if ($n === false || $n === 0) {
970+ $this->drop();
971+ $this->fail('メッセージの送信に失敗');
972+ }
973+ $w += $n;
974+ }
975+ }
976+
977+ private function log(string $dir, string $type, string $body): void {
978+ $this->packetLog[] = ['direction' => $dir, 'type' => $type, 'length' => strlen($body), 'data' => $body, 'timestamp' => microtime(true)];
979+ $show = preg_replace('/[^\x20-\x7E]/', '.', substr($body, 0, 200));
980+ echo "=== {$dir} {$type} (長さ: ".strlen($body).") === {$show}\n";
981+ }
982+
983+ private function requireConnection(): void {
984+ if (!$this->connected) $this->fail('接続していません(先にconnect()を呼んで下さい)');
985+ }
986+
987+ private function drop(): void {
988+ if ($this->socket) @fclose($this->socket);
989+ if ($this->raw && is_resource($this->raw)) @fclose($this->raw);
990+ $this->socket = $this->raw = null;
991+ $this->out = '';
992+ $this->connected = false;
993+ $this->secure = false;
994+ $this->status = '';
995+ $this->prepared = [];
996+ }
997+
998+ /**
999+ * @return never
1000+ * @throws DbException
1001+ */
1002+ private function fail(string $msg, int $code = 0, string $sqlState = ''): never {
1003+ if (function_exists('logger')) logger(\LogType::Db, $msg);
1004+ throw new DbException($msg, $code, $sqlState);
1005+ }
1006+}
@@ -0,0 +1,983 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+// テスト用の偽物のPostgreSQLサーバー(MockServerが起動する)。
39+// 使い方:php pg-server.php ポート [証明書のPEM] [秘密鍵のPEM] [オプション(カンマ区切り)]
40+// SQLはメモリーのSQLiteで実行し、結果をPostgreSQLのプロトコル3.0で返す(pdo_sqliteが必要)。
41+// 証明書と秘密鍵を渡すと、TLS(SSLRequest)とSCRAM-SHA-256-PLUS(tls-server-end-point)に対応する。
42+// オプション:nocb(TLSでもSCRAM-SHA-256-PLUSを出さない)、badsig(SCRAMのサーバーの署名を間違える)、
43+// badnonce(SCRAMのnonceを変える)、nofinal(SCRAMのSASLFinalを送らずにAuthenticationOk)
44+//
45+// ユーザー(パスワード、認証):
46+// lb(ひみつ、SCRAM-SHA-256)、md5user(md5ひみつ、MD5)、clear(clearひみつ、パスワードをそのまま)、
47+// trust(なし)、tlsonly(tlsひみつ、SCRAM-SHA-256、TLSのみ)、gss(GSSAPI=未対応)
48+// データベース:「nodb」以外は何でも存在する事にする
49+// 偽物なので、Describe(ステートメント)は列を返さない(NoData)。列はポータルのDescribeで返す。
50+// 「$1::型」・INSERTの列・「列 = $1」からパラメータの型を決める(無ければtext)。
51+// 特別なSQL:
52+// SELECT lb_disconnect() 返事をせずに接続を切る
53+// SELECT lb_fatal() FATALのエラーを送って切る
54+// SELECT lb_sleep(秒) 返事を遅らせる
55+// SELECT lb_notice() NOTICEとWARNINGを送る
56+// SELECT lb_async() 結果の途中にNotificationResponseとParameterStatusを送る
57+// SELECT lb_types() 色々な型の列(本物のPostgreSQL 16の出力と同じ)
58+// lb_tls()・lb_auth()・lb_cb()(チャネルバインディングの種類)・current_user・current_database()・
59+// pg_backend_pid()・version()
60+// COPY … FROM STDIN、COPY … TO STDOUT
61+
62+require_once __DIR__.'/../../../../autoload.php';
63+
64+use Std\Lib\Crypto\X509;
65+use Std\Lib\Tls;
66+
67+$port = (int)($argv[1] ?? 0);
68+$certFile = $argv[2] ?? '';
69+$keyFile = $argv[3] ?? '';
70+$options = array_filter(explode(',', $argv[4] ?? ''));
71+
72+if (!class_exists('PDO') || !in_array('sqlite', PDO::getAvailableDrivers(), true)) {
73+ fwrite(STDOUT, "error pdo_sqlite\n");
74+ exit(1);
75+}
76+
77+$server = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr);
78+if (!$server) {
79+ fwrite(STDOUT, "error {$errstr}\n");
80+ exit(1);
81+}
82+
83+$pg = new MockPgsql($certFile, $keyFile, $options);
84+fwrite(STDOUT, "ready\n");
85+fflush(STDOUT);
86+$pg->run($server);
87+
88+/** エラーを返す為の例外 */
89+class PgError extends \Exception {
90+ public function __construct(public string $sqlState, string $message, public string $detail = '', public string $severity = 'ERROR') {
91+ parent::__construct($message);
92+ }
93+}
94+
95+class MockPgsql {
96+ private const VERSION = '16.0 (lbmock)';
97+
98+ private const USERS = [
99+ 'lb' => ['ひみつ', 'scram'],
100+ 'md5user' => ['md5ひみつ', 'md5'],
101+ 'clear' => ['clearひみつ', 'password'],
102+ 'trust' => ['', 'trust'],
103+ 'tlsonly' => ['tlsひみつ', 'scram'],
104+ 'gss' => ['', 'gss'],
105+ ];
106+
107+ // SQLiteの宣言された型 => OID
108+ private const DECL = [
109+ 'BOOL' => 16, 'BYTEA' => 17, 'BIGINT' => 20, 'INT8' => 20, 'BIGSERIAL' => 20, 'SMALLINT' => 21, 'INT2' => 21,
110+ 'INTEGER' => 23, 'INT' => 23, 'INT4' => 23, 'SERIAL' => 23, 'TEXT' => 25, 'JSON' => 114, 'REAL' => 700, 'FLOAT4' => 700,
111+ 'DOUBLE' => 701, 'FLOAT8' => 701, 'FLOAT' => 701, 'VARCHAR' => 1043, 'DATE' => 1082, 'TIMESTAMP' => 1114,
112+ 'TIMESTAMPTZ' => 1184, 'NUMERIC' => 1700, 'DECIMAL' => 1700, 'UUID' => 2950, 'JSONB' => 3802,
113+ ];
114+
115+ // 本物のPostgreSQL 16(TimeZone = Asia/Tokyo)の出力(escはbytea_output = escapeの場合、big8はint8に入らない値)
116+ private const TYPES = [
117+ 'i2' => [21, '-5'], 'i4' => [23, '42'], 'i8' => [20, '9223372036854775807'], 'num' => [1700, '12345678901234567890.5'],
118+ 'f4' => [700, '2.5'], 'nan' => [701, 'NaN'], 'ninf' => [701, '-Infinity'], 'big' => [701, '1e+100'],
119+ 't' => [16, 't'], 'f' => [16, 'f'], 'bin' => [17, '\\x00ff5c'], 'txts' => [1009, '{"a,b","c\\"d",NULL,"NULL",""," x "}'],
120+ 'ints' => [1007, '{{1,2},{3,NULL}}'], 'ranged' => [1007, '[0:1]={1,2}'], 'bools' => [1000, '{t,f}'],
121+ 'bins' => [1001, '{"\\\\x01"}'], 'floats' => [1022, '{1.5,NaN}'], 'j' => [114, '{"a": [1, "x"]}'], 'jb' => [3802, '{"b": 2}'],
122+ 'd' => [1082, '2026-01-02'], 'ts' => [1184, '2026-01-02 03:04:05.123456+09'], 'u' => [2950, 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'],
123+ 'o' => [26, '1259'], 'n' => [25, null], 'v' => [1043, '日本語'], 'i8s' => [20, '2147483648'], 'empty' => [1009, '{}'],
124+ 'esc' => [17, '\\000\\377\\\\A'], 'big8' => [20, '99999999999999999999'],
125+ ];
126+
127+ private \PDO $pdo;
128+ private array $conns = [];
129+ private int $nextId = 1000;
130+ private string $certDer = '';
131+ private string $certSigAlg = '';
132+
133+ public function __construct(private string $certFile, private string $keyFile, private array $options) {
134+ $this->pdo = new \PDO('sqlite::memory:');
135+ $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
136+ if ($certFile !== '') {
137+ $cert = X509::fromPem((string)file_get_contents($certFile));
138+ $this->certDer = $cert->der;
139+ $this->certSigAlg = $cert->sigAlg;
140+ }
141+ }
142+
143+ public function run($server): void {
144+ while (true) {
145+ // TLSで既に復号されたデータが残っている接続を先に
146+ $pending = [];
147+ foreach ($this->conns as $id => $cn) {
148+ if ($cn['tls'] && Tls::pending($cn['sock'])) $pending[] = $id;
149+ }
150+
151+ if (empty($pending)) {
152+ $r = [$server];
153+ foreach ($this->conns as $cn) $r[] = $cn['sock'];
154+ $w = $e = null;
155+ $n = @stream_select($r, $w, $e, 120);
156+ if ($n === false || $n === 0) return;
157+
158+ foreach ($r as $s) {
159+ if ($s === $server) {
160+ $c = @stream_socket_accept($server, 5);
161+ if ($c) $this->accept($c);
162+ continue;
163+ }
164+ foreach ($this->conns as $id => $cn) {
165+ if ($cn['sock'] === $s) $pending[] = $id;
166+ }
167+ }
168+ }
169+
170+ foreach ($pending as $id) {
171+ if (!isset($this->conns[$id])) continue;
172+ try {
173+ $res = $this->handle($id);
174+ } catch (PgError $e) {
175+ // 認証中のエラー等(FATAL)
176+ $this->error($id, $e);
177+ $res = false;
178+ }
179+ if ($res === 'quit') return;
180+ if ($res === false) $this->drop($id);
181+ }
182+ }
183+ }
184+
185+ ///////////////
186+ // 接続・認証
187+
188+ private function accept($c): void {
189+ stream_set_blocking($c, true);
190+ stream_set_timeout($c, 30);
191+ // 読み込みのバッファを無くす(SSLRequestと一緒にTLSのClientHelloまで読んでしまうと、TLSを開始出来ない)
192+ stream_set_read_buffer($c, 0);
193+ $id = $this->nextId++;
194+ $this->conns[$id] = [
195+ 'sock' => $c, 'id' => $id, 'state' => 'new', 'tls' => false, 'user' => '', 'db' => '', 'params' => [],
196+ 'auth' => '', 'cb' => '', 'scram' => null, 'salt' => '', 'txn' => 'I', 'skip' => false, 'stmts' => [], 'portals' => [],
197+ ];
198+ }
199+
200+ private function drop(int $id): void {
201+ // 切れた接続のトランザクションはロールバック(SQLiteは1つなので)
202+ if ($this->conns[$id]['txn'] !== 'I' && $this->pdo->inTransaction()) $this->pdo->rollBack();
203+ @fclose($this->conns[$id]['sock']);
204+ unset($this->conns[$id]);
205+ }
206+
207+ /**
208+ * 1つのメッセージを処理する
209+ *
210+ * @return bool|string false=接続を切る、'quit'=サーバーを止める
211+ */
212+ private function handle(int $id): bool|string {
213+ $cn = &$this->conns[$id];
214+
215+ if ($cn['state'] === 'new') {
216+ $head = $this->readRaw($id, 4);
217+ if ($head === null) return false;
218+ // MockServer::stop()の終了のリクエスト
219+ if ($head === 'QUIT') return 'quit';
220+ $len = unpack('N', $head)[1];
221+ if ($len < 8 || $len > 10000) return false;
222+ $body = $this->readRaw($id, $len - 4);
223+ if ($body === null) return false;
224+ return $this->startup($id, $body);
225+ }
226+
227+ $msg = $this->recv($id);
228+ if ($msg === null) return false;
229+ [$type, $body] = $msg;
230+ if ($type === 'X') return false;
231+
232+ if ($cn['state'] === 'auth') return $this->authMessage($id, $type, $body);
233+ return $this->message($id, $type, $body);
234+ }
235+
236+ private function startup(int $id, string $body): bool {
237+ $cn = &$this->conns[$id];
238+ $code = unpack('N', $body)[1];
239+
240+ if ($code === 80877103) { // SSLRequest
241+ if ($cn['tls'] || $this->certFile === '') {
242+ $this->raw($id, 'N');
243+ return true;
244+ }
245+ $this->raw($id, 'S');
246+ try {
247+ $cn['sock'] = Tls::accept($cn['sock'], ['cert' => $this->certFile, 'key' => $this->keyFile]);
248+ } catch (\Throwable $e) {
249+ return false;
250+ }
251+ $cn['tls'] = true;
252+ return true;
253+ }
254+ if ($code === 80877104) { // GSSENCRequest
255+ $this->raw($id, 'N');
256+ return true;
257+ }
258+ if ($code === 80877102) return false; // CancelRequest
259+ if ($code !== 196608) throw new PgError('0A000', 'unsupported frontend protocol '.($code >> 16).'.'.($code & 0xFFFF), '', 'FATAL');
260+
261+ $kv = explode("\0", substr($body, 4));
262+ for ($i = 0; $i + 1 < count($kv) && $kv[$i] !== ''; $i += 2) $cn['params'][$kv[$i]] = $kv[$i + 1];
263+ $user = $cn['params']['user'] ?? '';
264+ if ($user === '') throw new PgError('28000', 'no PostgreSQL user name specified in startup packet', '', 'FATAL');
265+ $cn['user'] = $user;
266+ $cn['db'] = $cn['params']['database'] ?? $user;
267+
268+ [$pw, $method] = self::USERS[$user] ?? ['', 'scram'];
269+ if ($user === 'tlsonly' && !$cn['tls']) {
270+ throw new PgError('28000', "no pg_hba.conf entry for host \"127.0.0.1\", user \"{$user}\", database \"{$cn['db']}\", no encryption", '', 'FATAL');
271+ }
272+ $cn['auth'] = $method;
273+ $cn['state'] = 'auth';
274+
275+ switch ($method) {
276+ case 'trust':
277+ $this->ready($id);
278+ return true;
279+ case 'password':
280+ $this->send($id, 'R', pack('N', 3));
281+ return true;
282+ case 'md5':
283+ $cn['salt'] = random_bytes(4);
284+ $this->send($id, 'R', pack('N', 5).$cn['salt']);
285+ return true;
286+ case 'gss':
287+ $this->send($id, 'R', pack('N', 7));
288+ return true;
289+ }
290+ $plus = $cn['tls'] && !in_array('nocb', $this->options, true);
291+ $this->send($id, 'R', pack('N', 10).($plus ? "SCRAM-SHA-256-PLUS\0" : '')."SCRAM-SHA-256\0\0");
292+ return true;
293+ }
294+
295+ /** 認証中のメッセージ(PasswordMessage、SASLInitialResponse、SASLResponse) */
296+ private function authMessage(int $id, string $type, string $body): bool {
297+ $cn = &$this->conns[$id];
298+ if ($type !== 'p') throw new PgError('08P01', "invalid frontend message type {$type}", '', 'FATAL');
299+ $user = $cn['user'];
300+ $pw = self::USERS[$user][0] ?? null;
301+ $failed = new PgError('28P01', "password authentication failed for user \"{$user}\"", '', 'FATAL');
302+
303+ if ($cn['auth'] === 'password') {
304+ if ($pw === null || rtrim($body, "\0") !== $pw) throw $failed;
305+ $this->ready($id);
306+ return true;
307+ }
308+ if ($cn['auth'] === 'md5') {
309+ if ($pw === null || rtrim($body, "\0") !== 'md5'.md5(md5($pw.$user).$cn['salt'])) throw $failed;
310+ $this->ready($id);
311+ return true;
312+ }
313+
314+ if ($cn['scram'] === null) {
315+ // SASLInitialResponse
316+ $end = strpos($body, "\0");
317+ $mech = substr($body, 0, $end);
318+ $len = unpack('N', $body, $end + 1)[1];
319+ $data = substr($body, $end + 5, $len);
320+ $plusOffered = $cn['tls'] && !in_array('nocb', $this->options, true);
321+ if (!preg_match('/^([pny])(?:=([\w-]+))?,,(n=[^,]*,r=([^,]+))$/', $data, $m)) {
322+ throw new PgError('08P01', 'malformed SCRAM message', '', 'FATAL');
323+ }
324+ [, $flag, $cbType, $first, $nonce] = $m + ['', '', '', '', ''];
325+ if ($mech === 'SCRAM-SHA-256-PLUS') {
326+ if (!$plusOffered || $flag !== 'p' || $cbType !== 'tls-server-end-point') {
327+ throw new PgError('08P01', 'unsupported SCRAM channel-binding type', '', 'FATAL');
328+ }
329+ } else if ($mech === 'SCRAM-SHA-256') {
330+ if ($flag === 'p') throw new PgError('08P01', 'channel binding not supported with SCRAM-SHA-256', '', 'FATAL');
331+ if ($flag === 'y' && $plusOffered) {
332+ throw new PgError('08P01', 'SCRAM channel binding negotiation error', 'The client supports SCRAM channel binding but thinks the server does not. However, this server does support channel binding.', 'FATAL');
333+ }
334+ } else {
335+ throw new PgError('08P01', 'client selected an invalid SASL authentication mechanism', '', 'FATAL');
336+ }
337+ $cn['cb'] = $flag === 'p' ? $cbType : ($flag === 'y' ? 'y' : 'none');
338+ $salt = random_bytes(16);
339+ $snonce = $nonce.rtrim(base64_encode(random_bytes(18)), '=');
340+ if (in_array('badnonce', $this->options, true)) $snonce = 'x'.substr($snonce, 1);
341+ $serverFirst = 'r='.$snonce.',s='.base64_encode($salt).',i=4096';
342+ $cn['scram'] = ['gs2' => substr($data, 0, strlen($data) - strlen($first)), 'first' => $first, 'nonce' => $snonce,
343+ 'server' => $serverFirst, 'salt' => $salt];
344+ $this->send($id, 'R', pack('N', 11).$serverFirst);
345+ return true;
346+ }
347+
348+ // SASLResponse(client-final-message)
349+ $s = $cn['scram'];
350+ if (!preg_match('/^c=([^,]*),r=([^,]*),p=([^,]*)$/', $body, $m)) throw new PgError('08P01', 'malformed SCRAM message', '', 'FATAL');
351+ $cbData = $cn['cb'] === 'tls-server-end-point' ? $this->channelBinding() : '';
352+ if (base64_decode($m[1], true) !== $s['gs2'].$cbData) {
353+ throw new PgError('08P01', 'SCRAM channel binding check failed', '', 'FATAL');
354+ }
355+ if ($m[2] !== $s['nonce']) throw new PgError('08P01', 'SCRAM nonce mismatch', '', 'FATAL');
356+ if ($pw === null) throw $failed;
357+
358+ $salted = hash_pbkdf2('sha256', $pw, $s['salt'], 4096, 32, true);
359+ $clientKey = hash_hmac('sha256', 'Client Key', $salted, true);
360+ $authMessage = $s['first'].','.$s['server'].',c='.$m[1].',r='.$m[2];
361+ $proof = (string)base64_decode($m[3], true);
362+ $recovered = $proof ^ hash_hmac('sha256', $authMessage, hash('sha256', $clientKey, true), true);
363+ if (strlen($proof) !== 32 || !hash_equals(hash('sha256', $clientKey, true), hash('sha256', $recovered, true))) throw $failed;
364+
365+ $sig = hash_hmac('sha256', $authMessage, hash_hmac('sha256', 'Server Key', $salted, true), true);
366+ if (in_array('badsig', $this->options, true)) $sig = ~$sig;
367+ if (!in_array('nofinal', $this->options, true)) $this->send($id, 'R', pack('N', 12).'v='.base64_encode($sig));
368+ $this->ready($id);
369+ return true;
370+ }
371+
372+ /** サーバーの証明書のハッシュ(RFC 5929) */
373+ private function channelBinding(): string {
374+ $hash = match ($this->certSigAlg) {
375+ '1.2.840.113549.1.1.12', '1.2.840.10045.4.3.3' => 'sha384',
376+ '1.2.840.113549.1.1.13', '1.2.840.10045.4.3.4' => 'sha512',
377+ default => 'sha256',
378+ };
379+ return hash($hash, $this->certDer, true);
380+ }
381+
382+ /** 認証の完了:AuthenticationOk、ParameterStatus、BackendKeyData、ReadyForQuery */
383+ private function ready(int $id): void {
384+ $cn = &$this->conns[$id];
385+ if ($cn['db'] === 'nodb') throw new PgError('3D000', "database \"{$cn['db']}\" does not exist", '', 'FATAL');
386+ $this->send($id, 'R', pack('N', 0));
387+ $status = [
388+ 'application_name' => $cn['params']['application_name'] ?? '', 'client_encoding' => $cn['params']['client_encoding'] ?? 'UTF8',
389+ 'DateStyle' => $cn['params']['DateStyle'] ?? 'ISO, MDY', 'integer_datetimes' => 'on', 'IntervalStyle' => 'postgres',
390+ 'is_superuser' => 'off', 'server_encoding' => 'UTF8', 'server_version' => self::VERSION,
391+ 'session_authorization' => $cn['user'], 'standard_conforming_strings' => 'on', 'TimeZone' => 'Asia/Tokyo',
392+ ];
393+ foreach ($status as $k => $v) $this->send($id, 'S', "{$k}\0{$v}\0");
394+ $this->send($id, 'K', pack('NN', $id, random_int(0, 0x7FFFFFFF)));
395+ $cn['state'] = 'ready';
396+ $this->send($id, 'Z', 'I');
397+ }
398+
399+ ///////////////
400+ // クエリ
401+
402+ private function message(int $id, string $type, string $body): bool {
403+ $cn = &$this->conns[$id];
404+ // 拡張クエリのエラーの後はSyncまで無視する
405+ if ($cn['skip'] && $type !== 'S') return true;
406+
407+ try {
408+ switch ($type) {
409+ case 'Q':
410+ return $this->simple($id, rtrim($body, "\0"));
411+ case 'P':
412+ $this->parse($id, $body);
413+ return true;
414+ case 'B':
415+ $this->bind($id, $body);
416+ return true;
417+ case 'D':
418+ $this->describe($id, $body[0], substr($body, 1, -1));
419+ return true;
420+ case 'E':
421+ $this->executePortal($id, rtrim(substr($body, 0, -4), "\0"));
422+ return true;
423+ case 'C':
424+ if ($body[0] === 'S') {
425+ unset($cn['stmts'][substr($body, 1, -1)]);
426+ } else {
427+ unset($cn['portals'][substr($body, 1, -1)]);
428+ }
429+ $this->send($id, '3', '');
430+ return true;
431+ case 'S':
432+ $cn['skip'] = false;
433+ $cn['portals'] = [];
434+ $this->send($id, 'Z', $cn['txn']);
435+ return true;
436+ case 'H': // Flush
437+ return true;
438+ }
439+ throw new PgError('08P01', "invalid frontend message type {$type}", '', 'FATAL');
440+ } catch (PgError $e) {
441+ if ($e->severity === 'FATAL') throw $e;
442+ $this->error($id, $e);
443+ $cn['skip'] = true;
444+ return true;
445+ }
446+ }
447+
448+ /** 単純クエリ */
449+ private function simple(int $id, string $sql): bool {
450+ $cn = &$this->conns[$id];
451+ $stmts = array_values(array_filter(self::split($sql), fn($s) => trim($s) !== ''));
452+ if (empty($stmts)) {
453+ $this->send($id, 'I', '');
454+ $this->send($id, 'Z', $cn['txn']);
455+ return true;
456+ }
457+
458+ foreach ($stmts as $one) {
459+ try {
460+ $res = $this->statement($id, trim($one), []);
461+ } catch (PgError $e) {
462+ if ($e->severity === 'FATAL') throw $e;
463+ $this->error($id, $e);
464+ break;
465+ }
466+ if ($res === false) return false;
467+ if ($res['cols'] !== null) $this->send($id, 'T', $this->rowDescription($res['cols']));
468+ foreach ($res['rows'] as $row) $this->send($id, 'D', $this->dataRow($row));
469+ $this->send($id, 'C', $res['tag']."\0");
470+ }
471+ $this->send($id, 'Z', $cn['txn']);
472+ return true;
473+ }
474+
475+ private function parse(int $id, string $body): void {
476+ $cn = &$this->conns[$id];
477+ $p = 0;
478+ $name = self::cstr($body, $p);
479+ $sql = self::cstr($body, $p);
480+ $n = unpack('n', $body, $p)[1];
481+ $given = $n > 0 ? array_values(unpack("N{$n}", $body, $p + 2)) : [];
482+
483+ if ($name !== '' && isset($cn['stmts'][$name])) throw new PgError('42P05', "prepared statement \"{$name}\" already exists");
484+ if (count(array_filter(self::split($sql), fn($s) => trim($s) !== '')) > 1) {
485+ throw new PgError('42601', 'cannot insert multiple commands into a prepared statement');
486+ }
487+ $sql = trim(rtrim(trim($sql), ';'));
488+ $types = $this->paramTypes($sql);
489+ foreach ($given as $i => $oid) if ($oid !== 0) $types[$i] = $oid;
490+ // 構文・テーブルの確認(特別なSQLは実行の時に)
491+ if (!preg_match('/^(BEGIN|START|COMMIT|END|ROLLBACK|SET|SHOW|COPY)\b|lb_\w+\(/i', $sql)) {
492+ try {
493+ $this->pdo->prepare($this->rewrite($id, $sql));
494+ } catch (\PDOException $e) {
495+ throw $this->sqlError($e);
496+ }
497+ }
498+ $cn['stmts'][$name] = ['sql' => $sql, 'types' => $types];
499+ $this->send($id, '1', '');
500+ }
501+
502+ private function bind(int $id, string $body): void {
503+ $cn = &$this->conns[$id];
504+ $p = 0;
505+ $portal = self::cstr($body, $p);
506+ $name = self::cstr($body, $p);
507+ if (!isset($cn['stmts'][$name])) throw new PgError('26000', "prepared statement \"{$name}\" does not exist");
508+ $stmt = $cn['stmts'][$name];
509+
510+ $nf = unpack('n', $body, $p)[1];
511+ $formats = $nf > 0 ? array_values(unpack("n{$nf}", $body, $p + 2)) : [];
512+ $p += 2 + 2 * $nf;
513+ $np = unpack('n', $body, $p)[1];
514+ $p += 2;
515+ if ($np !== count($stmt['types'])) {
516+ throw new PgError('08P01', "bind message supplies {$np} parameters, but prepared statement \"{$name}\" requires ".count($stmt['types']));
517+ }
518+ $params = [];
519+ for ($i = 0; $i < $np; $i++) {
520+ $len = unpack('N', $body, $p)[1];
521+ $p += 4;
522+ $format = $formats[$nf === 1 ? 0 : $i] ?? 0;
523+ if ($len === 0xFFFFFFFF) {
524+ $params[] = null;
525+ continue;
526+ }
527+ $params[] = $this->decodeParam(substr($body, $p, $len), $format, $stmt['types'][$i]);
528+ $p += $len;
529+ }
530+ $nr = unpack('n', $body, $p)[1];
531+ if ($nr > 0 && in_array(1, array_values(unpack("n{$nr}", $body, $p + 2)), true)) {
532+ throw new PgError('0A000', 'binary result format is not supported by lbmock');
533+ }
534+
535+ $res = $this->statement($id, $stmt['sql'], $params);
536+ if ($res === false) throw new PgError('57P01', 'terminating connection', '', 'FATAL');
537+ $cn['portals'][$portal] = $res;
538+ $this->send($id, '2', '');
539+ }
540+
541+ private function describe(int $id, string $kind, string $name): void {
542+ $cn = &$this->conns[$id];
543+ if ($kind === 'S') {
544+ if (!isset($cn['stmts'][$name])) throw new PgError('26000', "prepared statement \"{$name}\" does not exist");
545+ $types = $cn['stmts'][$name]['types'];
546+ $this->send($id, 't', pack('n', count($types)).(empty($types) ? '' : pack('N*', ...$types)));
547+ $this->send($id, 'n', '');
548+ return;
549+ }
550+ if (!isset($cn['portals'][$name])) throw new PgError('34000', "portal \"{$name}\" does not exist");
551+ $cols = $cn['portals'][$name]['cols'];
552+ $this->send($id, $cols === null ? 'n' : 'T', $cols === null ? '' : $this->rowDescription($cols));
553+ }
554+
555+ private function executePortal(int $id, string $name): void {
556+ $cn = &$this->conns[$id];
557+ if (!isset($cn['portals'][$name])) throw new PgError('34000', "portal \"{$name}\" does not exist");
558+ $res = $cn['portals'][$name];
559+ foreach ($res['rows'] as $row) $this->send($id, 'D', $this->dataRow($row));
560+ $this->send($id, 'C', $res['tag']."\0");
561+ }
562+
563+ /**
564+ * 1つの文を実行する
565+ *
566+ * @return array|false cols(nullなら行なし)、rows(テキストの値)、tag、又は接続を切る場合false
567+ * @throws PgError
568+ */
569+ private function statement(int $id, string $sql, array $params): array|false {
570+ $cn = &$this->conns[$id];
571+ $sql = rtrim(trim($sql), ';');
572+ $none = fn(string $tag): array => ['cols' => null, 'rows' => [], 'tag' => $tag];
573+ $one = fn(string $name, int $oid, ?string $v): array => ['cols' => [[$name, $oid]], 'rows' => [[$v]], 'tag' => 'SELECT 1'];
574+
575+ if (preg_match('/^(BEGIN|START\s+TRANSACTION)$/i', $sql)) {
576+ if ($cn['txn'] !== 'I') {
577+ $this->notice($id, 'WARNING', '25001', 'there is already a transaction in progress');
578+ } else {
579+ $this->pdo->beginTransaction();
580+ $cn['txn'] = 'T';
581+ }
582+ return $none('BEGIN');
583+ }
584+ if (preg_match('/^(COMMIT|END|ROLLBACK)$/i', $sql, $m)) {
585+ if ($cn['txn'] === 'I') {
586+ $this->notice($id, 'WARNING', '25P01', 'there is no transaction in progress');
587+ return $none(strtoupper($m[1]) === 'ROLLBACK' ? 'ROLLBACK' : 'COMMIT');
588+ }
589+ $commit = strtoupper($m[1]) !== 'ROLLBACK' && $cn['txn'] === 'T';
590+ $commit ? $this->pdo->commit() : $this->pdo->rollBack();
591+ $cn['txn'] = 'I';
592+ return $none($commit ? 'COMMIT' : 'ROLLBACK');
593+ }
594+ if ($cn['txn'] === 'E') throw new PgError('25P02', 'current transaction is aborted, commands ignored until end of transaction block');
595+
596+ try {
597+ if (preg_match('/^SET\s+(?:SESSION\s+)?(\w+)\s*(?:=|TO)\s*\'?([^\']*)\'?$/i', $sql, $m)) {
598+ $key = ['application_name' => 'application_name', 'client_encoding' => 'client_encoding', 'datestyle' => 'DateStyle', 'timezone' => 'TimeZone'][strtolower($m[1])] ?? null;
599+ if ($key !== null) {
600+ $cn['params'][$key] = $m[2];
601+ $this->send($id, 'S', "{$key}\0{$m[2]}\0");
602+ }
603+ return $none('SET');
604+ }
605+ if (preg_match('/^SHOW\s+(\w+)$/i', $sql, $m)) {
606+ $v = ['server_version' => self::VERSION, 'client_encoding' => $cn['params']['client_encoding'] ?? 'UTF8'][strtolower($m[1])] ?? '';
607+ return $one(strtolower($m[1]), 25, $v);
608+ }
609+ if (preg_match('/^COPY\s.*\sFROM\s+STDIN/is', $sql)) return $this->copyIn($id);
610+ if (preg_match('/^COPY\s.*\sTO\s+STDOUT/is', $sql)) {
611+ $this->send($id, 'H', "\0".pack('nn', 1, 0));
612+ $this->send($id, 'd', "1\t一\n");
613+ $this->send($id, 'c', '');
614+ return $none('COPY 1');
615+ }
616+ if (preg_match('/^SELECT\s+lb_disconnect\(\)$/i', $sql)) return false;
617+ if (preg_match('/^SELECT\s+lb_fatal\(\)$/i', $sql)) throw new PgError('57P01', 'terminating connection due to administrator command', '', 'FATAL');
618+ if (preg_match('/^SELECT\s+lb_notice\(\)$/i', $sql)) {
619+ $this->notice($id, 'NOTICE', '00000', 'やあ');
620+ $this->notice($id, 'WARNING', '01000', 'w');
621+ return $one('lb_notice', 2278, '');
622+ }
623+ if (preg_match('/^SELECT\s+lb_async\(\)$/i', $sql)) {
624+ $this->send($id, 'A', pack('N', $id)."lbchan\0hi\0");
625+ $this->send($id, 'S', "TimeZone\0UTC\0");
626+ return $one('lb_async', 25, 'ok');
627+ }
628+ if (preg_match('/^SELECT\s+lb_types\(\)$/i', $sql)) {
629+ $cols = [];
630+ foreach (self::TYPES as $k => [$oid]) $cols[] = [$k, $oid];
631+ return ['cols' => $cols, 'rows' => [array_column(array_values(self::TYPES), 1)], 'tag' => 'SELECT 1'];
632+ }
633+ if (preg_match('/lb_sleep\(([\d.]+)\)/i', $sql, $m)) {
634+ usleep((int)((float)$m[1] * 1000000));
635+ $sql = preg_replace('/lb_sleep\([\d.]+\)/i', "''", $sql);
636+ }
637+ if (preg_match('/^DROP\s+TABLE\s+IF\s+EXISTS\s+(\w+)$/i', $sql, $m)) {
638+ if (!$this->columns($m[1])) {
639+ $this->notice($id, 'NOTICE', '00000', "table \"{$m[1]}\" does not exist, skipping");
640+ return $none('DROP TABLE');
641+ }
642+ }
643+
644+ $st = $this->pdo->prepare($this->rewrite($id, $sql));
645+ foreach ($params as $i => $v) {
646+ $type = match (true) {
647+ $v === null => \PDO::PARAM_NULL,
648+ is_int($v) => \PDO::PARAM_INT,
649+ $v instanceof Blob => \PDO::PARAM_LOB,
650+ default => \PDO::PARAM_STR,
651+ };
652+ $st->bindValue(':p'.($i + 1), $v instanceof Blob ? $v->data : $v, $type);
653+ }
654+ $st->execute();
655+ $rows = $st->columnCount() > 0 ? $st->fetchAll(\PDO::FETCH_NUM) : [];
656+ } catch (\PDOException $e) {
657+ if ($cn['txn'] === 'T') $cn['txn'] = 'E';
658+ throw $this->sqlError($e);
659+ } catch (PgError $e) {
660+ if ($cn['txn'] === 'T') $cn['txn'] = 'E';
661+ throw $e;
662+ }
663+
664+ $word = strtoupper((string)strtok($sql, " \t\r\n("));
665+ $count = $st->columnCount() > 0 ? count($rows) : $st->rowCount();
666+ $tag = match ($word) {
667+ 'SELECT', 'VALUES', 'WITH' => "SELECT {$count}",
668+ 'INSERT' => "INSERT 0 {$count}",
669+ 'UPDATE', 'DELETE' => "{$word} {$count}",
670+ 'CREATE', 'DROP', 'ALTER' => $word.' '.strtoupper(preg_split('/\s+/', $sql)[1] ?? ''),
671+ default => $word,
672+ };
673+ if ($st->columnCount() === 0) return $none($tag);
674+
675+ // 列の型
676+ $cols = [];
677+ for ($i = 0; $i < $st->columnCount(); $i++) {
678+ $meta = $st->getColumnMeta($i) ?: [];
679+ $decl = strtoupper(preg_replace('/\(.*$/', '', (string)($meta['sqlite:decl_type'] ?? '')));
680+ $oid = self::DECL[$decl] ?? (str_starts_with($decl, 'BOOL') ? 16 : null);
681+ if ($oid === null && $decl !== '') $oid = 25;
682+ if ($oid === null) {
683+ // 式の列:値から
684+ $oid = 25;
685+ foreach ($rows as $r) {
686+ if ($r[$i] === null) continue;
687+ $oid = is_int($r[$i]) ? ($r[$i] >= -2147483648 && $r[$i] <= 2147483647 ? 23 : 20) : (is_float($r[$i]) ? 701 : 25);
688+ break;
689+ }
690+ }
691+ $cols[] = [(string)($meta['name'] ?? "?column?"), $oid];
692+ }
693+ $out = [];
694+ foreach ($rows as $r) {
695+ $line = [];
696+ foreach ($r as $i => $v) $line[] = $this->textValue($v, $cols[$i][1]);
697+ $out[] = $line;
698+ }
699+ return ['cols' => $cols, 'rows' => $out, 'tag' => $tag];
700+ }
701+
702+ /** COPY FROM STDIN:CopyData・CopyDone・CopyFailを待つ */
703+ private function copyIn(int $id): array {
704+ $this->send($id, 'G', "\0".pack('nn', 1, 0));
705+ $n = 0;
706+ while (true) {
707+ $msg = $this->recv($id);
708+ if ($msg === null) throw new PgError('08006', 'unexpected EOF on client connection with an open transaction', '', 'FATAL');
709+ [$type, $body] = $msg;
710+ if ($type === 'd') {
711+ $n += substr_count($body, "\n");
712+ } else if ($type === 'c') {
713+ return ['cols' => null, 'rows' => [], 'tag' => "COPY {$n}"];
714+ } else if ($type === 'f') {
715+ throw new PgError('57014', 'COPY from stdin failed: '.rtrim($body, "\0"));
716+ } else if ($type !== 'H' && $type !== 'S') {
717+ throw new PgError('08P01', "unexpected message type 0x".bin2hex($type)." during COPY from stdin");
718+ }
719+ }
720+ }
721+
722+ ///////////////
723+ // SQLite ⇔ PostgreSQL
724+
725+ /** PostgreSQLの書き方をSQLiteにする、特別な関数を値にする */
726+ private function rewrite(int $id, string $sql): string {
727+ $cn = $this->conns[$id];
728+ $q = fn(string $s): string => $this->pdo->quote($s);
729+ $sql = preg_replace('/\b(?:BIG)?SERIAL\s+PRIMARY\s+KEY\b/i', 'INTEGER PRIMARY KEY AUTOINCREMENT', $sql);
730+
731+ $sql = strtr($sql, [
732+ 'current_database()' => $q($cn['db']),
733+ 'current_user' => $q($cn['user']),
734+ 'pg_backend_pid()' => (string)$cn['id'],
735+ 'version()' => $q('PostgreSQL '.self::VERSION),
736+ 'lb_tls()' => $cn['tls'] ? '1' : '0',
737+ 'lb_auth()' => $q($cn['auth']),
738+ 'lb_cb()' => $q($cn['cb']),
739+ ]);
740+
741+ // 「$1」→「:p1」、「値::型」→「CAST(値 AS 型)」(文字列・識別子の中は変えない)
742+ return preg_replace_callback('/((?:\$\d+|\'(?:[^\']|\'\')*\'|\w+)(?:::\w+(?:\[\])?)+)|\'(?:[^\']|\'\')*\'|"(?:[^"]|"")*"|\$(\d+)/', function(array $m): string {
743+ if (isset($m[2]) && $m[2] !== '') return ':p'.$m[2];
744+ if (!isset($m[1]) || $m[1] === '') return $m[0];
745+ $parts = explode('::', $m[1]);
746+ $v = array_shift($parts);
747+ if (preg_match('/^\$(\d+)$/', $v, $p)) $v = ':p'.$p[1];
748+ foreach ($parts as $t) {
749+ $t = strtoupper($t);
750+ $aff = match (true) {
751+ str_contains($t, 'INT') || str_starts_with($t, 'BOOL') => 'INTEGER',
752+ str_starts_with($t, 'FLOAT') || in_array($t, ['REAL', 'DOUBLE', 'NUMERIC', 'DECIMAL'], true) => 'REAL',
753+ $t === 'BYTEA' => 'BLOB',
754+ default => 'TEXT',
755+ };
756+ $v = "CAST({$v} AS {$aff})";
757+ }
758+ return $v;
759+ }, $sql);
760+ }
761+
762+ /** パラメータの型(「$1::型」、INSERTの列、「列 = $1」から、無ければtext) */
763+ private function paramTypes(string $sql): array {
764+ preg_match_all('/\$(\d+)/', preg_replace('/\'(?:[^\']|\'\')*\'/', "''", $sql), $all);
765+ $n = empty($all[1]) ? 0 : max(array_map('intval', $all[1]));
766+ $types = array_fill(0, $n, 25);
767+ $toOid = fn(string $t): int => self::DECL[strtoupper($t)] ?? (str_starts_with(strtoupper($t), 'BOOL') ? 16 : 25);
768+
769+ // テーブルの列
770+ $cols = [];
771+ if (preg_match_all('/\b(?:FROM|INTO|UPDATE|JOIN)\s+(\w+)/i', $sql, $t)) {
772+ foreach ($t[1] as $table) $cols += $this->columns($table);
773+ }
774+ if (preg_match('/INSERT\s+INTO\s+\w+\s*\(([^)]*)\)\s*VALUES\s*\(([^)]*)\)/i', $sql, $m)) {
775+ $names = array_map('trim', explode(',', $m[1]));
776+ foreach (array_map('trim', explode(',', $m[2])) as $i => $v) {
777+ if (preg_match('/^\$(\d+)$/', $v, $p) && isset($cols[strtolower($names[$i] ?? '')])) {
778+ $types[(int)$p[1] - 1] = $toOid($cols[strtolower($names[$i])]);
779+ }
780+ }
781+ }
782+ if (preg_match_all('/(\w+)\s*(?:=|<>|!=|<=|>=|<|>)\s*\$(\d+)(?![\d:])/', $sql, $mm, PREG_SET_ORDER)) {
783+ foreach ($mm as $m) {
784+ if (isset($cols[strtolower($m[1])])) $types[(int)$m[2] - 1] = $toOid($cols[strtolower($m[1])]);
785+ }
786+ }
787+ if (preg_match_all('/\$(\d+)::(\w+)(\[\])?/', $sql, $mm, PREG_SET_ORDER)) {
788+ foreach ($mm as $m) $types[(int)$m[1] - 1] = empty($m[3]) ? $toOid($m[2]) : 1009;
789+ }
790+ return $types;
791+ }
792+
793+ /** テーブルの列 => 宣言された型(小文字の名前) */
794+ private function columns(string $table): array {
795+ $out = [];
796+ try {
797+ foreach ($this->pdo->query('PRAGMA table_info('.$this->pdo->quote($table).')')->fetchAll(\PDO::FETCH_ASSOC) as $c) {
798+ $out[strtolower($c['name'])] = preg_replace('/\(.*$/', '', $c['type']);
799+ }
800+ } catch (\PDOException $e) {
801+ // 無い
802+ }
803+ return $out;
804+ }
805+
806+ /** パラメータ(テキスト又はバイナリ)をSQLiteの値にする */
807+ private function decodeParam(string $v, int $format, int $oid): mixed {
808+ if ($format === 1) {
809+ if ($oid !== 17) throw new PgError('0A000', "binary format for type {$oid} is not supported by lbmock");
810+ return new Blob($v);
811+ }
812+ switch ($oid) {
813+ case 16:
814+ $b = strtolower(trim($v));
815+ if (in_array($b, ['t', 'true', 'y', 'yes', 'on', '1'], true)) return 1;
816+ if (in_array($b, ['f', 'false', 'n', 'no', 'off', '0'], true)) return 0;
817+ throw new PgError('22P02', "invalid input syntax for type boolean: \"{$v}\"");
818+ case 20:
819+ case 21:
820+ case 23:
821+ if (!preg_match('/^\s*[+-]?\d+\s*$/', $v)) throw new PgError('22P02', "invalid input syntax for type integer: \"{$v}\"");
822+ return (int)$v;
823+ case 700:
824+ case 701:
825+ if (!is_numeric($v) && !in_array($v, ['NaN', 'Infinity', '-Infinity'], true)) {
826+ throw new PgError('22P02', "invalid input syntax for type double precision: \"{$v}\"");
827+ }
828+ return is_numeric($v) ? (float)$v : $v;
829+ }
830+ // テキストはUTF-8のみ(0x00も不可)
831+ if (!mb_check_encoding($v, 'UTF-8') || str_contains($v, "\0")) {
832+ throw new PgError('22021', 'invalid byte sequence for encoding "UTF8"');
833+ }
834+ if ($oid === 17) {
835+ // テキスト形式のbytea(\x…、又はescape形式)
836+ if (str_starts_with($v, '\\x')) return new Blob((string)hex2bin(substr($v, 2)));
837+ $bad = false;
838+ $out = preg_replace_callback('/\\\\(\\\\|[0-3][0-7]{2}|.?)/s', function(array $m) use (&$bad): string {
839+ if ($m[1] === '\\') return '\\';
840+ if (strlen($m[1]) === 3) return chr(octdec($m[1]));
841+ $bad = true;
842+ return '';
843+ }, $v);
844+ if ($bad) throw new PgError('22P02', 'invalid input syntax for type bytea');
845+ return new Blob($out);
846+ }
847+ return $v;
848+ }
849+
850+ /** SQLiteの値をPostgreSQLのテキストにする */
851+ private function textValue(mixed $v, int $oid): ?string {
852+ if ($v === null) return null;
853+ return match ($oid) {
854+ 16 => in_array($v, [1, '1', 't', 'true'], true) ? 't' : 'f',
855+ 17 => '\\x'.bin2hex((string)$v),
856+ 700, 701 => is_string($v) && !is_numeric($v) ? $v : self::floatText((float)$v),
857+ default => (string)$v,
858+ };
859+ }
860+
861+ private static function floatText(float $f): string {
862+ if (is_nan($f)) return 'NaN';
863+ if (is_infinite($f)) return $f > 0 ? 'Infinity' : '-Infinity';
864+ return str_replace(['.0E', 'E'], ['e', 'e'], (string)$f);
865+ }
866+
867+ private function sqlError(\PDOException $e): PgError {
868+ $msg = $e->getMessage();
869+ if (preg_match('/no such table: (\S+)/', $msg, $m)) return new PgError('42P01', "relation \"{$m[1]}\" does not exist");
870+ if (preg_match('/UNIQUE constraint failed: (\w+)\.(\w+)/', $msg, $m)) {
871+ return new PgError('23505', "duplicate key value violates unique constraint \"{$m[1]}_{$m[2]}_key\"", "Key ({$m[2]}) already exists.");
872+ }
873+ if (preg_match('/NOT NULL constraint failed: (\w+)\.(\w+)/', $msg, $m)) {
874+ return new PgError('23502', "null value in column \"{$m[2]}\" of relation \"{$m[1]}\" violates not-null constraint");
875+ }
876+ if (preg_match('/no such column: (\S+)/', $msg, $m)) return new PgError('42703', "column \"{$m[1]}\" does not exist");
877+ if (preg_match('/near "([^"]*)": syntax error/', $msg, $m)) return new PgError('42601', "syntax error at or near \"{$m[1]}\"");
878+ if (preg_match('/already exists/', $msg)) return new PgError('42P07', preg_replace('/^SQLSTATE\[\w+\]: [^:]*: \d+ /', '', $msg));
879+ return new PgError('XX000', preg_replace('/^SQLSTATE\[\w+\]: [^:]*: \d+ /', '', $msg));
880+ }
881+
882+ /** セミコロンで文を分ける(文字列・識別子・$$・コメントの中は分けない) */
883+ private static function split(string $sql): array {
884+ $out = [];
885+ $cur = '';
886+ $len = strlen($sql);
887+ for ($i = 0; $i < $len; $i++) {
888+ $c = $sql[$i];
889+ if ($c === "'" || $c === '"') {
890+ $j = $i + 1;
891+ while ($j < $len && !($sql[$j] === $c && ($sql[$j + 1] ?? '') !== $c)) $j += $sql[$j] === $c ? 2 : 1;
892+ $cur .= substr($sql, $i, $j - $i + 1);
893+ $i = $j;
894+ } else if ($c === '$' && preg_match('/\G\$(\w*)\$/', $sql, $m, 0, $i) && !ctype_digit($m[1])) {
895+ $end = strpos($sql, $m[0], $i + strlen($m[0]));
896+ $j = $end === false ? $len : $end + strlen($m[0]);
897+ $cur .= substr($sql, $i, $j - $i);
898+ $i = $j - 1;
899+ } else if ($c === '-' && ($sql[$i + 1] ?? '') === '-') {
900+ $j = strpos($sql, "\n", $i);
901+ $i = $j === false ? $len : $j;
902+ } else if ($c === ';') {
903+ $out[] = $cur;
904+ $cur = '';
905+ } else {
906+ $cur .= $c;
907+ }
908+ }
909+ $out[] = $cur;
910+ return $out;
911+ }
912+
913+ ///////////////
914+ // メッセージ
915+
916+ private function rowDescription(array $cols): string {
917+ $b = pack('n', count($cols));
918+ foreach ($cols as [$name, $oid]) {
919+ $size = [16 => 1, 20 => 8, 21 => 2, 23 => 4, 700 => 4, 701 => 8, 1082 => 4, 1114 => 8, 1184 => 8][$oid] ?? -1;
920+ $b .= $name."\0".pack('NnNnNn', 0, 0, $oid, $size & 0xFFFF, 0xFFFFFFFF, 0);
921+ }
922+ return $b;
923+ }
924+
925+ private function dataRow(array $row): string {
926+ $b = pack('n', count($row));
927+ foreach ($row as $v) $b .= $v === null ? pack('N', 0xFFFFFFFF) : pack('N', strlen($v)).$v;
928+ return $b;
929+ }
930+
931+ private function notice(int $id, string $severity, string $code, string $msg): void {
932+ $this->send($id, 'N', "S{$severity}\0V{$severity}\0C{$code}\0M{$msg}\0\0");
933+ }
934+
935+ private function error(int $id, PgError $e): void {
936+ $b = "S{$e->severity}\0V{$e->severity}\0C{$e->sqlState}\0M{$e->getMessage()}\0";
937+ if ($e->detail !== '') $b .= "D{$e->detail}\0";
938+ $this->send($id, 'E', $b."\0");
939+ }
940+
941+ private static function cstr(string $b, int &$pos): string {
942+ $end = strpos($b, "\0", $pos);
943+ $s = substr($b, $pos, $end - $pos);
944+ $pos = $end + 1;
945+ return $s;
946+ }
947+
948+ private function send(int $id, string $type, string $body): void {
949+ $this->raw($id, $type.pack('N', strlen($body) + 4).$body);
950+ }
951+
952+ private function raw(int $id, string $data): void {
953+ for ($w = 0; $w < strlen($data);) {
954+ $n = @fwrite($this->conns[$id]['sock'], substr($data, $w, 1048576));
955+ if ($n === false || $n === 0) return;
956+ $w += $n;
957+ }
958+ }
959+
960+ private function readRaw(int $id, int $n): ?string {
961+ $buf = '';
962+ while (strlen($buf) < $n) {
963+ $x = @fread($this->conns[$id]['sock'], $n - strlen($buf));
964+ if ($x === false || $x === '') return null;
965+ $buf .= $x;
966+ }
967+ return $buf;
968+ }
969+
970+ private function recv(int $id): ?array {
971+ $head = $this->readRaw($id, 5);
972+ if ($head === null) return null;
973+ $len = unpack('N', $head, 1)[1];
974+ if ($len < 4) return null;
975+ $body = $len > 4 ? $this->readRaw($id, $len - 4) : '';
976+ return $body === null ? null : [$head[0], $body];
977+ }
978+}
979+
980+/** byteaの値(SQLiteにBLOBとして渡す) */
981+class Blob {
982+ public function __construct(public string $data) {}
983+}
@@ -0,0 +1,672 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Test;
39+
40+require_once __DIR__.'/../../../autoload.php';
41+
42+use Std\Lib\Tester;
43+use Std\Lib\Db;
44+use Std\Lib\Db\DbException;
45+use Std\Lib\Db\Pgsql;
46+use Std\Test\Helper\MockServer;
47+use Std\Test\Helper\TestCert;
48+
49+// 本物のサーバーでも試す場合(任意):LB_TEST_PGSQL=pgsql://ユーザー:パスワード@ホスト:ポート/データベース
50+// 「lb_test_」で始まるテーブルを作って消します。
51+
52+$test = new Tester([
53+ 'colorOutput' => true,
54+ 'verboseOutput' => true
55+]);
56+
57+enum LibPgsqlColor: string {
58+ case Red = 'あか';
59+}
60+
61+// 例外を受け取る(SQLSTATE・メッセージを確認する為)
62+$catch = function(callable $fn): ?\Throwable {
63+ try {
64+ $fn();
65+ } catch (\Throwable $e) {
66+ return $e;
67+ }
68+ return null;
69+};
70+
71+// 偽物と本物の両方のサーバーで同じ結果になるはずのテスト
72+$common = function(Tester $test, callable $info) use ($catch): void {
73+ $open = function(array $o = []) use ($info): Pgsql {
74+ $pg = new Pgsql($info($o));
75+ $pg->connect();
76+ return $pg;
77+ };
78+ $one = fn(Pgsql $pg, string $sql) => array_values($pg->query($sql)['rows'][0] ?? [])[0] ?? null;
79+ // プリペアドステートメントで1回だけ実行する
80+ $run = function(Pgsql $pg, string $sql, array $params = []): array {
81+ $id = $pg->prepare($sql);
82+ try {
83+ return $pg->execute($id, $params);
84+ } finally {
85+ $pg->demolish($id);
86+ }
87+ };
88+
89+ $test->it('接続して切断出来るはず', function($test) use ($open, $one, $info): void {
90+ $pg = $open();
91+ $test->assertTrue($pg->isConnected());
92+ $test->assertTrue($pg->connect()); // 2回目は何もしない
93+ $si = $pg->getServerInfo();
94+ $test->assertSame('UTF8', $si['client_encoding']);
95+ $test->assertSame('LittleBeast', $si['application_name']);
96+ $test->assertTrue($si['server_version'] !== '');
97+ $test->assertSame($si['pid'], $one($pg, 'SELECT pg_backend_pid() AS pid'));
98+ $test->assertSame($info()['username'], $one($pg, 'SELECT current_user AS u'));
99+ $test->assertSame($info()['dbname'], $one($pg, 'SELECT current_database() AS d'));
100+ $test->assertTrue($pg->ping());
101+ $test->assertFalse($pg->inTransaction());
102+
103+ $pg->close();
104+ $test->assertFalse($pg->isConnected());
105+ $test->assertFalse($pg->ping());
106+ $pg->close(); // 2回目は何もしない
107+ });
108+
109+ $test->it('テーブルを作って、データを入れて読めるはず', function($test) use ($open, $run): void {
110+ $pg = $open();
111+ $pg->query('DROP TABLE IF EXISTS lb_test_items');
112+ $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)');
113+ $test->assertSame('CREATE TABLE', $res['command']);
114+ $test->assertSame(0, $res['affectedRows']);
115+ $test->assertSame([], $res['rows']);
116+
117+ $bin = "\x00\xff\\x'\"\n";
118+ $id = $pg->prepare('INSERT INTO lb_test_items (name, n, f, b, d) VALUES (?, ?, ?, ?, ?) RETURNING id');
119+ $res = $pg->execute($id, ['諏訪子', PHP_INT_MAX, 1.5, true, $bin]);
120+ $test->assertSame('INSERT 0 1', $res['command']);
121+ $test->assertSame(1, $res['affectedRows']);
122+ $test->assertSame(0, $res['insertId']);
123+ $test->assertSame([['id' => 1]], $res['rows']);
124+ $test->assertSame(Pgsql::OID_INT4, $res['fields'][0]['type']);
125+ $test->assertSame([['id' => 2]], $pg->execute($id, ['神奈子', null, -0.25, false, ''])['rows']);
126+ $test->assertSame([['id' => 3]], $pg->execute($id, ['早苗', -7, null, null, null])['rows']);
127+ $pg->demolish($id);
128+
129+ $res = $pg->query('SELECT * FROM lb_test_items ORDER BY id');
130+ $test->assertSame('SELECT 3', $res['command']);
131+ $test->assertSame(3, $res['affectedRows']);
132+ $test->assertSame(['id', 'name', 'n', 'f', 'b', 'd'], array_column($res['fields'], 'name'));
133+ $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'));
134+ $test->assertSame([
135+ ['id' => 1, 'name' => '諏訪子', 'n' => PHP_INT_MAX, 'f' => 1.5, 'b' => true, 'd' => $bin],
136+ ['id' => 2, 'name' => '神奈子', 'n' => null, 'f' => -0.25, 'b' => false, 'd' => ''],
137+ ['id' => 3, 'name' => '早苗', 'n' => -7, 'f' => null, 'b' => null, 'd' => null],
138+ ], $res['rows']);
139+
140+ // 拡張クエリの結果も同じ
141+ $test->assertSame($res['rows'], $run($pg, 'SELECT * FROM lb_test_items WHERE id > ? ORDER BY id', [0])['rows']);
142+ $test->assertSame([['name' => '神奈子']], $run($pg, 'SELECT name FROM lb_test_items WHERE b = ?', [false])['rows']);
143+ $test->assertSame([], $run($pg, 'SELECT name FROM lb_test_items WHERE id = ?', [99])['rows']);
144+
145+ $res = $run($pg, 'UPDATE lb_test_items SET n = ? WHERE id > ?', [5, 1]);
146+ $test->assertSame('UPDATE 2', $res['command']);
147+ $test->assertSame(2, $res['affectedRows']);
148+ $test->assertSame([], $res['fields']);
149+ $test->assertSame(1, $run($pg, 'DELETE FROM lb_test_items WHERE id = ?', [3])['affectedRows']);
150+ });
151+
152+ $test->it('「?」のプレースホルダは文字列の中で変えないはず', function($test) use ($open, $run): void {
153+ $pg = $open();
154+ $res = $run($pg, "SELECT name FROM lb_test_items WHERE name = ? AND 'a?' <> ? -- ?\n", ['諏訪子', 'b']);
155+ $test->assertSame([['name' => '諏訪子']], $res['rows']);
156+ $res = $run($pg, 'SELECT name FROM lb_test_items WHERE id = $1 OR id = $1', [2]);
157+ $test->assertSame([['name' => '神奈子']], $res['rows']);
158+ });
159+
160+ $test->it('PHPの色々な型をパラメータとして送れるはず', function($test) use ($open, $run, $catch): void {
161+ $pg = $open();
162+ $pg->query('DROP TABLE IF EXISTS lb_test_params');
163+ $pg->query('CREATE TABLE lb_test_params (id SERIAL PRIMARY KEY, s TEXT, i INT8, f FLOAT8)');
164+ $ins = $pg->prepare('INSERT INTO lb_test_params (s, i, f) VALUES (?, ?, ?) RETURNING id');
165+ $get = $pg->prepare('SELECT s, i, f FROM lb_test_params WHERE id = ?');
166+ $stringable = new class {
167+ public function __toString(): string {
168+ return '文字列';
169+ }
170+ };
171+ $cases = [
172+ [[42, 42, 42], ['42', 42, 42.0]],
173+ [['', PHP_INT_MIN, -0.1], ['', PHP_INT_MIN, -0.1]],
174+ [[1.25, true, 1e100], ['1.25', 1, 1.0E+100]],
175+ [[false, false, NAN], ['f', 0, NAN]],
176+ [[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]],
177+ [[LibPgsqlColor::Red, null, -INF], ['あか', null, -INF]],
178+ [[$stringable, null, null], ['文字列', null, null]],
179+ ];
180+ foreach ($cases as [$in, $out]) {
181+ $id = $pg->execute($ins, $in)['rows'][0]['id'];
182+ $row = array_values($pg->execute($get, [$id])['rows'][0]);
183+ $label = 'パラメータ:'.var_export($in[0], true);
184+ $test->assertSame($out[0], $row[0], $label);
185+ $test->assertSame($out[1], $row[1], $label);
186+ is_float($out[2]) && is_nan($out[2]) ? $test->assertTrue(is_nan($row[2]), $label) : $test->assertSame($out[2], $row[2], $label);
187+ }
188+
189+ // 連想配列でも順番通り
190+ $id = $pg->execute($ins, ['x' => 'あ', 'y' => 1, 'z' => 2])['rows'][0]['id'];
191+ $test->assertSame(['s' => 'あ', 'i' => 1, 'f' => 2.0], $pg->execute($get, [$id])['rows'][0]);
192+
193+ $e = $catch(fn() => $pg->execute($ins, [[1, 2], 1, 1]));
194+ $test->assertTrue($e instanceof DbException);
195+ $test->assertStringContains('パラメータ0の型は未対応です: array', $e->getMessage());
196+ $e = $catch(fn() => $pg->execute($ins, ['足りない']));
197+ $test->assertStringContains('パラメータ数が一致しません: 期待 3, 取得 1', $e->getMessage());
198+
199+ $test->assertTrue($pg->demolish($ins));
200+ $test->assertFalse($pg->demolish($ins));
201+ $e = $catch(fn() => $pg->execute($ins, ['x', 1, 1]));
202+ $test->assertStringContains("不正なステートメントID: {$ins}", $e->getMessage());
203+ $test->assertTrue($pg->ping());
204+ });
205+
206+ $test->it('SQLのエラーは例外で、その後も使えるはず', function($test) use ($open, $one, $catch): void {
207+ $pg = $open();
208+ $e = $catch(fn() => $pg->query('SELECT * FROM lb_test_nope'));
209+ $test->assertTrue($e instanceof DbException);
210+ $test->assertSame('42P01', $e->sqlState);
211+ $test->assertSame('クエリに失敗: 42P01 - relation "lb_test_nope" does not exist', $e->getMessage());
212+
213+ $e = $catch(fn() => $pg->query('SELEC 1'));
214+ $test->assertSame('42601', $e->sqlState);
215+ $test->assertStringContains('syntax error at or near "SELEC"', $e->getMessage());
216+
217+ $e = $catch(fn() => $pg->prepare('SELECT * FROM lb_test_nope WHERE id = ?'));
218+ $test->assertStringContains('準備に失敗: 42P01', $e->getMessage());
219+
220+ $id = $pg->prepare('INSERT INTO lb_test_items (name) VALUES (?)');
221+ $e = $catch(fn() => $pg->execute($id, ['諏訪子']));
222+ $test->assertSame('23505', $e->sqlState);
223+ $test->assertStringContains('実行に失敗: 23505 - duplicate key value violates unique constraint "lb_test_items_name_key"', $e->getMessage());
224+ $test->assertStringContains('Key (name)', $e->getMessage());
225+ $e = $catch(fn() => $pg->execute($id, [null]));
226+ $test->assertSame('23502', $e->sqlState);
227+
228+ $test->assertTrue($pg->isConnected());
229+ $test->assertFalse($pg->inTransaction());
230+ $test->assertSame(2, $one($pg, 'SELECT count(*) AS c FROM lb_test_items'));
231+ });
232+
233+ $test->it('複数の文の結果を全部受け取れるはず', function($test) use ($open): void {
234+ $pg = $open();
235+ $res = $pg->query("SELECT 1 AS a; UPDATE lb_test_items SET n = 6; SELECT '二' AS b;");
236+ $test->assertSame([['a' => 1]], $res['rows']);
237+ $test->assertCount(2, $res['more']);
238+ $test->assertSame('UPDATE 2', $res['more'][0]['command']);
239+ $test->assertSame(2, $res['more'][0]['affectedRows']);
240+ $test->assertSame([['b' => '二']], $res['more'][1]['rows']);
241+ $test->assertFalse(isset($res['more'][1]['more']));
242+
243+ // 空のクエリ
244+ $res = $pg->query('');
245+ $test->assertSame('', $res['command']);
246+ $test->assertSame([], $res['rows']);
247+ });
248+
249+ $test->it('通知を受け取れるはず', function($test) use ($open): void {
250+ $pg = $open();
251+ $res = $pg->query('DROP TABLE IF EXISTS lb_test_none');
252+ $test->assertSame(1, $res['warningCount']);
253+ $test->assertSame([['severity' => 'NOTICE', 'code' => '00000', 'message' => 'table "lb_test_none" does not exist, skipping']], $pg->getNotices());
254+ $pg->query('SELECT 1');
255+ $test->assertSame([], $pg->getNotices());
256+ $pg->query('COMMIT');
257+ $test->assertSame('25P01', $pg->getNotices()[0]['code']);
258+ });
259+
260+ $test->it('トランザクションを使えるはず', function($test) use ($open, $one, $catch): void {
261+ $pg = $open();
262+ $count = fn(): int => $one($pg, 'SELECT count(*) AS c FROM lb_test_items');
263+
264+ $pg->beginTransaction();
265+ $test->assertTrue($pg->inTransaction());
266+ $pg->query("INSERT INTO lb_test_items (name) VALUES ('あ')");
267+ $pg->rollback();
268+ $test->assertFalse($pg->inTransaction());
269+ $test->assertSame(2, $count());
270+
271+ $pg->beginTransaction();
272+ $pg->query("INSERT INTO lb_test_items (name) VALUES ('い')");
273+ $test->assertTrue($pg->commit());
274+ $test->assertSame(3, $count());
275+
276+ // エラーの後は失敗したトランザクション:COMMITはロールバックになるので例外
277+ $pg->beginTransaction();
278+ $pg->query("INSERT INTO lb_test_items (name) VALUES ('う')");
279+ $catch(fn() => $pg->query('SELECT * FROM lb_test_nope'));
280+ $test->assertTrue($pg->inTransaction());
281+ $e = $catch(fn() => $pg->query('SELECT 1'));
282+ $test->assertSame('25P02', $e->sqlState);
283+ $e = $catch(fn() => $pg->commit());
284+ $test->assertTrue($e instanceof DbException);
285+ $test->assertSame('25P02', $e->sqlState);
286+ $test->assertStringContains('ロールバックしました', $e->getMessage());
287+ $test->assertFalse($pg->inTransaction());
288+ $test->assertSame(3, $count());
289+ });
290+
291+ $test->it('COPYは断るはず(接続はそのまま)', function($test) use ($open, $one, $catch): void {
292+ $pg = $open();
293+ $e = $catch(fn() => $pg->query('COPY lb_test_items (name) FROM STDIN'));
294+ $test->assertTrue($e instanceof DbException);
295+ $test->assertSame('57014', $e->sqlState);
296+ $test->assertStringContains('COPYは未対応です', $e->getMessage());
297+ $test->assertSame(1, $one($pg, 'SELECT 1 AS x'));
298+ });
299+
300+ $test->it('長い値を送受信出来るはず', function($test) use ($open, $run): void {
301+ $pg = $open();
302+ $pg->query('DROP TABLE IF EXISTS lb_test_big');
303+ $pg->query('CREATE TABLE lb_test_big (id SERIAL PRIMARY KEY, t TEXT, d BYTEA)');
304+ $text = str_repeat('あいう', 400000);
305+ $bin = random_bytes(1500000);
306+ $id = $run($pg, 'INSERT INTO lb_test_big (t, d) VALUES (?, ?) RETURNING id', [$text, $bin])['rows'][0]['id'];
307+ $row = $run($pg, 'SELECT t, d FROM lb_test_big WHERE id = ?', [$id])['rows'][0];
308+ $test->assertSame(strlen($text), strlen($row['t']));
309+ $test->assertTrue($row['t'] === $text && $row['d'] === $bin, '値が違う');
310+ $test->assertTrue($pg->query("SELECT d FROM lb_test_big WHERE id = {$id}")['rows'][0]['d'] === $bin, '値が違う(単純クエリ)');
311+ });
312+
313+ $test->it('Db経由で使えるはず', function($test) use ($info, $catch): void {
314+ $db = new Db(['driver' => 'pgsql'] + $info());
315+ $test->assertSame('pgsql', $db->driverName);
316+ $test->assertSame('"a""b"', $db->quoteIdentifier('a"b'));
317+ $test->assertSame([['c' => 2]], $db->query('SELECT count(*) AS c FROM lb_test_items WHERE id > ? AND id < ?', [0, 3])['rows']);
318+
319+ $db->transaction(fn(Db $db) => $db->query('UPDATE lb_test_items SET n = ? WHERE id = ?', [100, 1]));
320+ $test->assertSame(100, $db->query('SELECT n FROM lb_test_items WHERE id = 1')['rows'][0]['n']);
321+ $e = $catch(fn() => $db->transaction(function(Db $db): void {
322+ $db->query('UPDATE lb_test_items SET n = ? WHERE id = ?', [200, 1]);
323+ throw new \RuntimeException('やめる');
324+ }));
325+ $test->assertSame('やめる', $e->getMessage());
326+ $test->assertSame(100, $db->query('SELECT n FROM lb_test_items WHERE id = ?', [1])['rows'][0]['n']);
327+
328+ // query()のパラメータ付きはステートメントを解放する(失敗しても)
329+ $e = $catch(fn() => $db->query('SELECT * FROM lb_test_nope WHERE id = ?', [1]));
330+ $test->assertSame('42P01', $e->sqlState);
331+ $test->assertTrue($db->ping());
332+
333+ $db->query('DROP TABLE lb_test_items');
334+ $db->query('DROP TABLE lb_test_params');
335+ $db->query('DROP TABLE lb_test_big');
336+ $db->close();
337+ $test->assertFalse($db->isConnected());
338+ });
339+};
340+
341+
342+$test->describe('Pgsql(サーバーなし)', function($test) use ($catch): void {
343+ $test->it('DB_ENABLEDが無効なら設定なしで作れないはず', function($test): void {
344+ if (defined('DB_ENABLED') && DB_ENABLED) {
345+ $test->assertNotNull(new Pgsql());
346+ return;
347+ }
348+ $test->assertThrows(fn() => new Pgsql(), \Exception::class);
349+ });
350+
351+ $test->it('Dbでドライバを選べるはず', function($test): void {
352+ foreach (['pgsql', 'postgres', 'PostgreSQL'] as $name) {
353+ $test->assertTrue((new Db(['driver' => $name]))->driver() instanceof Pgsql, $name);
354+ $test->assertTrue(Db::isSupported($name), $name);
355+ }
356+ });
357+
358+ $test->it('不正なchannel_bindingは例外のはず', function($test): void {
359+ $test->assertThrows(fn() => new Pgsql(['channel_binding' => 'always']), \InvalidArgumentException::class);
360+ $test->assertNotNull(new Pgsql(['channel_binding' => 'REQUIRE']));
361+ });
362+
363+ $test->it('識別子を囲めるはず', function($test): void {
364+ $pg = new Pgsql([]);
365+ $test->assertSame('"users"', $pg->quoteIdentifier('users'));
366+ $test->assertSame('"a""b"', $pg->quoteIdentifier('a"b'));
367+ });
368+
369+ $test->it('接続していなければ例外のはず', function($test) use ($catch): void {
370+ $pg = new Pgsql([]);
371+ foreach ([fn() => $pg->query('SELECT 1'), fn() => $pg->prepare('SELECT 1'), fn() => $pg->execute(1), fn() => $pg->beginTransaction()] as $fn) {
372+ $e = $catch($fn);
373+ $test->assertTrue($e instanceof DbException);
374+ $test->assertStringContains('接続していません', $e->getMessage());
375+ }
376+ $test->assertFalse($pg->ping());
377+ $test->assertFalse($pg->inTransaction());
378+ $test->assertFalse($pg->demolish(1));
379+ });
380+
381+ $test->it('サーバーが無ければ接続に失敗するはず', function($test) use ($catch): void {
382+ $probe = stream_socket_server('tcp://127.0.0.1:0');
383+ $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1);
384+ fclose($probe);
385+
386+ $e = $catch(fn() => (new Pgsql(['port' => $port, 'timeout' => 2]))->connect());
387+ $test->assertTrue($e instanceof DbException);
388+ $test->assertStringContains('ソケットに接続に失敗', $e->getMessage());
389+ $e = $catch(fn() => (new Pgsql(['socket' => sys_get_temp_dir().'/lb-no-such-dir']))->connect());
390+ $test->assertStringContains('ソケットに接続に失敗', $e->getMessage());
391+ });
392+
393+ $test->it('「?」を「$1」等に変えるはず', function($test): void {
394+ $cases = [
395+ 'SELECT 1' => 'SELECT 1',
396+ 'SELECT ?, ?' => 'SELECT $1, $2',
397+ "SELECT '?', ?, 'it''s ?', ?" => "SELECT '?', \$1, 'it''s ?', \$2",
398+ 'SELECT "a?""b", ?' => 'SELECT "a?""b", $1',
399+ "SELECT E'\\'?', ?" => "SELECT E'\\'?', \$1",
400+ "SELECT e'\\\\', ?" => "SELECT e'\\\\', \$1",
401+ "SELECT x'?', ?" => "SELECT x'?', \$1",
402+ 'SELECT $$?$$, $tag$ ? $$ ? $tag$, ?' => 'SELECT $$?$$, $tag$ ? $$ ? $tag$, $1',
403+ "SELECT ? -- ?\n, ?" => "SELECT \$1 -- ?\n, \$2",
404+ 'SELECT /* ? /* ? */ ? */ ?' => 'SELECT /* ? /* ? */ ? */ $1',
405+ "SELECT j ?? 'a', ?" => "SELECT j ? 'a', \$1",
406+ 'SELECT a$1, ?' => 'SELECT a$1, $1',
407+ "SELECT 'unterminated ?" => "SELECT 'unterminated ?",
408+ 'SELECT 名前?' => 'SELECT 名前$1',
409+ ];
410+ foreach ($cases as $in => $out) $test->assertSame($out, Pgsql::placeholders($in), $in);
411+ });
412+
413+ $test->it('配列と型を変換出来るはず', function($test): void {
414+ $test->assertSame([1, 2, null], Pgsql::parseArray('{1,2,NULL}', Pgsql::OID_INT4));
415+ $test->assertSame([['a', 'b,c'], ['d"e', '\\']], Pgsql::parseArray('{{a,"b,c"},{"d\\"e","\\\\"}}'));
416+ $test->assertSame(['NULL', null, ''], Pgsql::parseArray('{"NULL",null,""}'));
417+ $test->assertSame([1, 2], Pgsql::parseArray('[0:1]={1,2}', Pgsql::OID_INT4));
418+ $test->assertSame([], Pgsql::parseArray('{}'));
419+ $test->assertNull(Pgsql::parseArray('{1,2'));
420+ $test->assertNull(Pgsql::parseArray('{1}x'));
421+ $test->assertNull(Pgsql::parseArray('1,2'));
422+
423+ $test->assertSame(-5, Pgsql::castText('-5', Pgsql::OID_INT2));
424+ $test->assertSame('99999999999999999999', Pgsql::castText('99999999999999999999', Pgsql::OID_INT8));
425+ $test->assertSame(INF, Pgsql::castText('Infinity', Pgsql::OID_FLOAT4));
426+ $test->assertSame(1.0E-5, Pgsql::castText('1e-05', Pgsql::OID_FLOAT8));
427+ $test->assertSame(false, Pgsql::castText('f', Pgsql::OID_BOOL));
428+ $test->assertSame("\x00\xff", Pgsql::castText('\\x00ff', Pgsql::OID_BYTEA));
429+ $test->assertSame("\x00\xff\\A", Pgsql::castText('\\000\\377\\\\A', Pgsql::OID_BYTEA));
430+ $test->assertSame('1.50', Pgsql::castText('1.50', Pgsql::OID_NUMERIC));
431+ $test->assertSame([true, false], Pgsql::castText('{t,f}', 1000));
432+ $test->assertSame('{bad', Pgsql::castText('{bad', 1009));
433+ });
434+});
435+
436+// TLS用の証明書(RSAの鍵、CAが発行した物)
437+$cert = TestCert::get('rsa');
438+
439+$test->describe('Pgsql(偽物のPostgreSQLサーバー)', function($test) use ($catch, $cert, $common): void {
440+ $srv = MockServer::start('pg-server.php', [$cert['cert'], $cert['key']]);
441+ if ($srv === null) {
442+ $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効、又はpdo_sqliteが無い?)');
443+ return;
444+ }
445+
446+ $info = fn(array $o = []): array => $o + [
447+ 'host' => '127.0.0.1',
448+ 'port' => $srv->port,
449+ 'username' => 'lb',
450+ 'password' => 'ひみつ',
451+ 'dbname' => 'lb',
452+ 'timeout' => 5,
453+ ];
454+ $tls = ['cafile' => $cert['ca'], 'peer_name' => 'localhost'];
455+ $open = function(array $o = []) use ($info): Pgsql {
456+ $pg = new Pgsql($info($o));
457+ $pg->connect();
458+ return $pg;
459+ };
460+ $one = fn(Pgsql $pg, string $sql) => array_values($pg->query($sql)['rows'][0] ?? [])[0] ?? null;
461+
462+ $common($test, $info);
463+
464+ $test->it('SCRAM-SHA-256で接続出来るはず', function($test) use ($open, $one, $info, $catch): void {
465+ $pg = $open();
466+ $test->assertSame('SCRAM-SHA-256', $pg->getServerInfo()['authMethod']);
467+ $test->assertFalse($pg->getServerInfo()['tls']);
468+ $test->assertSame('none', $one($pg, 'SELECT lb_cb() AS cb'));
469+
470+ foreach ([['password' => 'ちがう'], ['username' => 'nobody'], ['password' => '']] as $o) {
471+ $pg = new Pgsql($info($o));
472+ $e = $catch(fn() => $pg->connect());
473+ $test->assertTrue($e instanceof DbException);
474+ $test->assertSame('28P01', $e->sqlState);
475+ $test->assertStringContains('接続に失敗: 28P01 - password authentication failed', $e->getMessage());
476+ $test->assertFalse($pg->isConnected());
477+ }
478+ });
479+
480+ $test->it('MD5・trustで接続出来るはず', function($test) use ($open, $one, $catch): void {
481+ $pg = $open(['username' => 'md5user', 'password' => 'md5ひみつ']);
482+ $test->assertSame('md5', $pg->getServerInfo()['authMethod']);
483+ $test->assertSame('md5user', $one($pg, 'SELECT current_user AS u'));
484+ $e = $catch(fn() => $open(['username' => 'md5user', 'password' => 'md5']));
485+ $test->assertSame('28P01', $e->sqlState);
486+
487+ $pg = $open(['username' => 'trust', 'password' => '']);
488+ $test->assertSame('trust', $pg->getServerInfo()['authMethod']);
489+ });
490+
491+ $test->it('TLSなしでパスワードをそのまま送らないはず', function($test) use ($open, $one, $catch, $tls): void {
492+ $e = $catch(fn() => $open(['username' => 'clear', 'password' => 'clearひみつ']));
493+ $test->assertTrue($e instanceof DbException);
494+ $test->assertStringContains('allow_cleartext', $e->getMessage());
495+
496+ $test->assertSame('password', $one($open(['username' => 'clear', 'password' => 'clearひみつ', 'allow_cleartext' => true]), 'SELECT lb_auth() AS a'));
497+ $pg = $open(['username' => 'clear', 'password' => 'clearひみつ', 'tls' => $tls]);
498+ $test->assertSame('password', $pg->getServerInfo()['authMethod']);
499+ $test->assertTrue($pg->getServerInfo()['tls']);
500+ });
501+
502+ $test->it('TLSとチャネルバインディング(SCRAM-SHA-256-PLUS)で接続出来るはず', function($test) use ($open, $one, $catch, $tls): void {
503+ $pg = $open(['tls' => $tls]);
504+ $test->assertSame('SCRAM-SHA-256-PLUS', $pg->getServerInfo()['authMethod']);
505+ $test->assertTrue($pg->getServerInfo()['tls']);
506+ $test->assertSame(1, $one($pg, 'SELECT lb_tls() AS t'));
507+ $test->assertSame('tls-server-end-point', $one($pg, 'SELECT lb_cb() AS cb'));
508+ $test->assertSame('諏訪子', $one($pg, "SELECT '諏訪子' AS n"));
509+
510+ $pg = $open(['tls' => $tls, 'channel_binding' => 'disable']);
511+ $test->assertSame('SCRAM-SHA-256', $pg->getServerInfo()['authMethod']);
512+ $test->assertSame('none', $one($pg, 'SELECT lb_cb() AS cb'));
513+
514+ $pg = $open(['username' => 'tlsonly', 'password' => 'tlsひみつ', 'tls' => $tls, 'channel_binding' => 'require']);
515+ $test->assertSame('tls-server-end-point', $one($pg, 'SELECT lb_cb() AS cb'));
516+ $e = $catch(fn() => $open(['username' => 'tlsonly', 'password' => 'tlsひみつ']));
517+ $test->assertSame('28000', $e->sqlState);
518+ $test->assertStringContains('no encryption', $e->getMessage());
519+
520+ // TLSなしでチャネルバインディングを求める
521+ $e = $catch(fn() => $open(['channel_binding' => 'require']));
522+ $test->assertStringContains('チャネルバインディングが使えません', $e->getMessage());
523+
524+ // 信頼していない証明書
525+ $e = $catch(fn() => $open(['tls' => true]));
526+ $test->assertTrue($e instanceof DbException);
527+ $test->assertStringContains('TLSの開始に失敗', $e->getMessage());
528+ });
529+
530+ $test->it('未対応の認証方式・無いデータベースは例外のはず', function($test) use ($open, $catch): void {
531+ $e = $catch(fn() => $open(['username' => 'gss']));
532+ $test->assertStringContains('未対応の認証方式: 7', $e->getMessage());
533+ $e = $catch(fn() => $open(['dbname' => 'nodb']));
534+ $test->assertSame('3D000', $e->sqlState);
535+ $test->assertSame('接続に失敗: 3D000 - database "nodb" does not exist', $e->getMessage());
536+ });
537+
538+ $test->it('色々な型を変換するはず', function($test) use ($open): void {
539+ $row = $open()->query('SELECT lb_types()')['rows'][0];
540+ // NANは同じにならないので別に
541+ $test->assertTrue(is_nan($row['nan']));
542+ $test->assertSame(1.5, $row['floats'][0]);
543+ $test->assertTrue(is_nan($row['floats'][1]));
544+ unset($row['nan'], $row['floats']);
545+ $test->assertSame([
546+ 'i2' => -5, 'i4' => 42, 'i8' => PHP_INT_MAX, 'num' => '12345678901234567890.5', 'f4' => 2.5, 'ninf' => -INF, 'big' => 1.0E+100,
547+ 't' => true, 'f' => false, 'bin' => "\x00\xff\\", 'txts' => ['a,b', 'c"d', null, 'NULL', '', ' x '], 'ints' => [[1, 2], [3, null]],
548+ 'ranged' => [1, 2], 'bools' => [true, false], 'bins' => ["\x01"], 'j' => '{"a": [1, "x"]}', 'jb' => '{"b": 2}',
549+ 'd' => '2026-01-02', 'ts' => '2026-01-02 03:04:05.123456+09', 'u' => 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'o' => 1259, 'n' => null,
550+ 'v' => '日本語', 'i8s' => 2147483648, 'empty' => [], 'esc' => "\x00\xff\\A", 'big8' => '99999999999999999999',
551+ ], $row);
552+ });
553+
554+ $test->it('結果の途中の非同期のメッセージを処理するはず', function($test) use ($open, $one): void {
555+ $pg = $open();
556+ $test->assertSame('Asia/Tokyo', $pg->getServerInfo()['TimeZone']);
557+ $test->assertSame('ok', $one($pg, 'SELECT lb_async()'));
558+ $test->assertSame('UTC', $pg->getServerInfo()['TimeZone']);
559+ $pg->query("SET TimeZone = 'Europe/Amsterdam'");
560+ $test->assertSame('Europe/Amsterdam', $pg->getServerInfo()['TimeZone']);
561+ });
562+
563+ $test->it('COPY TO STDOUTのデータは無視するはず', function($test) use ($open): void {
564+ $res = $open()->query('COPY lb_test TO STDOUT');
565+ $test->assertSame('COPY 1', $res['command']);
566+ $test->assertSame(1, $res['affectedRows']);
567+ $test->assertSame([], $res['rows']);
568+ });
569+
570+ $test->it('FATAL・切断・タイムアウトで接続を閉じるはず', function($test) use ($open, $catch): void {
571+ $pg = $open();
572+ $pg->beginTransaction();
573+ $e = $catch(fn() => $pg->query('SELECT lb_fatal()'));
574+ $test->assertSame('57P01', $e->sqlState);
575+ $test->assertFalse($pg->isConnected());
576+ $test->assertFalse($pg->inTransaction());
577+
578+ $pg = $open();
579+ $e = $catch(fn() => $pg->query('SELECT lb_disconnect()'));
580+ $test->assertSame('サーバーとの接続が切れました', $e->getMessage());
581+ $test->assertFalse($pg->isConnected());
582+ // 再接続
583+ $test->assertTrue($pg->connect());
584+ $test->assertTrue($pg->ping());
585+
586+ $pg = $open(['timeout' => 1]);
587+ $start = microtime(true);
588+ $e = $catch(fn() => $pg->query('SELECT lb_sleep(2.5) AS z'));
589+ $test->assertStringContains('タイムアウト(1秒)', $e->getMessage());
590+ $test->assertTrue(microtime(true) - $start < 2.3, 'タイムアウトが遅い');
591+ $test->assertFalse($pg->isConnected());
592+ });
593+
594+ $test->it('デバッグでメッセージを記録するはず', function($test) use ($open): void {
595+ $pg = $open();
596+ $pg->setDebug(true);
597+ ob_start();
598+ $pg->query('SELECT 1 AS a');
599+ $out = ob_get_clean();
600+ $pg->setDebug(false);
601+ $test->assertStringContains('=== SEND Q', $out);
602+ $log = $pg->getPacketLog();
603+ $test->assertSame(['SEND', 'Q', "SELECT 1 AS a\0"], [$log[0]['direction'], $log[0]['type'], $log[0]['data']]);
604+ $test->assertSame(['T', 'D', 'C', 'Z'], array_column(array_slice($log, 1), 'type'));
605+ });
606+
607+ $srv->stop();
608+});
609+
610+$test->describe('Pgsql(偽物の悪いサーバー)', function($test) use ($catch, $cert): void {
611+ $tls = ['cafile' => $cert['ca'], 'peer_name' => 'localhost'];
612+ // 接続して、出来たらSQLの結果、出来なかったら例外
613+ $connect = function(array $args, array $o = [], string $sql = 'SELECT 1 AS x'): mixed {
614+ $srv = MockServer::start('pg-server.php', $args);
615+ if ($srv === null) return new \RuntimeException('起動出来ない');
616+ try {
617+ $pg = new Pgsql($o + ['host' => '127.0.0.1', 'port' => $srv->port, 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb', 'timeout' => 5]);
618+ $pg->connect();
619+ return array_values($pg->query($sql)['rows'][0])[0];
620+ } catch (\Throwable $e) {
621+ return $e;
622+ } finally {
623+ $srv->stop();
624+ }
625+ };
626+
627+ $test->it('TLSに対応していないサーバーでTLSを求めたら例外のはず', function($test) use ($connect): void {
628+ $e = $connect([], ['tls' => true]);
629+ $test->assertTrue($e instanceof DbException);
630+ $test->assertStringContains('サーバーがTLSに対応していません', $e->getMessage());
631+ });
632+
633+ $test->it('サーバーの署名・nonceが不正なら例外のはず', function($test) use ($connect): void {
634+ $e = $connect(['', '', 'badsig']);
635+ $test->assertTrue($e instanceof DbException);
636+ $test->assertStringContains('サーバーの署名が不正です', $e->getMessage());
637+ $e = $connect(['', '', 'badnonce']);
638+ $test->assertStringContains('不正なSCRAMのメッセージ', $e->getMessage());
639+ $e = $connect(['', '', 'nofinal']);
640+ $test->assertStringContains('サーバーがSCRAMを完了しませんでした', $e->getMessage());
641+ $test->assertSame(1, $connect(['', '', 'nofinal'], ['username' => 'trust']));
642+ });
643+
644+ $test->it('チャネルバインディングを出さないTLSのサーバー', function($test) use ($connect, $cert, $tls): void {
645+ // preferなら「y」(クライアントは対応している)で接続する
646+ $test->assertSame('y', $connect([$cert['cert'], $cert['key'], 'nocb'], ['tls' => $tls], 'SELECT lb_cb() AS cb'));
647+ $e = $connect([$cert['cert'], $cert['key'], 'nocb'], ['tls' => $tls, 'channel_binding' => 'require']);
648+ $test->assertStringContains('チャネルバインディングが使えません', $e->getMessage());
649+ });
650+});
651+
652+$test->describe('Pgsql(本物のサーバー、LB_TEST_PGSQL)', function($test) use ($common): void {
653+ $url = (string)getenv('LB_TEST_PGSQL');
654+ $u = $url === '' ? false : parse_url($url);
655+ if ($u === false) {
656+ $test->skipAll('LB_TEST_PGSQL(pgsql://ユーザー:パスワード@ホスト:ポート/データベース)が無い');
657+ return;
658+ }
659+ parse_str($u['query'] ?? '', $q);
660+ $info = fn(array $o = []): array => $o + [
661+ 'host' => $u['host'] ?? '127.0.0.1',
662+ 'port' => $u['port'] ?? 5432,
663+ 'username' => urldecode($u['user'] ?? ''),
664+ 'password' => urldecode($u['pass'] ?? ''),
665+ 'dbname' => ltrim(urldecode($u['path'] ?? ''), '/'),
666+ 'tls' => isset($q['cafile']) ? ['cafile' => $q['cafile']] : false,
667+ 'timeout' => 10,
668+ ];
669+ $common($test, $info);
670+});
671+
672+$test->printSummary();