MySQLドライバー

守矢諏訪子 <suwako@076.moe>、2026-11-12 12:02
コミット 33021bffe2979002f1c5686ff81eb3fec501f2b6 ・親 fc1e074 ・ファイルを見る

11個のファイル、+3933 -7

変更 README-JP.md
@@ -207,6 +207,23 @@
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 コンテキストオプションの配列)。
213+```php
214+$db = new \Std\Lib\Db();
215+$rows = $db->query('SELECT * FROM users WHERE id = ?', [1])['rows'];
216+$id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId'];
217+$db->transaction(function(\Std\Lib\Db $db): void {
218+ $db->query('UPDATE users SET points = points - ? WHERE id = ?', [100, 1]);
219+ $db->query('UPDATE users SET points = points + ? WHERE id = ?', [100, 2]);
220+});
221+```
222+* 最初のクエリで接続します。パラメータ付きのクエリはプリペアドステートメントで実行するので、値をエスケープする必要はありません。
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 か UNIX ソケットでなければ `php_openssl` が必要)、`mysql_clear_password`(TLS か UNIX ソケットのみ)。MariaDB の `client_ed25519`、圧縮、`LOAD DATA LOCAL INFILE` は未対応です。
226+
210227 ### コメント
211228 `config/config.php` で `COMMENTS_ENABLED` を `true` にすると、全部のブログ記事の下にコメント欄が表示されます(書き込みには `AUTH_ENABLED` も必要)。
212229 * ログイン中のユーザーはコメント、返信、いいねが出来ます(Markdown:太字、斜体、取り消し線、コード、引用、リスト、リンク)。自分のコメントは削除出来、管理者とスタッフは全部のコメントを削除出来ます。JavaScript は不要です。
変更 README.md
@@ -205,6 +205,23 @@
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']`).
211+```php
212+$db = new \Std\Lib\Db();
213+$rows = $db->query('SELECT * FROM users WHERE id = ?', [1])['rows'];
214+$id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId'];
215+$db->transaction(function(\Std\Lib\Db $db): void {
216+ $db->query('UPDATE users SET points = points - ? WHERE id = ?', [100, 1]);
217+ $db->query('UPDATE users SET points = points + ? WHERE id = ?', [100, 2]);
218+});
219+```
220+* 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` (both need `php_openssl` unless TLS or a UNIX socket is used), and `mysql_clear_password` (TLS or UNIX socket only). MariaDB's `client_ed25519`, compression and `LOAD DATA LOCAL INFILE` are not supported.
224+
208225 ### Comments
209226 Set `COMMENTS_ENABLED` to `true` in `config/config.php` to show a comment section under every blog post (posting needs `AUTH_ENABLED`).
210227 * Logged-in users can comment, reply and like (Markdown: bold, italics, strikethrough, code, quotes, lists and links). Users can delete their own comments, and admins and staff can delete any comment. No JavaScript is needed.
@@ -73,6 +73,22 @@
7373 'pubkey' => ROOT.'/public/static/pub.pem',
7474 'privkey' => ROOT.'/data/priv.pem',
7575 ]);
76+// データベース(DB_ENABLEDが必要)。driver:mysql(MariaDBも)
77+// socket:UNIXソケットのパス(指定するとhost・portの代わりに使う、例:/var/run/mysql/mysql.sock)
78+// tls:trueで証明書を確認するTLS、又はsslのコンテキストオプションの配列(例:['cafile' => '/etc/ssl/ca.pem'])
79+// debug:送受信したパケットを出力する
80+define('DBINFO', [
81+ 'driver' => 'mysql',
82+ 'host' => '127.0.0.1',
83+ 'port' => 3306,
84+ 'socket' => '',
85+ 'username' => '',
86+ 'password' => '',
87+ 'dbname' => '',
88+ 'charset' => 'utf8mb4',
89+ 'tls' => false,
90+ 'debug' => false,
91+]);
7692 define('OPENPROVIDER', [
7793 'username' => '',
7894 'password' => '',
@@ -88,6 +104,7 @@
88104 define('CURL_ENABLED', true);
89105 define('AUTH_ENABLED', false);
90106 define('OPENPROVIDER_ENABLED', false);
107+define('DB_ENABLED', false); // データベース(DBINFOを参照)
91108 define('AUTH_REGISTER_ENABLED', false);
92109 define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要)
93110 define('AUTH_2FA_ENABLED', false); // 二段階認証(認証アプリのコード)を使えるようにする(各ユーザーがプロフィールの編集から有効にする)
@@ -0,0 +1,212 @@
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;
39+
40+use Std\Lib\Db\DbInterface;
41+use Std\Lib\Db\Mysql;
42+
43+/**
44+ * データベース
45+ *
46+ * DBINFOの「driver」でドライバ(Std\Lib\Db\の中)を選ぶ。接続は最初のクエリの時に自動で行う。
47+ *
48+ * 使い方:
49+ * $db = new Db();
50+ * $users = $db->query('SELECT * FROM users WHERE role > ?', [0])['rows'];
51+ * $id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId'];
52+ * $db->transaction(function(Db $db): void {
53+ * $db->query('UPDATE users SET points = points - ? WHERE id = ?', [100, 1]);
54+ * $db->query('UPDATE users SET points = points + ? WHERE id = ?', [100, 2]);
55+ * });
56+ */
57+class Db {
58+ private const SUPPORTED = [
59+ 'mysql' => Mysql::class,
60+ 'mariadb' => Mysql::class,
61+ ];
62+
63+ public string $driverName;
64+ private DbInterface $driver;
65+
66+ /**
67+ * コンストラクタ
68+ *
69+ * @param array|null $info 設定(省略するとDBINFO)。driver(mysql等)、その他はドライバ毎
70+ * @throws \Exception $infoを省略し、DB_ENABLEDが無効な場合
71+ * @throws \RuntimeException 未対応のドライバの場合
72+ */
73+ public function __construct(?array $info = null) {
74+ if ($info === null && !(defined('DB_ENABLED') && DB_ENABLED)) {
75+ throw new \Exception('データベースは無効です。');
76+ }
77+
78+ $info ??= DBINFO;
79+ $this->driverName = strtolower((string)($info['driver'] ?? 'mysql'));
80+ $class = self::SUPPORTED[$this->driverName] ?? null;
81+ if (!$class) throw new \RuntimeException("未対応のデータベース: {$this->driverName}");
82+ $this->driver = new $class($info);
83+ }
84+
85+ /**
86+ * ドライバに対応しているかどうか
87+ *
88+ * @param string $driver ドライバ名
89+ * @return bool
90+ */
91+ public static function isSupported(string $driver): bool {
92+ return isset(self::SUPPORTED[strtolower($driver)]);
93+ }
94+
95+ /**
96+ * ドライバ(ドライバ毎の機能用、例:MysqlのsetDebug())
97+ *
98+ * @return DbInterface
99+ */
100+ public function driver(): DbInterface {
101+ return $this->driver;
102+ }
103+
104+ public function connect(): bool {
105+ return $this->driver->connect();
106+ }
107+
108+ public function close(): void {
109+ $this->driver->close();
110+ }
111+
112+ public function isConnected(): bool {
113+ return $this->driver->isConnected();
114+ }
115+
116+ public function ping(): bool {
117+ return $this->driver->ping();
118+ }
119+
120+ /**
121+ * SQLを実行する
122+ *
123+ * パラメータがある場合はプリペアドステートメント(プレースホルダは「?」)で実行するので、
124+ * 値をエスケープする必要はない。
125+ *
126+ * @param string $sql SQL
127+ * @param array $params パラメータ(順番通り)
128+ * @return array 結果(fields、rows、affectedRows、insertId、warningCount等、DbInterfaceを参照)
129+ * @throws Db\DbException 失敗した場合
130+ */
131+ public function query(string $sql, array $params = []): array {
132+ $this->driver->connect();
133+ if (empty($params)) return $this->driver->query($sql);
134+
135+ $id = $this->driver->prepare($sql);
136+ try {
137+ return $this->driver->execute($id, $params);
138+ } finally {
139+ $this->driver->demolish($id);
140+ }
141+ }
142+
143+ /**
144+ * 何回も実行するSQLを準備する(終わったらdemolish())
145+ *
146+ * @param string $sql SQL
147+ * @return int ステートメントID
148+ * @throws Db\DbException
149+ */
150+ public function prepare(string $sql): int {
151+ $this->driver->connect();
152+ return $this->driver->prepare($sql);
153+ }
154+
155+ public function execute(int $statementId, array $params = []): array {
156+ return $this->driver->execute($statementId, $params);
157+ }
158+
159+ public function demolish(int $statementId): bool {
160+ return $this->driver->demolish($statementId);
161+ }
162+
163+ /**
164+ * トランザクションの中で実行する
165+ *
166+ * 例外が出たらロールバックして、その例外を投げ直す。それ以外はコミットする。
167+ *
168+ * @param callable $fn function(Db $db): mixed
169+ * @return mixed $fnの戻り値
170+ * @throws \Throwable $fnの例外
171+ */
172+ public function transaction(callable $fn): mixed {
173+ $this->beginTransaction();
174+
175+ try {
176+ $res = $fn($this);
177+ } catch (\Throwable $e) {
178+ if ($this->driver->isConnected()) {
179+ try {
180+ $this->driver->rollback();
181+ } catch (\Throwable $ignore) {
182+ // 接続が切れた場合等:元の例外を優先する
183+ }
184+ }
185+ throw $e;
186+ }
187+
188+ $this->driver->commit();
189+ return $res;
190+ }
191+
192+ public function beginTransaction(): bool {
193+ $this->driver->connect();
194+ return $this->driver->beginTransaction();
195+ }
196+
197+ public function commit(): bool {
198+ return $this->driver->commit();
199+ }
200+
201+ public function rollback(): bool {
202+ return $this->driver->rollback();
203+ }
204+
205+ public function inTransaction(): bool {
206+ return $this->driver->inTransaction();
207+ }
208+
209+ public function quoteIdentifier(string $name): string {
210+ return $this->driver->quoteIdentifier($name);
211+ }
212+}
@@ -0,0 +1,53 @@
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+/**
41+ * データベースのエラー
42+ *
43+ * getCode()はサーバーのエラーコード(MySQLの1062=重複等)、無い場合は0。
44+ */
45+class DbException extends \Exception {
46+ /** SQLSTATE(例:23000)、無い場合は空 */
47+ public string $sqlState;
48+
49+ public function __construct(string $message, int $code = 0, string $sqlState = '', ?\Throwable $previous = null) {
50+ parent::__construct($message, $code, $previous);
51+ $this->sqlState = $sqlState;
52+ }
53+}
@@ -0,0 +1,90 @@
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+/**
41+ * データベースのドライバ(Mysql等)
42+ *
43+ * query()・execute()の結果:
44+ * fields 列の説明の配列(name、type等、ドライバ毎)
45+ * rows 行の配列(列名 => 値。整数はint、浮動小数点数はfloat、NULLはnull、その他は文字列)
46+ * affectedRows INSERT・UPDATE・DELETEで影響を受けた行数
47+ * insertId AUTO_INCREMENTで作られたID
48+ * warningCount 警告の数
49+ * more (結果が複数ある場合)2つ目からの結果
50+ */
51+interface DbInterface {
52+ /** 接続する(接続済みなら何もしない、失敗はDbException) */
53+ public function connect(): bool;
54+
55+ /** 接続を閉じる */
56+ public function close(): void;
57+
58+ /** 接続しているかどうか */
59+ public function isConnected(): bool;
60+
61+ /** 接続がまだ生きているか確認する */
62+ public function ping(): bool;
63+
64+ /** SQLをそのまま実行する(失敗はDbException) */
65+ public function query(string $query): array;
66+
67+ /** プリペアドステートメント(プレースホルダは「?」)を準備し、IDを返す */
68+ public function prepare(string $query): int;
69+
70+ /** プリペアドステートメントを実行する */
71+ public function execute(int $statementId, array $params = []): array;
72+
73+ /** プリペアドステートメントを解放する */
74+ public function demolish(int $statementId): bool;
75+
76+ /** トランザクションを開始する */
77+ public function beginTransaction(): bool;
78+
79+ /** トランザクションをコミットする */
80+ public function commit(): bool;
81+
82+ /** トランザクションをロールバックする */
83+ public function rollback(): bool;
84+
85+ /** トランザクション中かどうか */
86+ public function inTransaction(): bool;
87+
88+ /** 識別子(テーブル名・列名)を囲む */
89+ public function quoteIdentifier(string $name): string;
90+}
@@ -0,0 +1,1963 @@
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+/**
41+ * PHP純正のMySQL(MariaDBも)ドライバ。mysqli・pdo_mysql・socketsのモジュールが不要。
42+ *
43+ * 対応:TCP・UNIXソケット、TLS、認証プラグイン(mysql_native_password、caching_sha2_password、
44+ * sha256_password、mysql_clear_password)、テキスト・バイナリ(プリペアドステートメント)プロトコル、
45+ * 16MiB以上のパケット、複数の結果(CALL等)、トランザクション
46+ * 未対応:LOAD DATA LOCAL INFILE、MariaDBのclient_ed25519、圧縮
47+ *
48+ * 普通はStd\Lib\Db経由で使う。直接使う場合:
49+ * $my = new Mysql(['host' => '127.0.0.1', 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb']);
50+ * $my->connect();
51+ * $id = $my->prepare('SELECT * FROM users WHERE id = ?');
52+ * $res = $my->execute($id, [1]); // $res['rows']
53+ * $my->demolish($id);
54+ */
55+class Mysql implements DbInterface {
56+ // コマンド
57+ private const COM_QUIT = 0x01;
58+ private const COM_INIT_DB = 0x02;
59+ private const COM_QUERY = 0x03;
60+ private const COM_PING = 0x0E;
61+ private const COM_STMT_PREPARE = 0x16;
62+ private const COM_STMT_EXECUTE = 0x17;
63+ private const COM_STMT_CLOSE = 0x19;
64+
65+ // クライアント機能フラグ
66+ private const CLIENT_LONG_PASSWORD = 0x00000001;
67+ private const CLIENT_LONG_FLAG = 0x00000004;
68+ private const CLIENT_CONNECT_WITH_DB = 0x00000008;
69+ private const CLIENT_PROTOCOL_41 = 0x00000200;
70+ private const CLIENT_SSL = 0x00000800;
71+ private const CLIENT_TRANSACTIONS = 0x00002000;
72+ private const CLIENT_SECURE_CONNECTION = 0x00008000;
73+ private const CLIENT_MULTI_RESULTS = 0x00020000;
74+ private const CLIENT_PS_MULTI_RESULTS = 0x00040000;
75+ private const CLIENT_PLUGIN_AUTH = 0x00080000;
76+ private const CLIENT_PLUGIN_AUTH_LENENC = 0x00200000;
77+
78+ // サーバーの状況
79+ private const SERVER_STATUS_IN_TRANS = 0x0001;
80+ private const SERVER_MORE_RESULTS_EXISTS = 0x0008;
81+
82+ // 列のフラグ
83+ private const UNSIGNED_FLAG = 0x0020;
84+
85+ // 列の種類
86+ public const TYPE_DECIMAL = 0x00;
87+ public const TYPE_TINY = 0x01;
88+ public const TYPE_SHORT = 0x02;
89+ public const TYPE_LONG = 0x03;
90+ public const TYPE_FLOAT = 0x04;
91+ public const TYPE_DOUBLE = 0x05;
92+ public const TYPE_NULL = 0x06;
93+ public const TYPE_TIMESTAMP = 0x07;
94+ public const TYPE_LONGLONG = 0x08;
95+ public const TYPE_INT24 = 0x09;
96+ public const TYPE_DATE = 0x0A;
97+ public const TYPE_TIME = 0x0B;
98+ public const TYPE_DATETIME = 0x0C;
99+ public const TYPE_YEAR = 0x0D;
100+ public const TYPE_NEWDATE = 0x0E;
101+ public const TYPE_VARCHAR = 0x0F;
102+ public const TYPE_BIT = 0x10;
103+ public const TYPE_JSON = 0xF5;
104+ public const TYPE_NEWDECIMAL = 0xF6;
105+ public const TYPE_ENUM = 0xF7;
106+ public const TYPE_SET = 0xF8;
107+ public const TYPE_TINY_BLOB = 0xF9;
108+ public const TYPE_MEDIUM_BLOB = 0xFA;
109+ public const TYPE_LONG_BLOB = 0xFB;
110+ public const TYPE_BLOB = 0xFC;
111+ public const TYPE_VAR_STRING = 0xFD;
112+ public const TYPE_STRING = 0xFE;
113+ public const TYPE_GEOMETRY = 0xFF;
114+
115+ // 文字セット => 照合順序のID(ハンドシェイクで送る)
116+ private const CHARSETS = [
117+ 'utf8mb4' => 45,
118+ 'utf8mb3' => 33,
119+ 'utf8' => 33,
120+ 'latin1' => 8,
121+ 'ascii' => 11,
122+ 'binary' => 63,
123+ 'ujis' => 12,
124+ 'sjis' => 13,
125+ 'cp932' => 95,
126+ 'eucjpms' => 97,
127+ ];
128+
129+ private const PLUGINS = ['mysql_native_password', 'caching_sha2_password', 'sha256_password', 'mysql_clear_password'];
130+ private const MAX_PACKET = 0x01000000;
131+
132+ /** @var resource|null */
133+ private $socket = null;
134+ private bool $connected = false;
135+ private bool $secure = false;
136+ private bool $debug = false;
137+ private array $packetLog = [];
138+ private array $serverInfo = [];
139+ public string $username;
140+ private string $password;
141+ public string $dbname;
142+ public string $host;
143+ private int $port;
144+ private string $socketPath;
145+ private string $charset;
146+ private bool|array $tls;
147+ private int $timeout;
148+ private string $pubkey;
149+ private int $seq = 0;
150+ private int $status = 0;
151+ private string $plugin = '';
152+
153+ private array $prepared = [];
154+
155+ /**
156+ * コンストラクタ
157+ *
158+ * 接続に必要な設定を初期化します。$infoを省略するとDBINFOを使います。
159+ *
160+ * @param array|null $info host、port(3306)、socket(UNIXソケットのパス、指定するとhost・portの代わりに使う)、
161+ * username、password、dbname、charset(utf8mb4)、
162+ * tls(true=証明書を確認するTLS、又はstream_context_createのsslオプションの配列)、
163+ * timeout(秒、10)、pubkey(caching_sha2_password・sha256_password用のサーバーの公開鍵のPEMファイル、任意)、debug
164+ * @throws \Exception $infoを省略し、DB_ENABLEDが無効な場合
165+ */
166+ public function __construct(?array $info = null) {
167+ if ($info === null && !(defined('DB_ENABLED') && DB_ENABLED)) {
168+ throw new \Exception('データベースは無効です。');
169+ }
170+
171+ $info ??= DBINFO;
172+ $this->host = (string)($info['host'] ?? '127.0.0.1');
173+ $this->port = (int)($info['port'] ?? 3306);
174+ $this->socketPath = (string)($info['socket'] ?? '');
175+ $this->username = (string)($info['username'] ?? '');
176+ $this->password = (string)($info['password'] ?? '');
177+ $this->dbname = (string)($info['dbname'] ?? '');
178+ $this->charset = strtolower((string)($info['charset'] ?? 'utf8mb4'));
179+ $this->tls = $info['tls'] ?? false;
180+ $this->timeout = max(1, (int)($info['timeout'] ?? 10));
181+ $this->pubkey = (string)($info['pubkey'] ?? '');
182+ $this->debug = (bool)($info['debug'] ?? false);
183+
184+ if (!isset(self::CHARSETS[$this->charset])) {
185+ throw new \InvalidArgumentException("未対応の文字セット: {$this->charset}");
186+ }
187+ }
188+
189+ /**
190+ * デストラクタ
191+ *
192+ * オブジェクト破棄時に接続を閉じます。
193+ */
194+ public function __destruct() {
195+ $this->close();
196+ }
197+
198+ /**
199+ * デバッギングの有無
200+ *
201+ * 有効にすると、送受信したパケットを出力し、ログに残します。
202+ *
203+ * @param bool $debug デバッグモードを有効にするかどうか
204+ * @return Mysql 自身を返す(メソッドチェーン用)
205+ */
206+ public function setDebug(bool $debug): Mysql {
207+ $this->debug = $debug;
208+ return $this;
209+ }
210+
211+ /**
212+ * パケットログの取得
213+ *
214+ * @return array 送信および受信したパケットのログ
215+ */
216+ public function getPacketLog(): array {
217+ return $this->packetLog;
218+ }
219+
220+ /**
221+ * サーバーの情報(挨拶パケットから)
222+ *
223+ * @return array protocol、version、threadId、capabilities、charset、status、plugin(サーバーの既定)、authPlugin(実際に使った)
224+ */
225+ public function getServerInfo(): array {
226+ $info = $this->serverInfo;
227+ unset($info['scramble']);
228+ $info['authPlugin'] = $this->plugin;
229+ return $info;
230+ }
231+
232+ /**
233+ * MySQLサーバーに接続
234+ *
235+ * サーバーに接続後、(設定された場合)TLSを開始し、認証とデータベース選択を行います。
236+ *
237+ * @return bool 接続成功時はtrue
238+ * @throws DbException 接続または認証に失敗した場合
239+ */
240+ public function connect(): bool {
241+ if ($this->connected) return true;
242+
243+ $target = $this->socketPath !== '' ? "unix://{$this->socketPath}" : "tcp://{$this->host}:{$this->port}";
244+ $errno = 0;
245+ $errstr = '';
246+ // 自分のコンテキスト(既定のコンテキストだと、TLSの設定が他の接続に残る)
247+ $socket = @stream_socket_client($target, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, stream_context_create());
248+ if ($socket === false) {
249+ $this->fail("ソケットに接続に失敗: {$errstr} ({$errno})");
250+ }
251+ stream_set_timeout($socket, $this->timeout);
252+ $this->socket = $socket;
253+ $this->secure = $this->socketPath !== '';
254+ $this->seq = 0;
255+
256+ try {
257+ $this->parseServerGreeting($this->readPacket());
258+ $this->authenticate();
259+ $this->connected = true;
260+
261+ // CONNECT_WITH_DBに対応していないサーバー
262+ if ($this->dbname !== '' && !($this->serverInfo['capabilities'] & self::CLIENT_CONNECT_WITH_DB)) {
263+ $this->selectDatabase($this->dbname);
264+ }
265+ } catch (\Throwable $e) {
266+ $this->drop();
267+ throw $e;
268+ }
269+
270+ return true;
271+ }
272+
273+ /**
274+ * 接続を閉じる
275+ *
276+ * COM_QUITコマンドを送信し、ソケットを閉じます。
277+ *
278+ * @return void
279+ */
280+ public function close(): void {
281+ if ($this->socket && $this->connected) {
282+ try {
283+ $this->sendCommand(self::COM_QUIT);
284+ } catch (\Throwable $e) {
285+ // 既に切れている
286+ }
287+ }
288+ $this->drop();
289+ }
290+
291+ /**
292+ * 接続しているかどうか
293+ *
294+ * @return bool
295+ */
296+ public function isConnected(): bool {
297+ return $this->connected;
298+ }
299+
300+ /**
301+ * 接続がまだ生きているか確認する(COM_PING)
302+ *
303+ * @return bool サーバーが応答したらtrue
304+ */
305+ public function ping(): bool {
306+ if (!$this->connected) return false;
307+
308+ try {
309+ $this->sendCommand(self::COM_PING);
310+ $res = $this->readPacket();
311+ } catch (\Throwable $e) {
312+ return false;
313+ }
314+
315+ if (ord($res[0] ?? "\xFF") !== 0x00) return false;
316+ $this->parseOkPacket($res);
317+ return true;
318+ }
319+
320+ /**
321+ * 利用するデータベースを選択する
322+ *
323+ * COM_INIT_DBコマンドを使用してデータベースを選択します。
324+ *
325+ * @param string $database データベース名
326+ * @return bool 成功時はtrue
327+ * @throws DbException データベース選択に失敗した場合
328+ */
329+ public function selectDatabase(string $database): bool {
330+ $this->requireConnection();
331+
332+ $this->sendCommand(self::COM_INIT_DB, $database);
333+ $res = $this->readPacket();
334+
335+ if (ord($res[0]) === 0xFF) {
336+ $this->failPacket('データベースの選択に失敗', $res);
337+ }
338+
339+ $this->parseOkPacket($res);
340+ $this->dbname = $database;
341+ return true;
342+ }
343+
344+ /**
345+ * プリペアドステートメントの準備
346+ *
347+ * SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。
348+ *
349+ * @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?")
350+ * @return int 成功時はステートメントID
351+ * @throws DbException 準備に失敗した場合
352+ */
353+ public function prepare(string $query): int {
354+ $this->requireConnection();
355+
356+ $this->sendCommand(self::COM_STMT_PREPARE, $query);
357+ $res = $this->readPacket();
358+
359+ if (ord($res[0]) === 0xFF) {
360+ $this->failPacket('準備に失敗', $res);
361+ }
362+
363+ // 0x00、ステートメントID(4)、列数(2)、パラメータ数(2)、予約(1)、警告数(2)
364+ $head = unpack('Vid/vcols/vparams', substr($res, 1, 8));
365+ $statementId = $head['id'];
366+
367+ $this->prepared[$statementId] = [
368+ 'num_params' => $head['params'],
369+ 'num_columns' => $head['cols'],
370+ 'params' => [],
371+ 'columns' => [],
372+ ];
373+
374+ if ($head['params'] > 0) {
375+ for ($i = 0; $i < $head['params']; $i++) {
376+ $this->prepared[$statementId]['params'][] = $this->parseFieldPacket($this->readPacket());
377+ }
378+ $this->readEof();
379+ }
380+
381+ if ($head['cols'] > 0) {
382+ for ($i = 0; $i < $head['cols']; $i++) {
383+ $this->prepared[$statementId]['columns'][] = $this->parseFieldPacket($this->readPacket());
384+ }
385+ $this->readEof();
386+ }
387+
388+ return $statementId;
389+ }
390+
391+ /**
392+ * プリペアドステートメントの実行
393+ *
394+ * 指定されたステートメントIDとパラメータを使用してクエリを実行します。
395+ * int=BIGINT、float=DOUBLE、bool=TINYINT、null=NULL、DateTimeInterface=「Y-m-d H:i:s.u」、
396+ * BackedEnum=その値、その他=文字列として送ります。
397+ *
398+ * @param int $statementId プリペアドステートメントID
399+ * @param array $params パラメータ値の配列(順番通り)
400+ * @return array 結果(DbInterface::query()と同じ形)
401+ * @throws DbException 実行に失敗した場合
402+ */
403+ public function execute(int $statementId, array $params = []): array {
404+ $this->requireConnection();
405+
406+ if (!isset($this->prepared[$statementId])) {
407+ $this->fail("不正なステートメントID: {$statementId}");
408+ }
409+
410+ $stmtInfo = $this->prepared[$statementId];
411+ $params = array_values($params);
412+ if (count($params) !== $stmtInfo['num_params']) {
413+ $this->fail("パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params));
414+ }
415+
416+ $data = chr(self::COM_STMT_EXECUTE);
417+ $data .= pack('V', $statementId);
418+ $data .= chr(0); // 0 = カーソルなし
419+ $data .= pack('V', 1); // 繰り返し数(常に1)
420+
421+ if ($stmtInfo['num_params'] > 0) {
422+ $nullBitmap = str_repeat("\0", intdiv($stmtInfo['num_params'] + 7, 8));
423+ $paramTypes = '';
424+ $paramValues = '';
425+
426+ foreach ($params as $k => $param) {
427+ if ($param instanceof \BackedEnum) $param = $param->value;
428+ if ($param instanceof \DateTimeInterface) $param = $param->format('Y-m-d H:i:s.u');
429+
430+ if ($param === null) {
431+ $nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7)));
432+ $paramTypes .= pack('v', self::TYPE_NULL);
433+ } else if (is_bool($param)) {
434+ $paramTypes .= pack('v', self::TYPE_TINY);
435+ $paramValues .= chr((int)$param);
436+ } else if (is_int($param)) {
437+ $paramTypes .= pack('v', self::TYPE_LONGLONG);
438+ $paramValues .= pack('P', $param);
439+ } else if (is_float($param)) {
440+ $paramTypes .= pack('v', self::TYPE_DOUBLE);
441+ $paramValues .= pack('e', $param);
442+ } else if (is_scalar($param) || $param instanceof \Stringable) {
443+ $param = (string)$param;
444+ $paramTypes .= pack('v', self::TYPE_VAR_STRING);
445+ $paramValues .= $this->encodeLengthEncodedInteger(strlen($param)).$param;
446+ } else {
447+ $this->fail("パラメータ{$k}の型は未対応です: ".get_debug_type($param));
448+ }
449+ }
450+
451+ $data .= $nullBitmap.chr(1).$paramTypes.$paramValues; // 1 = 新しいパラメータの種類
452+ }
453+
454+ $this->seq = 0;
455+ $this->sendPacket($data);
456+ return $this->readResult($this->readPacket(), true, '実行に失敗');
457+ }
458+
459+ /**
460+ * プリペアドステートメントの解放
461+ *
462+ * 指定されたステートメントIDを解放し、リソースをクリーンアップします。
463+ *
464+ * @param int $statementId プリペアドステートメントID
465+ * @return bool 成功時はtrue
466+ */
467+ public function demolish(int $statementId): bool {
468+ if (!isset($this->prepared[$statementId])) return false;
469+ unset($this->prepared[$statementId]);
470+ if (!$this->connected) return false;
471+
472+ $this->sendCommand(self::COM_STMT_CLOSE, pack('V', $statementId)); // 返事はない
473+ return true;
474+ }
475+
476+ /**
477+ * SQLクエリの実行
478+ *
479+ * COM_QUERYを使用してSQLクエリを実行し、結果を返します。
480+ *
481+ * @param string $query 実行するSQLクエリ
482+ * @return array 結果(DbInterface::query()を参照)
483+ * @throws DbException クエリ実行に失敗した場合
484+ */
485+ public function query(string $query): array {
486+ $this->requireConnection();
487+
488+ $this->sendCommand(self::COM_QUERY, $query);
489+ return $this->readResult($this->readPacket(), false, 'クエリに失敗');
490+ }
491+
492+ /**
493+ * トランザクションを開始する
494+ *
495+ * @return bool
496+ * @throws DbException
497+ */
498+ public function beginTransaction(): bool {
499+ $this->query('START TRANSACTION');
500+ return true;
501+ }
502+
503+ /**
504+ * トランザクションをコミットする
505+ *
506+ * @return bool
507+ * @throws DbException
508+ */
509+ public function commit(): bool {
510+ $this->query('COMMIT');
511+ return true;
512+ }
513+
514+ /**
515+ * トランザクションをロールバックする
516+ *
517+ * @return bool
518+ * @throws DbException
519+ */
520+ public function rollback(): bool {
521+ $this->query('ROLLBACK');
522+ return true;
523+ }
524+
525+ /**
526+ * トランザクション中かどうか(サーバーの状況フラグから)
527+ *
528+ * @return bool
529+ */
530+ public function inTransaction(): bool {
531+ return $this->connected && ($this->status & self::SERVER_STATUS_IN_TRANS) !== 0;
532+ }
533+
534+ /**
535+ * 識別子(テーブル名・列名)を囲む
536+ *
537+ * @param string $name 識別子
538+ * @return string `name`
539+ */
540+ public function quoteIdentifier(string $name): string {
541+ return '`'.str_replace('`', '``', $name).'`';
542+ }
543+
544+ /**
545+ * パケットログをファイルに保存する
546+ *
547+ * デバッグ用に収集したパケットログを指定されたファイルに保存します。
548+ *
549+ * @param string $filename 保存先ファイル名(ROOT/log/の中)
550+ * @return bool|int 成功時は書き込んだバイト数、失敗時はfalse
551+ */
552+ public function savePacketLogToFile(string $filename): bool|int {
553+ $output = '';
554+
555+ foreach ($this->packetLog as $index => $packetInfo) {
556+ $direction = $packetInfo['direction'];
557+ $length = $packetInfo['length'];
558+ $seqNum = $packetInfo['seqNum'];
559+ $data = $packetInfo['data'];
560+ $timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']);
561+
562+ $output .= "=== パケット #{$index} ({$timestamp}) {$direction} ";
563+ $output .= "(長さ: {$length}, シーケンス: {$seqNum}) ===\n";
564+ $output .= "16進数: ".$this->hexDump($data)."\n";
565+ $output .= "ASCII: ".$this->asciiDump($data)."\n";
566+ $output .= "==========================================\n\n";
567+ }
568+
569+ return file_put_contents(ROOT.'/log/'.basename($filename), $output);
570+ }
571+
572+ // 機能性メソッド
573+
574+ /**
575+ * MySQLサーバーで認証する
576+ *
577+ * 必要ならTLSを開始し、クライアント機能フラグと認証情報を送信してサーバー認証を行います。
578+ * サーバーが別の認証プラグインを求めたら(Auth Switch)、それに従います。
579+ *
580+ * @return bool 認証成功時はtrue
581+ * @throws DbException 認証に失敗した場合
582+ */
583+ private function authenticate(): bool {
584+ $server = $this->serverInfo['capabilities'];
585+ if (!($server & self::CLIENT_PROTOCOL_41)) {
586+ $this->fail('サーバーが古すぎます(MySQL 4.1以上が必要)');
587+ }
588+
589+ $caps = self::CLIENT_LONG_PASSWORD | self::CLIENT_LONG_FLAG | self::CLIENT_PROTOCOL_41
590+ | self::CLIENT_TRANSACTIONS | self::CLIENT_SECURE_CONNECTION | self::CLIENT_MULTI_RESULTS
591+ | self::CLIENT_PS_MULTI_RESULTS | self::CLIENT_PLUGIN_AUTH | self::CLIENT_PLUGIN_AUTH_LENENC;
592+ if ($this->dbname !== '') $caps |= self::CLIENT_CONNECT_WITH_DB;
593+ $caps &= $server;
594+
595+ $charset = chr(self::CHARSETS[$this->charset]);
596+ $head = pack('VV', 0, self::MAX_PACKET).$charset.str_repeat("\0", 23);
597+
598+ // TLS:SSLリクエストを送ってから暗号化する
599+ if ($this->tls !== false) {
600+ if (!($server & self::CLIENT_SSL)) {
601+ $this->fail('サーバーがTLSに対応していません');
602+ }
603+ $caps |= self::CLIENT_SSL;
604+ $this->sendPacket(substr_replace($head, pack('V', $caps), 0, 4));
605+ $this->startTls();
606+ }
607+
608+ $plugin = in_array($this->serverInfo['plugin'], self::PLUGINS, true) ? $this->serverInfo['plugin'] : 'mysql_native_password';
609+ $scramble = $this->serverInfo['scramble'];
610+ $auth = $this->authResponse($plugin, $scramble);
611+
612+ $data = substr_replace($head, pack('V', $caps), 0, 4);
613+ $data .= $this->username."\0";
614+ if ($caps & self::CLIENT_PLUGIN_AUTH_LENENC) {
615+ $data .= $this->encodeLengthEncodedInteger(strlen($auth)).$auth;
616+ } else {
617+ $data .= chr(strlen($auth)).$auth;
618+ }
619+ if ($caps & self::CLIENT_CONNECT_WITH_DB) $data .= $this->dbname."\0";
620+ if ($caps & self::CLIENT_PLUGIN_AUTH) $data .= $plugin."\0";
621+
622+ $this->sendPacket($data);
623+
624+ while (true) {
625+ $res = $this->readPacket();
626+ $first = ord($res[0] ?? "\xFF");
627+
628+ if ($first === 0x00) {
629+ $this->parseOkPacket($res);
630+ $this->plugin = $plugin;
631+ return true;
632+ }
633+
634+ if ($first === 0xFF) {
635+ $this->failPacket('認証に失敗', $res);
636+ }
637+
638+ if ($first === 0xFE) {
639+ // Auth Switch:プラグイン名\0、新しいスクランブル
640+ if (strlen($res) === 1) {
641+ $this->fail('古い認証方法(4.1以前)は未対応です');
642+ }
643+ $end = strpos($res, "\0", 1);
644+ $plugin = substr($res, 1, ($end === false ? strlen($res) : $end) - 1);
645+ $scramble = $end === false ? '' : substr($res, $end + 1);
646+ if (str_ends_with($scramble, "\0")) $scramble = substr($scramble, 0, -1);
647+ if (!in_array($plugin, self::PLUGINS, true)) {
648+ $this->fail("未対応の認証プラグイン: {$plugin}");
649+ }
650+ $this->sendPacket($this->authResponse($plugin, $scramble));
651+ continue;
652+ }
653+
654+ if ($first === 0x01) {
655+ // Auth More Data(caching_sha2_password・sha256_password)
656+ $more = substr($res, 1);
657+ if (str_starts_with($more, '-----BEGIN')) {
658+ $this->sendPacket($this->rsaEncrypt($more, $scramble));
659+ } else if ($plugin === 'caching_sha2_password' && $more === "\x03") {
660+ continue; // 高速認証に成功、OKが続く
661+ } else if ($plugin === 'caching_sha2_password' && $more === "\x04") {
662+ // 完全な認証:TLSならそのまま、それ以外は公開鍵で暗号化
663+ if ($this->secure) {
664+ $this->sendPacket($this->password."\0");
665+ } else if ($this->pubkey !== '') {
666+ $this->sendPacket($this->rsaEncrypt($this->readPubkey(), $scramble));
667+ } else {
668+ $this->sendPacket("\x02"); // 公開鍵を求める
669+ }
670+ } else {
671+ $this->fail('想定外の認証データ');
672+ }
673+ continue;
674+ }
675+
676+ $this->fail(sprintf('想定外の認証応答: 0x%02X', $first));
677+ }
678+ }
679+
680+ /**
681+ * 認証プラグイン毎の認証データ
682+ *
683+ * @param string $plugin プラグイン名
684+ * @param string $scramble サーバーから提供されたスクランブル文字列
685+ * @return string
686+ * @throws DbException
687+ */
688+ private function authResponse(string $plugin, string $scramble): string {
689+ switch ($plugin) {
690+ case 'mysql_native_password':
691+ return $this->password === '' ? '' : $this->scramblePassword($this->password, $scramble);
692+ case 'caching_sha2_password':
693+ return $this->password === '' ? '' : $this->scrambleSha256($this->password, $scramble);
694+ case 'sha256_password':
695+ if ($this->password === '') return "\0";
696+ if ($this->secure) return $this->password."\0";
697+ if ($this->pubkey !== '') return $this->rsaEncrypt($this->readPubkey(), $scramble);
698+ return "\x01"; // 公開鍵を求める
699+ case 'mysql_clear_password':
700+ if (!$this->secure) {
701+ $this->fail('mysql_clear_passwordはTLS又はUNIXソケットでしか使えません');
702+ }
703+ return $this->password."\0";
704+ }
705+
706+ $this->fail("未対応の認証プラグイン: {$plugin}");
707+ }
708+
709+ /**
710+ * TLSを開始する
711+ *
712+ * @return void
713+ * @throws DbException
714+ */
715+ private function startTls(): void {
716+ $opts = is_array($this->tls) ? $this->tls : [];
717+ $opts += [
718+ 'verify_peer' => true,
719+ 'verify_peer_name' => true,
720+ 'peer_name' => $this->host,
721+ 'SNI_enabled' => true,
722+ ];
723+ stream_context_set_option($this->socket, ['ssl' => $opts]);
724+
725+ $method = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
726+ if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) $method |= STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
727+
728+ $err = '';
729+ set_error_handler(function(int $no, string $str) use (&$err): bool {
730+ $err = $str;
731+ return true;
732+ });
733+ try {
734+ $ok = stream_socket_enable_crypto($this->socket, true, $method);
735+ } finally {
736+ restore_error_handler();
737+ }
738+
739+ if ($ok !== true) {
740+ $this->fail('TLSの開始に失敗'.($err !== '' ? ": {$err}" : ''));
741+ }
742+ $this->secure = true;
743+ }
744+
745+ /**
746+ * パスワードをサーバーの公開鍵で暗号化する(caching_sha2_password・sha256_password)
747+ *
748+ * @param string $pem 公開鍵(PEM)
749+ * @param string $scramble スクランブル文字列
750+ * @return string
751+ * @throws DbException
752+ */
753+ private function rsaEncrypt(string $pem, string $scramble): string {
754+ if (!function_exists('openssl_public_encrypt')) {
755+ $this->fail('パスワードの暗号化にはopensslモジュールが必要です(又はTLSで接続して下さい)');
756+ }
757+
758+ $pw = $this->password."\0";
759+ $xor = '';
760+ $len = strlen($scramble);
761+ for ($i = 0; $i < strlen($pw); $i++) {
762+ $xor .= $pw[$i] ^ $scramble[$i % $len];
763+ }
764+
765+ $key = openssl_pkey_get_public($pem);
766+ if ($key === false || !openssl_public_encrypt($xor, $out, $key, OPENSSL_PKCS1_OAEP_PADDING)) {
767+ $this->fail('パスワードの暗号化に失敗');
768+ }
769+
770+ return $out;
771+ }
772+
773+ /**
774+ * 設定された公開鍵のファイルを読む
775+ *
776+ * @return string PEM
777+ * @throws DbException
778+ */
779+ private function readPubkey(): string {
780+ $pem = @file_get_contents($this->pubkey);
781+ if ($pem === false) {
782+ $this->fail("公開鍵を読めません: {$this->pubkey}");
783+ }
784+ return $pem;
785+ }
786+
787+ /**
788+ * サーバーからの結果を読む
789+ *
790+ * OKパケット、又は結果セット(列の説明、行)を解析します。
791+ * 結果が複数ある場合(CALL等)、2つ目からは「more」に入ります。
792+ *
793+ * @param string $firstPacket 最初のパケット
794+ * @param bool $binary バイナリプロトコル(プリペアドステートメント)の行かどうか
795+ * @param string $what 失敗した時のメッセージ
796+ * @return array fields、rows、affectedRows、insertId、serverStatus、warningCount、info(、more)
797+ * @throws DbException
798+ */
799+ private function readResult(string $firstPacket, bool $binary, string $what): array {
800+ $first = ord($firstPacket[0] ?? "\xFF");
801+
802+ if ($first === 0xFF) {
803+ $this->failPacket($what, $firstPacket);
804+ }
805+
806+ if ($first === 0xFB) {
807+ // LOCAL INFILEリクエスト(CLIENT_LOCAL_FILESを送っていないので、普通は来ない):断る
808+ $this->sendPacket('');
809+ $this->readPacket();
810+ $this->fail('LOCAL INFILEリクエストは未対応です');
811+ }
812+
813+ if ($first === 0x00) {
814+ $res = ['fields' => [], 'rows' => []] + $this->parseOkPacket($firstPacket);
815+ } else {
816+ $res = $this->parseResultSet($firstPacket, $binary);
817+ }
818+
819+ if ($this->status & self::SERVER_MORE_RESULTS_EXISTS) {
820+ $next = $this->readResult($this->readPacket(), $binary, $what);
821+ $more = $next['more'] ?? [];
822+ unset($next['more']);
823+ $res['more'] = array_merge([$next], $more);
824+ }
825+
826+ return $res;
827+ }
828+
829+ /**
830+ * サーバーから結果セットを解析する
831+ *
832+ * クエリ結果のフィールドと行データを解析して返します。
833+ *
834+ * @param string $firstPacket 最初の結果セットパケット(列数)
835+ * @param bool $binary バイナリプロトコルの行かどうか
836+ * @return array フィールドと行データの配列
837+ * @throws DbException EOFパケットが期待通りに受信できない場合
838+ */
839+ private function parseResultSet(string $firstPacket, bool $binary): array {
840+ $pos = 0;
841+ $fieldCnt = (int)$this->readLengthEncodedInteger($firstPacket, $pos);
842+
843+ $fields = [];
844+ for ($i = 0; $i < $fieldCnt; $i++) {
845+ $fields[] = $this->parseFieldPacket($this->readPacket());
846+ }
847+ $this->readEof();
848+
849+ $rows = [];
850+ $warnings = 0;
851+ while (true) {
852+ $rowPacket = $this->readPacket();
853+ $first = ord($rowPacket[0] ?? "\xFF");
854+
855+ if ($first === 0xFF) {
856+ $this->failPacket('行の読み込みに失敗', $rowPacket);
857+ }
858+
859+ // 行データの終了を示すEOFパケットを確認
860+ if ($first === 0xFE && strlen($rowPacket) < 9) {
861+ $eof = $this->parseEofPacket($rowPacket);
862+ $warnings = $eof['warningCount'];
863+ break;
864+ }
865+
866+ $rows[] = $binary ? $this->parseBinaryRowPacket($rowPacket, $fields) : $this->parseRowPacket($rowPacket, $fields);
867+ }
868+
869+ return [
870+ 'fields' => $fields,
871+ 'rows' => $rows,
872+ 'affectedRows' => 0,
873+ 'insertId' => 0,
874+ 'serverStatus' => $this->status,
875+ 'warningCount' => $warnings,
876+ 'info' => '',
877+ ];
878+ }
879+
880+ /**
881+ * EOFパケットを読む(列の説明の後)
882+ *
883+ * @return void
884+ * @throws DbException
885+ */
886+ private function readEof(): void {
887+ $eofPacket = $this->readPacket();
888+ if (ord($eofPacket[0] ?? "\0") === 0xFF) {
889+ $this->failPacket('結果の読み込みに失敗', $eofPacket);
890+ }
891+ if (ord($eofPacket[0] ?? "\0") !== 0xFE) {
892+ $this->fail('フィールド説明の後にEOFパケットが期待されます');
893+ }
894+ $this->parseEofPacket($eofPacket);
895+ }
896+
897+ /**
898+ * フィールドパケットを解析する
899+ *
900+ * フィールドのメタデータ(Protocol::ColumnDefinition41)を解析して返します。
901+ *
902+ * @param string $packet フィールドパケット
903+ * @return array catalog、db、table、org_table、name、org_name、charset、length、type、flags、decimals
904+ */
905+ private function parseFieldPacket(string $packet): array {
906+ $pos = 0;
907+ $field = [];
908+
909+ $field['catalog'] = (string)$this->readLengthEncodedString($packet, $pos); // 常に「def」
910+ $field['db'] = (string)$this->readLengthEncodedString($packet, $pos);
911+ $field['table'] = (string)$this->readLengthEncodedString($packet, $pos);
912+ $field['org_table'] = (string)$this->readLengthEncodedString($packet, $pos);
913+ $field['name'] = (string)$this->readLengthEncodedString($packet, $pos);
914+ $field['org_name'] = (string)$this->readLengthEncodedString($packet, $pos);
915+
916+ // 固定長の部分の長さ(常に0x0C)
917+ $this->readLengthEncodedInteger($packet, $pos);
918+
919+ $fixed = unpack('vcharset/Vlength/Ctype/vflags/Cdecimals', str_pad(substr($packet, $pos, 10), 10, "\0"));
920+ $field['charset'] = $fixed['charset'];
921+ $field['length'] = $fixed['length'];
922+ $field['type'] = $fixed['type'];
923+ $field['flags'] = $fixed['flags'];
924+ $field['decimals'] = $fixed['decimals'];
925+
926+ return $field;
927+ }
928+
929+ /**
930+ * 行パケットを解析する(テキストプロトコル)
931+ *
932+ * 結果セットの行データを解析して返します。数値の列はint・floatにします。
933+ *
934+ * @param string $packet 行パケット
935+ * @param array $fields フィールドメタデータの配列
936+ * @return array 行データの連想配列
937+ */
938+ private function parseRowPacket(string $packet, array $fields): array {
939+ $pos = 0;
940+ $row = [];
941+
942+ foreach ($fields as $field) {
943+ $value = $this->readLengthEncodedString($packet, $pos); // 0xFB = NULL
944+ $row[$field['name']] = $value === null ? null : $this->castText($value, $field);
945+ }
946+
947+ return $row;
948+ }
949+
950+ /**
951+ * 行パケットを解析する(バイナリプロトコル)
952+ *
953+ * 0x00、NULLビットマップ(2ビットずらし)、値の順。
954+ *
955+ * @param string $packet 行パケット
956+ * @param array $fields フィールドメタデータの配列
957+ * @return array 行データの連想配列
958+ */
959+ private function parseBinaryRowPacket(string $packet, array $fields): array {
960+ $cnt = count($fields);
961+ $bitmapLen = intdiv($cnt + 7 + 2, 8);
962+ $bitmap = substr($packet, 1, $bitmapLen);
963+ $pos = 1 + $bitmapLen;
964+ $row = [];
965+
966+ foreach ($fields as $i => $field) {
967+ $bit = $i + 2;
968+ if (ord($bitmap[$bit >> 3]) & (1 << ($bit & 7))) {
969+ $row[$field['name']] = null;
970+ continue;
971+ }
972+ $row[$field['name']] = $this->readBinaryValue($packet, $pos, $field);
973+ }
974+
975+ return $row;
976+ }
977+
978+ /**
979+ * テキストプロトコルの値をPHPの型にする
980+ *
981+ * @param string $value 値
982+ * @param array $field フィールドメタデータ
983+ * @return mixed 整数の列はint(PHP_INT_MAXを超えたら文字列のまま)、FLOAT・DOUBLEはfloat、その他は文字列
984+ */
985+ private function castText(string $value, array $field): mixed {
986+ switch ($field['type']) {
987+ case self::TYPE_TINY:
988+ case self::TYPE_SHORT:
989+ case self::TYPE_LONG:
990+ case self::TYPE_INT24:
991+ case self::TYPE_LONGLONG:
992+ case self::TYPE_YEAR:
993+ $int = (int)$value;
994+ return (string)$int === $value ? $int : $value;
995+ case self::TYPE_FLOAT:
996+ case self::TYPE_DOUBLE:
997+ return (float)$value;
998+ }
999+
1000+ return $value;
1001+ }
1002+
1003+ /**
1004+ * バイナリプロトコルの値を1つ読む
1005+ *
1006+ * @param string $packet 行パケット
1007+ * @param int $pos 位置(進める)
1008+ * @param array $field フィールドメタデータ
1009+ * @return mixed
1010+ */
1011+ private function readBinaryValue(string $packet, int &$pos, array $field): mixed {
1012+ $unsigned = ($field['flags'] & self::UNSIGNED_FLAG) !== 0;
1013+
1014+ switch ($field['type']) {
1015+ case self::TYPE_TINY:
1016+ $v = ord($packet[$pos]);
1017+ $pos += 1;
1018+ return $unsigned || $v < 0x80 ? $v : $v - 0x100;
1019+ case self::TYPE_SHORT:
1020+ case self::TYPE_YEAR:
1021+ $v = unpack('v', $packet, $pos)[1];
1022+ $pos += 2;
1023+ return $unsigned || $v < 0x8000 ? $v : $v - 0x10000;
1024+ case self::TYPE_LONG:
1025+ case self::TYPE_INT24:
1026+ $v = unpack('V', $packet, $pos)[1];
1027+ $pos += 4;
1028+ return $unsigned || $v < 0x80000000 ? $v : $v - 0x100000000;
1029+ case self::TYPE_LONGLONG:
1030+ $v = unpack('P', $packet, $pos)[1];
1031+ $pos += 8;
1032+ return $unsigned && $v < 0 ? sprintf('%u', $v) : $v;
1033+ case self::TYPE_FLOAT:
1034+ $v = unpack('g', $packet, $pos)[1];
1035+ $pos += 4;
1036+ return (float)sprintf('%.6g', $v); // テキストプロトコルと同じ精度
1037+ case self::TYPE_DOUBLE:
1038+ $v = unpack('e', $packet, $pos)[1];
1039+ $pos += 8;
1040+ return $v;
1041+ case self::TYPE_DATE:
1042+ case self::TYPE_DATETIME:
1043+ case self::TYPE_TIMESTAMP:
1044+ return $this->readBinaryDateTime($packet, $pos, $field);
1045+ case self::TYPE_TIME:
1046+ return $this->readBinaryTime($packet, $pos, $field);
1047+ case self::TYPE_NULL:
1048+ return null;
1049+ }
1050+
1051+ // DECIMAL、文字列、BLOB、BIT、ENUM、SET、JSON、GEOMETRY等
1052+ return $this->readLengthEncodedString($packet, $pos);
1053+ }
1054+
1055+ /**
1056+ * バイナリのDATE・DATETIME・TIMESTAMPを読む
1057+ *
1058+ * 長さ(0、4、7、11)、年(2)、月、日、時、分、秒、マイクロ秒(4)。
1059+ *
1060+ * @return string 「Y-m-d」又は「Y-m-d H:i:s[.u]」
1061+ */
1062+ private function readBinaryDateTime(string $packet, int &$pos, array $field): string {
1063+ $len = ord($packet[$pos]);
1064+ $pos++;
1065+ $b = substr($packet, $pos, $len)."\0\0\0\0\0\0\0\0\0\0\0";
1066+ $pos += $len;
1067+
1068+ $date = sprintf('%04d-%02d-%02d', unpack('v', $b)[1], ord($b[2]), ord($b[3]));
1069+ if ($field['type'] === self::TYPE_DATE) return $date;
1070+
1071+ return $date.sprintf(' %02d:%02d:%02d', ord($b[4]), ord($b[5]), ord($b[6]))
1072+ .$this->fraction(unpack('V', $b, 7)[1], $field['decimals']);
1073+ }
1074+
1075+ /**
1076+ * バイナリのTIMEを読む
1077+ *
1078+ * 長さ(0、8、12)、負数(1)、日(4)、時、分、秒、マイクロ秒(4)。
1079+ *
1080+ * @return string 「[-]H:i:s[.u]」(時は24以上になる事がある)
1081+ */
1082+ private function readBinaryTime(string $packet, int &$pos, array $field): string {
1083+ $len = ord($packet[$pos]);
1084+ $pos++;
1085+ $b = substr($packet, $pos, $len)."\0\0\0\0\0\0\0\0\0\0\0\0";
1086+ $pos += $len;
1087+
1088+ $hours = unpack('V', $b, 1)[1] * 24 + ord($b[5]);
1089+ return (ord($b[0]) ? '-' : '').sprintf('%02d:%02d:%02d', $hours, ord($b[6]), ord($b[7]))
1090+ .$this->fraction(unpack('V', $b, 8)[1], $field['decimals']);
1091+ }
1092+
1093+ /**
1094+ * 秒の小数部分(列の桁数に合わせる)
1095+ *
1096+ * @param int $usec マイクロ秒
1097+ * @param int $decimals 列の小数点以下の桁数(0~6、31=不明)
1098+ * @return string
1099+ */
1100+ private function fraction(int $usec, int $decimals): string {
1101+ if ($decimals > 0 && $decimals <= 6) return '.'.substr(sprintf('%06d', $usec), 0, $decimals);
1102+ if ($decimals > 6 && $usec > 0) return '.'.sprintf('%06d', $usec);
1103+ return '';
1104+ }
1105+
1106+ /**
1107+ * OKパケットを解析する
1108+ *
1109+ * OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。
1110+ *
1111+ * @param string $packet OKパケット
1112+ * @return array affectedRows、insertId、serverStatus、warningCount、info
1113+ * @throws DbException パケットが不完全な場合
1114+ */
1115+ private function parseOkPacket(string $packet): array {
1116+ if (strlen($packet) < 7) {
1117+ $this->fail("OKパケットが短すぎます: ".strlen($packet)."バイト");
1118+ }
1119+
1120+ $pos = 1; // ヘッダーバイト(0x00)をスキップする
1121+ $affectedRows = (int)$this->readLengthEncodedInteger($packet, $pos);
1122+ $insertId = (int)$this->readLengthEncodedInteger($packet, $pos);
1123+ $serverStatus = unpack('v', $packet, $pos)[1];
1124+ $warningCount = unpack('v', $packet, $pos + 2)[1];
1125+ $this->status = $serverStatus;
1126+
1127+ return [
1128+ 'affectedRows' => $affectedRows,
1129+ 'insertId' => $insertId,
1130+ 'serverStatus' => $serverStatus,
1131+ 'warningCount' => $warningCount,
1132+ 'info' => (string)substr($packet, $pos + 4),
1133+ ];
1134+ }
1135+
1136+ /**
1137+ * EOFパケットを解析する
1138+ *
1139+ * @param string $packet EOFパケット(0xFE、警告数(2)、サーバーの状況(2))
1140+ * @return array warningCount、serverStatus
1141+ */
1142+ private function parseEofPacket(string $packet): array {
1143+ if (strlen($packet) < 5) return ['warningCount' => 0, 'serverStatus' => $this->status];
1144+ $eof = unpack('vwarningCount/vserverStatus', $packet, 1);
1145+ $this->status = $eof['serverStatus'];
1146+ return $eof;
1147+ }
1148+
1149+ /**
1150+ * MySQLサーバーからパケットを読み込む
1151+ *
1152+ * 16MiB以上のパケット(複数のパケットに分かれる)は1つにまとめます。
1153+ *
1154+ * @return string 受信したパケットデータ
1155+ * @throws DbException 読み込みに失敗した場合
1156+ */
1157+ private function readPacket(): string {
1158+ $data = '';
1159+
1160+ do {
1161+ $header = $this->readBytes(4); // パケットのヘッダー=4バイト
1162+
1163+ // パケットの長さを最初3バイトからパーシングする
1164+ $len = unpack('V', substr($header, 0, 3)."\0")[1];
1165+
1166+ // パケットの順序番号は第4目のバイト
1167+ $seqNum = ord($header[3]);
1168+ $this->seq = ($seqNum + 1) & 0xFF;
1169+
1170+ $chunk = $len > 0 ? $this->readBytes($len) : '';
1171+ $data .= $chunk;
1172+
1173+ // デバッグ
1174+ if ($this->debug) {
1175+ $this->logPacket([
1176+ 'direction' => 'RECV',
1177+ 'length' => $len,
1178+ 'seqNum' => $seqNum,
1179+ 'data' => $chunk,
1180+ 'timestamp' => microtime(true),
1181+ ]);
1182+ }
1183+ } while ($len === 0xFFFFFF);
1184+
1185+ return $data;
1186+ }
1187+
1188+ /**
1189+ * ソケットから指定したバイト数を読む
1190+ *
1191+ * @param int $len バイト数
1192+ * @return string
1193+ * @throws DbException 接続が切れた、又はタイムアウトした場合
1194+ */
1195+ private function readBytes(int $len): string {
1196+ if (!$this->socket) $this->fail('接続していません');
1197+
1198+ $buf = '';
1199+ while (strlen($buf) < $len) {
1200+ $chunk = @fread($this->socket, min($len - strlen($buf), 1048576));
1201+ if ($chunk === false || $chunk === '') {
1202+ $timedOut = (bool)(stream_get_meta_data($this->socket)['timed_out'] ?? false);
1203+ if (!$timedOut && $chunk === '' && !feof($this->socket)) continue;
1204+ $this->drop();
1205+ $this->fail($timedOut ? "パケットの読み込みがタイムアウト({$this->timeout}秒)" : 'サーバーとの接続が切れました');
1206+ }
1207+ $buf .= $chunk;
1208+ }
1209+
1210+ return $buf;
1211+ }
1212+
1213+ /**
1214+ * MySQLサーバーにパケットを送信する
1215+ *
1216+ * 16MiB以上のデータは複数のパケットに分けます。
1217+ *
1218+ * @param string $data 送信するデータ
1219+ * @return bool 成功時はtrue
1220+ * @throws DbException 送信に失敗した場合
1221+ */
1222+ private function sendPacket(string $data): bool {
1223+ $total = strlen($data);
1224+ $pos = 0;
1225+
1226+ do {
1227+ $len = min($total - $pos, 0xFFFFFF);
1228+ $chunk = substr($data, $pos, $len);
1229+ $pos += $len;
1230+
1231+ // パケットヘッダー:長さ=3バイト、順序番号=1バイト
1232+ $header = substr(pack('V', $len), 0, 3).chr($this->seq);
1233+
1234+ // デバッグ
1235+ if ($this->debug) {
1236+ $this->logPacket([
1237+ 'direction' => 'SEND',
1238+ 'length' => $len,
1239+ 'seqNum' => $this->seq,
1240+ 'data' => $chunk,
1241+ 'timestamp' => microtime(true),
1242+ ]);
1243+ }
1244+
1245+ $this->seq = ($this->seq + 1) & 0xFF;
1246+ $this->writeBytes($header.$chunk);
1247+ } while ($len === 0xFFFFFF); // ちょうど16MiB-1の倍数なら、空のパケットで終わる
1248+
1249+ return true;
1250+ }
1251+
1252+ /**
1253+ * ソケットに全部書く
1254+ *
1255+ * @param string $data
1256+ * @return void
1257+ * @throws DbException
1258+ */
1259+ private function writeBytes(string $data): void {
1260+ if (!$this->socket) $this->fail('接続していません');
1261+
1262+ $len = strlen($data);
1263+ $sent = 0;
1264+ while ($sent < $len) {
1265+ $n = @fwrite($this->socket, substr($data, $sent, 1048576));
1266+ if ($n === false || $n === 0) {
1267+ $this->drop();
1268+ $this->fail('パケットデータの送信に失敗');
1269+ }
1270+ $sent += $n;
1271+ }
1272+ }
1273+
1274+ /**
1275+ * MySQLサーバーにコマンドを送信する
1276+ *
1277+ * 新しいコマンドなので、順序番号は0から。
1278+ *
1279+ * @param int $command コマンド(例: 0x03 = COM_QUERY)
1280+ * @param string $data 付加データ(デフォルトは空)
1281+ * @return bool 成功時はtrue
1282+ * @throws DbException 送信に失敗した場合
1283+ */
1284+ private function sendCommand(int $command, string $data = ''): bool {
1285+ $this->seq = 0;
1286+ return $this->sendPacket(chr($command).$data);
1287+ }
1288+
1289+ /**
1290+ * 接続していなければ例外
1291+ *
1292+ * @return void
1293+ * @throws DbException
1294+ */
1295+ private function requireConnection(): void {
1296+ if (!$this->connected) $this->fail('接続していません(先にconnect()を呼んで下さい)');
1297+ }
1298+
1299+ /**
1300+ * ソケットを閉じ、状態を戻す(COM_QUITを送らない)
1301+ *
1302+ * @return void
1303+ */
1304+ private function drop(): void {
1305+ if ($this->socket) @fclose($this->socket);
1306+ $this->socket = null;
1307+ $this->connected = false;
1308+ $this->secure = false;
1309+ $this->status = 0;
1310+ $this->prepared = [];
1311+ }
1312+
1313+ /**
1314+ * ログに書いて例外を投げる
1315+ *
1316+ * @param string $msg メッセージ
1317+ * @param int $code MySQLのエラーコード
1318+ * @param string $sqlState SQLSTATE
1319+ * @return never
1320+ * @throws DbException
1321+ */
1322+ private function fail(string $msg, int $code = 0, string $sqlState = ''): never {
1323+ if (function_exists('logger')) logger(\LogType::Db, $msg);
1324+ throw new DbException($msg, $code, $sqlState);
1325+ }
1326+
1327+ /**
1328+ * エラーパケット(0xFF、コード(2)、「#」、SQLSTATE(5)、メッセージ)から例外を投げる
1329+ *
1330+ * @param string $what 何に失敗したか
1331+ * @param string $packet エラーパケット
1332+ * @return never
1333+ * @throws DbException
1334+ */
1335+ private function failPacket(string $what, string $packet): never {
1336+ $code = strlen($packet) >= 3 ? unpack('v', $packet, 1)[1] : 0;
1337+ $state = '';
1338+ $mes = (string)substr($packet, 3);
1339+ if (str_starts_with($mes, '#')) {
1340+ $state = substr($mes, 1, 5);
1341+ $mes = (string)substr($mes, 6);
1342+ }
1343+ $this->fail("{$what}: {$code} - {$mes}", $code, $state);
1344+ }
1345+
1346+ /**
1347+ * サーバーの挨拶パケットを解析する
1348+ *
1349+ * サーバーからの初期挨拶パケット(Protocol::HandshakeV10)を解析し、サーバー情報を保存します。
1350+ *
1351+ * @param string $packet 挨拶パケット
1352+ * @return void
1353+ * @throws DbException エラー(接続の拒否等)又は未対応のプロトコルの場合
1354+ */
1355+ private function parseServerGreeting(string $packet): void {
1356+ if (ord($packet[0] ?? "\0") === 0xFF) {
1357+ $this->failPacket('接続を拒否されました', $packet);
1358+ }
1359+
1360+ $pos = 0;
1361+
1362+ // プロトコールバージョン(1バイト)
1363+ $this->serverInfo['protocol'] = ord($packet[$pos]);
1364+ $pos++;
1365+ if ($this->serverInfo['protocol'] !== 10) {
1366+ $this->fail("未対応のプロトコル: {$this->serverInfo['protocol']}");
1367+ }
1368+
1369+ // サーバーバージョン
1370+ $end = strpos($packet, "\0", $pos);
1371+ $this->serverInfo['version'] = substr($packet, $pos, $end - $pos);
1372+ $pos = $end + 1;
1373+
1374+ // スレッドID(4バイト)
1375+ $this->serverInfo['threadId'] = unpack('V', $packet, $pos)[1];
1376+ $pos += 4;
1377+
1378+ // スクランブルバッファの最初の部分(8バイト)
1379+ $scramble = substr($packet, $pos, 8);
1380+ $pos += 8;
1381+
1382+ // フィルターバイトをスキップする
1383+ $pos++;
1384+
1385+ // サーバー機能の下位(2バイト)
1386+ $caps = unpack('v', $packet, $pos)[1];
1387+ $pos += 2;
1388+
1389+ $this->serverInfo['charset'] = 0;
1390+ $this->serverInfo['status'] = 0;
1391+ $this->serverInfo['plugin'] = '';
1392+
1393+ if ($pos < strlen($packet)) {
1394+ // サーバー言語(1バイト)
1395+ $this->serverInfo['charset'] = ord($packet[$pos]);
1396+ $pos++;
1397+
1398+ // サーバー状況(2バイト)
1399+ $this->serverInfo['status'] = unpack('v', $packet, $pos)[1];
1400+ $pos += 2;
1401+
1402+ // サーバー機能の上位(2バイト)
1403+ $caps |= unpack('v', $packet, $pos)[1] << 16;
1404+ $pos += 2;
1405+
1406+ // スクランブルの長さ(1バイト)と予約(10バイト)
1407+ $authLen = ord($packet[$pos]);
1408+ $pos += 11;
1409+
1410+ // スクランブルの残り(最後の\0を除く)
1411+ if ($caps & self::CLIENT_SECURE_CONNECTION) {
1412+ $len = max(13, $authLen - 8);
1413+ $scramble .= rtrim(substr($packet, $pos, $len), "\0");
1414+ $pos += $len;
1415+ }
1416+
1417+ // 認証プラグイン名
1418+ if ($caps & self::CLIENT_PLUGIN_AUTH) {
1419+ $end = strpos($packet, "\0", $pos);
1420+ $this->serverInfo['plugin'] = substr($packet, $pos, ($end === false ? strlen($packet) : $end) - $pos);
1421+ }
1422+ }
1423+
1424+ $this->serverInfo['capabilities'] = $caps;
1425+ $this->serverInfo['scramble'] = $scramble;
1426+ $this->status = $this->serverInfo['status'];
1427+ }
1428+
1429+ /**
1430+ * パスワードをスクランブルする(mysql_native_password)
1431+ *
1432+ * SHA1(password) XOR SHA1(scramble + SHA1(SHA1(password)))
1433+ *
1434+ * @param string $password プレーンテキストのパスワード
1435+ * @param string $scramble サーバーから提供されたスクランブル文字列
1436+ * @return string スクランブルされたパスワード(20バイト)
1437+ */
1438+ private function scramblePassword(string $password, string $scramble): string {
1439+ $stage1 = sha1($password, true);
1440+ $stage2 = sha1($stage1, true);
1441+ $stage3 = sha1($scramble.$stage2, true);
1442+
1443+ return $stage1 ^ $stage3;
1444+ }
1445+
1446+ /**
1447+ * パスワードをスクランブルする(caching_sha2_password)
1448+ *
1449+ * SHA256(password) XOR SHA256(SHA256(SHA256(password)) + scramble)
1450+ *
1451+ * @param string $password プレーンテキストのパスワード
1452+ * @param string $scramble サーバーから提供されたスクランブル文字列
1453+ * @return string スクランブルされたパスワード(32バイト)
1454+ */
1455+ private function scrambleSha256(string $password, string $scramble): string {
1456+ $stage1 = hash('sha256', $password, true);
1457+ $stage2 = hash('sha256', $stage1, true);
1458+ $stage3 = hash('sha256', $stage2.$scramble, true);
1459+
1460+ return $stage1 ^ $stage3;
1461+ }
1462+
1463+ /**
1464+ * デバッグのためにパケットをログする
1465+ *
1466+ * パケット情報をログに追加し、デバッグ出力を表示します。
1467+ *
1468+ * @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ)
1469+ * @return void
1470+ */
1471+ private function logPacket(array $packetInfo): void {
1472+ $this->packetLog[] = $packetInfo;
1473+
1474+ $direction = $packetInfo['direction'];
1475+ $length = $packetInfo['length'];
1476+ $seqNum = $packetInfo['seqNum'];
1477+ $data = $packetInfo['data'];
1478+
1479+ echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n";
1480+ echo $this->hexDumpWithAscii(substr($data, 0, 4096))."\n";
1481+
1482+ $this->interpretPacket($data);
1483+
1484+ echo "==========================================\n\n";
1485+ }
1486+
1487+ /**
1488+ * バイナリデータを16進数で出力する
1489+ *
1490+ * デバッグ用にデータを16進数形式で表示します。
1491+ *
1492+ * @param string $data バイナリデータ
1493+ * @return string 16進数文字列
1494+ */
1495+ private function hexDump(string $data): string {
1496+ $res = '';
1497+ $len = strlen($data);
1498+
1499+ for ($i = 0; $i < $len; $i++) {
1500+ $res .= sprintf('%02X ', ord($data[$i]));
1501+
1502+ // 読み易さの為、各16バイトで新行列を入る
1503+ if (($i + 1) % 16 === 0 && $i !== $len - 1) {
1504+ $res .= "\n";
1505+ }
1506+ }
1507+
1508+ return $res;
1509+ }
1510+
1511+ /**
1512+ * バイナリデータをASCIIで出力する
1513+ *
1514+ * デバッグ用にデータをASCII形式で表示します。
1515+ *
1516+ * @param string $data バイナリデータ
1517+ * @return string ASCII文字列
1518+ */
1519+ private function asciiDump(string $data): string {
1520+ $res = '';
1521+ $len = strlen($data);
1522+
1523+ for ($i = 0; $i < $len; $i++) {
1524+ $char = ord($data[$i]);
1525+
1526+ // 表示出来るASCII文字だけを書き出す
1527+ if ($char >= 32 && $char <= 126) {
1528+ $res .= $data[$i];
1529+ } else {
1530+ $res .= '.';
1531+ }
1532+
1533+ // 読み易さの為、各16バイトで新行列を入る
1534+ if (($i + 1) % 16 === 0 && $i !== $len - 1) {
1535+ $res .= "\n";
1536+ }
1537+ }
1538+
1539+ return $res;
1540+ }
1541+
1542+ /**
1543+ * バイナリデータを2進数で出力する
1544+ *
1545+ * デバッグ用にデータを2進数形式で表示します。
1546+ *
1547+ * @param string $data バイナリデータ
1548+ * @return string 2進数文字列
1549+ */
1550+ private function binaryDump(string $data): string {
1551+ $res = '';
1552+ $len = strlen($data);
1553+
1554+ for ($i = 0; $i < $len; $i++) {
1555+ $res .= sprintf('%08b ', ord($data[$i]));
1556+
1557+ // 読み易さの為、各8バイトで新行列を入る
1558+ if (($i + 1) % 8 === 0 && $i !== $len - 1) {
1559+ $res .= "\n";
1560+ }
1561+ }
1562+
1563+ return $res;
1564+ }
1565+
1566+ /**
1567+ * パケットを最初のバイトに基づいて解釈する
1568+ *
1569+ * パケットの種類を特定し、デバッグ情報を出力します。
1570+ *
1571+ * @param string $data パケットデータ
1572+ * @return void
1573+ */
1574+ private function interpretPacket(string $data): void {
1575+ if ($data === '') {
1576+ echo "解釈: 空パケット\n";
1577+ return;
1578+ }
1579+
1580+ $firstByte = ord($data[0]);
1581+
1582+ switch ($firstByte) {
1583+ case 0x00:
1584+ echo "解釈: OKパケット\n";
1585+ $this->debugOkPacket($data);
1586+ break;
1587+ case 0x17:
1588+ echo "解釈: COM_STMT_EXECUTEパケット\n";
1589+ $this->debugStmtExecutePacket($data);
1590+ break;
1591+ case 0xFF:
1592+ echo "解釈: エラーパケット\n";
1593+ $this->debugErrorPacket($data);
1594+ break;
1595+ case 0xFE:
1596+ echo "解釈: EOFパケット\n";
1597+ break;
1598+ case 0xFB:
1599+ echo "解釈: LOCAL INFILEリクエスト\n";
1600+ break;
1601+ default:
1602+ if ($firstByte === 3 && substr($data, 1, 3) === 'def') {
1603+ // フィールドパケットかどうかの確認
1604+ echo "解釈: フィールド説明パケット\n";
1605+ $this->debugFieldPacket($data);
1606+ } else if ($firstByte > 0 && $firstByte < 251 && strlen($data) <= 9) {
1607+ // 結果セットパケットかどうかの確認
1608+ echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n";
1609+ } else {
1610+ // 以上じゃないと、列データパケットでかもしん
1611+ echo "解釈: 列データパケット又はその他のパケット種類\n";
1612+ $this->debugLengthEncodedStrings($data);
1613+ }
1614+ break;
1615+ }
1616+ }
1617+
1618+ /**
1619+ * OKパケット構造をデバッグする
1620+ *
1621+ * OKパケットの内容を解析し、デバッグ情報を出力します。
1622+ *
1623+ * @param string $data OKパケットデータ
1624+ * @return void
1625+ */
1626+ private function debugOkPacket(string $data): void {
1627+ if (strlen($data) < 2) {
1628+ echo ' エラー: OKパケットが短すぎます ('.strlen($data)."バイト)\n";
1629+ return;
1630+ }
1631+
1632+ $pos = 1; // ヘッダーバイトをスキップ
1633+
1634+ // 影響を受けた行数を取得
1635+ $affectedRows = $this->readLengthEncodedInteger($data, $pos);
1636+ echo " 影響を受けた行数: {$affectedRows}\n";
1637+
1638+ // 最後の挿入IDを取得
1639+ $insertId = $this->readLengthEncodedInteger($data, $pos);
1640+ echo " 最後の挿入ID: {$insertId}\n";
1641+
1642+ // サーバーステータス
1643+ if (strlen($data) >= $pos + 2) {
1644+ $serverStatus = unpack('v', $data, $pos)[1];
1645+ echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n";
1646+ $pos += 2;
1647+ } else {
1648+ echo " サーバーステータス: 利用不可\n";
1649+ }
1650+
1651+ // 警告カウント
1652+ if (strlen($data) >= $pos + 2) {
1653+ $warningCount = unpack('v', $data, $pos)[1];
1654+ echo " 警告カウント: {$warningCount}\n";
1655+ $pos += 2;
1656+ } else {
1657+ echo " 警告カウント: 利用不可\n";
1658+ }
1659+
1660+ // サーバーメッセージ(存在する場合)
1661+ if (strlen($data) > $pos) {
1662+ $message = substr($data, $pos);
1663+ echo " メッセージ: ".$this->safeString($message)."\n";
1664+ }
1665+ }
1666+
1667+ /**
1668+ * エラーパケット構造をデバッグする
1669+ *
1670+ * エラーパケットの内容を解析し、デバッグ情報を出力します。
1671+ *
1672+ * @param string $data エラーパケットデータ
1673+ * @return void
1674+ */
1675+ private function debugErrorPacket(string $data): void {
1676+ $errorCode = unpack('v', substr($data, 1, 2)."\0\0")[1];
1677+ echo " エラーコード: {$errorCode}\n";
1678+
1679+ // SQLステートマーカーをスキップ(#)
1680+ $sqlState = substr($data, 4, 5);
1681+ echo " SQLステート: {$sqlState}\n";
1682+
1683+ $errorMessage = substr($data, 9);
1684+ echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n";
1685+ }
1686+
1687+ /**
1688+ * フィールドパケット構造をデバッグする
1689+ *
1690+ * フィールドパケットの内容を解析し、デバッグ情報を出力します。
1691+ *
1692+ * @param string $data フィールドパケットデータ
1693+ * @return void
1694+ */
1695+ private function debugFieldPacket(string $data): void {
1696+ $field = $this->parseFieldPacket($data);
1697+
1698+ echo " フィールドパケット構造:\n";
1699+ echo " カタログ: ".$this->safeString($field['catalog'])."\n";
1700+ echo " データベース: ".$this->safeString($field['db'])."\n";
1701+ echo " テーブル: ".$this->safeString($field['table'])."\n";
1702+ echo " 元のテーブル: ".$this->safeString($field['org_table'])."\n";
1703+ echo " 名前: ".$this->safeString($field['name'])."\n";
1704+ echo " 元の名前: ".$this->safeString($field['org_name'])."\n";
1705+ echo " 文字セット: ".sprintf('0x%04X', $field['charset'])."\n";
1706+ echo " 列の長さ: {$field['length']}\n";
1707+ echo " 列の種類: ".sprintf('0x%02X', $field['type'])." (".$this->getMysqlTypeName($field['type']).")\n";
1708+ echo " フラグ: ".sprintf('0x%04X', $field['flags'])."\n";
1709+ echo " 小数点以下の桁数: {$field['decimals']}\n";
1710+ }
1711+
1712+ /**
1713+ * パケット内の長さエンコード文字列をデバッグする
1714+ *
1715+ * パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。
1716+ *
1717+ * @param string $data パケットデータ
1718+ * @return void
1719+ */
1720+ private function debugLengthEncodedStrings(string $data): void {
1721+ $pos = 0;
1722+ $length = strlen($data);
1723+ $stringCount = 0;
1724+
1725+ echo " 長さエンコード文字列:\n";
1726+
1727+ while ($pos < $length) {
1728+ $firstByte = ord($data[$pos]);
1729+ if ($firstByte === 0xFF) break;
1730+
1731+ $start = $pos;
1732+ $value = $this->readLengthEncodedString($data, $pos);
1733+ if ($pos > $length) {
1734+ echo " 位置 {$start} での無効な長さエンコーディング\n";
1735+ break;
1736+ }
1737+
1738+ if ($value === null) {
1739+ echo " 文字列 ".(++$stringCount).": NULL\n";
1740+ } else {
1741+ echo " 文字列 ".(++$stringCount).": ".$this->safeString(substr($value, 0, 256))
1742+ ." (長さ: ".strlen($value).")\n";
1743+ }
1744+ }
1745+
1746+ if ($stringCount === 0) {
1747+ echo " 長さエンコード文字列はありません\n";
1748+ }
1749+ }
1750+
1751+ /**
1752+ * 文字列を安全に出力用に変換する
1753+ *
1754+ * 表示可能な文字のみを含み、非表示文字は16進数で表現します。
1755+ *
1756+ * @param string $str 変換する文字列
1757+ * @return string 安全な文字列
1758+ */
1759+ private function safeString(string $str): string {
1760+ $result = '';
1761+ $length = strlen($str);
1762+
1763+ for ($i = 0; $i < $length; $i++) {
1764+ $char = ord($str[$i]);
1765+ if ($char >= 32 && $char <= 126) {
1766+ $result .= $str[$i];
1767+ } else {
1768+ $result .= '\\x'.sprintf('%02X', $char);
1769+ }
1770+ }
1771+
1772+ return $result;
1773+ }
1774+
1775+ /**
1776+ * 長さエンコード整数を読む
1777+ *
1778+ * 0xFB=NULL、0xFC=2バイト、0xFD=3バイト、0xFE=8バイト、それ以外=その値。
1779+ *
1780+ * @param string $data パケットデータ
1781+ * @param int $pos 開始位置(読んだ分だけ進める)
1782+ * @return int|null 整数値、NULLの場合はnull
1783+ */
1784+ private function readLengthEncodedInteger(string $data, int &$pos): ?int {
1785+ $firstByte = ord($data[$pos] ?? "\0");
1786+ $pos++;
1787+
1788+ if ($firstByte < 0xFB) return $firstByte;
1789+ if ($firstByte === 0xFB) return null;
1790+
1791+ $size = [0xFC => 2, 0xFD => 3, 0xFE => 8][$firstByte] ?? 0;
1792+ $bytes = str_pad(substr($data, $pos, $size), 8, "\0");
1793+ $pos += $size;
1794+ return unpack('P', $bytes)[1];
1795+ }
1796+
1797+ /**
1798+ * 長さエンコード文字列を読む
1799+ *
1800+ * @param string $data パケットデータ
1801+ * @param int $pos 開始位置(読んだ分だけ進める)
1802+ * @return string|null 文字列、NULLの場合はnull
1803+ */
1804+ private function readLengthEncodedString(string $data, int &$pos): ?string {
1805+ $len = $this->readLengthEncodedInteger($data, $pos);
1806+ if ($len === null) return null;
1807+
1808+ $value = (string)substr($data, $pos, $len);
1809+ $pos += $len;
1810+ return $value;
1811+ }
1812+
1813+ /**
1814+ * 整数を長さエンコード形式に変換する
1815+ *
1816+ * MySQLプロトコルに基づいて整数を長さエンコードします。
1817+ *
1818+ * @param int $value 変換する整数
1819+ * @return string 長さエンコードされた文字列
1820+ */
1821+ private function encodeLengthEncodedInteger(int $value): string {
1822+ if ($value < 251) {
1823+ return chr($value);
1824+ } else if ($value < 65536) {
1825+ return chr(0xFC).pack('v', $value);
1826+ } else if ($value < 16777216) {
1827+ return chr(0xFD).substr(pack('V', $value), 0, 3);
1828+ } else {
1829+ return chr(0xFE).pack('P', $value);
1830+ }
1831+ }
1832+
1833+ /**
1834+ * STMTパケットをデバッグする
1835+ *
1836+ * COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。
1837+ *
1838+ * @param string $data パケットデータ
1839+ * @return void
1840+ */
1841+ private function debugStmtExecutePacket(string $data): void {
1842+ if (strlen($data) < 10) {
1843+ echo " エラー: パケットが短すぎます\n";
1844+ return;
1845+ }
1846+
1847+ $pos = 1; // コマンドバイトをスキップ
1848+ $statementId = unpack('V', $data, $pos)[1];
1849+ echo " ステートメントID: {$statementId}\n";
1850+ $pos += 5; // ステートメントIDとカーソルフラグをスキップ
1851+ $iterationCount = unpack('V', $data, $pos)[1];
1852+ echo " 繰り返し数: {$iterationCount}\n";
1853+ $pos += 4;
1854+
1855+ $numParams = $this->prepared[$statementId]['num_params'] ?? 0;
1856+ if ($numParams > 0) {
1857+ $nullBitmapLen = intdiv($numParams + 7, 8);
1858+ $nullBitmap = substr($data, $pos, $nullBitmapLen);
1859+ echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n";
1860+ $pos += $nullBitmapLen;
1861+
1862+ $newParamsFlag = ord($data[$pos]);
1863+ echo " 新パラメータフラグ: {$newParamsFlag}\n";
1864+ $pos++;
1865+
1866+ if ($newParamsFlag) {
1867+ echo " パラメータ種類:\n";
1868+
1869+ for ($i = 0; $i < $numParams; $i++) {
1870+ $type = ord($data[$pos]);
1871+ $typeName = $this->getMysqlTypeName($type);
1872+ echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n";
1873+ $pos += 2;
1874+ }
1875+
1876+ echo " パラメータ値: (生バイナリが続く)\n";
1877+ }
1878+ }
1879+ }
1880+
1881+ /**
1882+ * 16進値をMySQLタイプ名に変換する
1883+ *
1884+ * MySQLのデータ型コードを対応する型名に変換します。
1885+ *
1886+ * @param int $type 16進数の型コード
1887+ * @return string 型名(不明な場合は'不明')
1888+ */
1889+ private function getMysqlTypeName(int $type): string {
1890+ $types = [
1891+ 0x00 => 'MYSQL_TYPE_DECIMAL',
1892+ 0x01 => 'MYSQL_TYPE_TINY',
1893+ 0x02 => 'MYSQL_TYPE_SHORT',
1894+ 0x03 => 'MYSQL_TYPE_LONG',
1895+ 0x04 => 'MYSQL_TYPE_FLOAT',
1896+ 0x05 => 'MYSQL_TYPE_DOUBLE',
1897+ 0x06 => 'MYSQL_TYPE_NULL',
1898+ 0x07 => 'MYSQL_TYPE_TIMESTAMP',
1899+ 0x08 => 'MYSQL_TYPE_LONGLONG',
1900+ 0x09 => 'MYSQL_TYPE_INT24',
1901+ 0x0A => 'MYSQL_TYPE_DATE',
1902+ 0x0B => 'MYSQL_TYPE_TIME',
1903+ 0x0C => 'MYSQL_TYPE_DATETIME',
1904+ 0x0D => 'MYSQL_TYPE_YEAR',
1905+ 0x0E => 'MYSQL_TYPE_NEWDATE',
1906+ 0x0F => 'MYSQL_TYPE_VARCHAR',
1907+ 0x10 => 'MYSQL_TYPE_BIT',
1908+ 0xF5 => 'MYSQL_TYPE_JSON',
1909+ 0xF6 => 'MYSQL_TYPE_NEWDECIMAL',
1910+ 0xF7 => 'MYSQL_TYPE_ENUM',
1911+ 0xF8 => 'MYSQL_TYPE_SET',
1912+ 0xF9 => 'MYSQL_TYPE_TINY_BLOB',
1913+ 0xFA => 'MYSQL_TYPE_MEDIUM_BLOB',
1914+ 0xFB => 'MYSQL_TYPE_LONG_BLOB',
1915+ 0xFC => 'MYSQL_TYPE_BLOB',
1916+ 0xFD => 'MYSQL_TYPE_VAR_STRING',
1917+ 0xFE => 'MYSQL_TYPE_STRING',
1918+ 0xFF => 'MYSQL_TYPE_GEOMETRY',
1919+ ];
1920+
1921+ return $types[$type] ?? '不明';
1922+ }
1923+
1924+ /**
1925+ * 16進数とASCII値を横に並べて出力する
1926+ *
1927+ * 16進エディタのように、16進数とASCII値を並べて表示します。
1928+ *
1929+ * @param string $data バイナリデータ
1930+ * @return string フォーマットされた文字列
1931+ */
1932+ private function hexDumpWithAscii(string $data): string {
1933+ $output = '';
1934+ $len = strlen($data);
1935+ $offset = 0;
1936+
1937+ while ($offset < $len) {
1938+ $hex = '';
1939+ $ascii = '';
1940+ $bytesInLine = min(16, $len - $offset); // 1行あたり16バイト
1941+
1942+ // 16進数部分
1943+ for ($i = 0; $i < 16; $i++) {
1944+ if ($i < $bytesInLine) {
1945+ $hex .= sprintf('%02X ', ord($data[$offset + $i]));
1946+ } else {
1947+ $hex .= ' '; // 揃えのためにスペースを埋める
1948+ }
1949+ }
1950+
1951+ // ASCII部分
1952+ for ($i = 0; $i < $bytesInLine; $i++) {
1953+ $char = ord($data[$offset + $i]);
1954+ $ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.';
1955+ }
1956+
1957+ $output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii);
1958+ $offset += 16;
1959+ }
1960+
1961+ return $output;
1962+ }
1963+}
@@ -466,12 +466,6 @@
466466 ?string $message = null): Tester {
467467 try {
468468 call_user_func($callback);
469-
470- if ($message === null) {
471- $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
472- }
473-
474- throw new AssertionFailedException($message);
475469 } catch (\Throwable $e) {
476470 if (!($e instanceof $exceptionClass)) {
477471 if ($message === null) {
@@ -481,9 +475,16 @@
481475
482476 throw new AssertionFailedException($message);
483477 }
478+
479+ return $this;
484480 }
485481
486- return $this;
482+ // 以前はtryの中で投げていたので、\Exceptionを期待するとこの例外自体が合格になった
483+ if ($message === null) {
484+ $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
485+ }
486+
487+ throw new AssertionFailedException($message);
487488 }
488489
489490 /**
@@ -0,0 +1,934 @@
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+// テスト用の偽物のMySQLサーバー(MockServerが起動する)。
39+// 使い方:php mysql-server.php ポート [既定の認証プラグイン] [証明書のPEM] [秘密鍵のPEM]
40+// SQLはメモリーのSQLiteで実行し、結果をMySQLのプロトコル(テキスト・バイナリ)で返す(pdo_sqliteが必要)。
41+// 証明書と秘密鍵を渡すと、TLSとRSA(caching_sha2_password・sha256_passwordの完全な認証)に対応する。
42+//
43+// ユーザー(パスワード、認証プラグイン):
44+// lb(ひみつ、mysql_native_password)、nopw(なし、mysql_native_password)、
45+// sha2(sha2ひみつ、caching_sha2_password)、sha256(sha256ひみつ、sha256_password)、
46+// clear(clearひみつ、mysql_clear_password、TLSのみ)
47+// データベース:「nodb」以外は何でも存在する事にする
48+// 特別なSQL:
49+// SELECT LB_DISCONNECT() 返事をせずに接続を切る
50+// SELECT LB_SLEEP(秒) 返事を遅らせる
51+// CALL lb_multi() 結果を2つ返す
52+// @@lb_tls・@@lb_plugin・@@lb_full_auth・@@lb_caps・@@character_set_client・@@version、
53+// DATABASE()・CONNECTION_ID()・VERSION()
54+
55+$port = (int)($argv[1] ?? 0);
56+$defaultPlugin = $argv[2] ?? 'mysql_native_password';
57+$certFile = $argv[3] ?? '';
58+$keyFile = $argv[4] ?? '';
59+
60+if (!class_exists('PDO') || !in_array('sqlite', PDO::getAvailableDrivers(), true)) {
61+ fwrite(STDOUT, "error pdo_sqlite\n");
62+ exit(1);
63+}
64+
65+$server = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr);
66+if (!$server) {
67+ fwrite(STDOUT, "error {$errstr}\n");
68+ exit(1);
69+}
70+
71+$my = new MockMysql($defaultPlugin, $certFile, $keyFile);
72+fwrite(STDOUT, "ready\n");
73+fflush(STDOUT);
74+$my->run($server);
75+
76+class MockMysql {
77+ private const VERSION = '8.4.0-lbmock';
78+
79+ // 機能フラグ
80+ private const CLIENT_CONNECT_WITH_DB = 0x00000008;
81+ private const CLIENT_PROTOCOL_41 = 0x00000200;
82+ private const CLIENT_SSL = 0x00000800;
83+ private const CLIENT_SECURE_CONNECTION = 0x00008000;
84+ private const CLIENT_PLUGIN_AUTH = 0x00080000;
85+ private const CLIENT_CONNECT_ATTRS = 0x00100000;
86+ private const CLIENT_PLUGIN_AUTH_LENENC = 0x00200000;
87+ private const SERVER_CAPS = 0x00000001 | 0x00000002 | 0x00000004 | 0x00000008 | 0x00000200 | 0x00002000
88+ | 0x00008000 | 0x00020000 | 0x00040000 | 0x00080000 | 0x00100000 | 0x00200000;
89+
90+ // 状況
91+ private const STATUS_IN_TRANS = 0x0001;
92+ private const STATUS_AUTOCOMMIT = 0x0002;
93+ private const STATUS_MORE_RESULTS = 0x0008;
94+
95+ // 種類
96+ private const T_DECIMAL = 0xF6, T_TINY = 0x01, T_SHORT = 0x02, T_LONG = 0x03, T_FLOAT = 0x04, T_DOUBLE = 0x05,
97+ T_NULL = 0x06, T_TIMESTAMP = 0x07, T_LONGLONG = 0x08, T_INT24 = 0x09, T_DATE = 0x0A, T_TIME = 0x0B,
98+ T_DATETIME = 0x0C, T_YEAR = 0x0D, T_JSON = 0xF5, T_BLOB = 0xFC, T_VAR_STRING = 0xFD, T_STRING = 0xFE;
99+
100+ private const USERS = [
101+ 'lb' => ['ひみつ', 'mysql_native_password'],
102+ 'nopw' => ['', 'mysql_native_password'],
103+ 'sha2' => ['sha2ひみつ', 'caching_sha2_password'],
104+ 'sha256' => ['sha256ひみつ', 'sha256_password'],
105+ 'clear' => ['clearひみつ', 'mysql_clear_password'],
106+ ];
107+
108+ private const CHARSETS = [45 => 'utf8mb4', 33 => 'utf8mb3', 8 => 'latin1', 11 => 'ascii', 63 => 'binary', 12 => 'ujis', 13 => 'sjis', 95 => 'cp932', 97 => 'eucjpms', 255 => 'utf8mb4'];
109+
110+ private \PDO $pdo;
111+ private array $conns = [];
112+ private int $nextId = 1;
113+ private int $nextStmt = 1;
114+ private array $sha2Cache = [];
115+ private $privKey = null;
116+ private string $pubPem = '';
117+
118+ public function __construct(private string $defaultPlugin, private string $certFile, private string $keyFile) {
119+ $this->pdo = new \PDO('sqlite::memory:');
120+ $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
121+ if ($keyFile !== '' && function_exists('openssl_pkey_get_private')) {
122+ $this->privKey = openssl_pkey_get_private((string)file_get_contents($keyFile)) ?: null;
123+ if ($this->privKey) $this->pubPem = openssl_pkey_get_details($this->privKey)['key'];
124+ }
125+ }
126+
127+ public function run($server): void {
128+ while (true) {
129+ // TLSで既に復号されたデータが残っている接続を先に
130+ $pending = [];
131+ foreach ($this->conns as $id => $cn) {
132+ if ($cn['tls'] && (stream_get_meta_data($cn['sock'])['unread_bytes'] ?? 0) > 0) $pending[] = $id;
133+ }
134+
135+ if (empty($pending)) {
136+ $r = [$server];
137+ foreach ($this->conns as $cn) $r[] = $cn['sock'];
138+ $w = $e = null;
139+ $n = @stream_select($r, $w, $e, 120);
140+ if ($n === false || $n === 0) return;
141+
142+ foreach ($r as $s) {
143+ if ($s === $server) {
144+ $c = @stream_socket_accept($server, 5);
145+ if ($c) $this->accept($c);
146+ continue;
147+ }
148+ foreach ($this->conns as $id => $cn) {
149+ if ($cn['sock'] === $s) $pending[] = $id;
150+ }
151+ }
152+ }
153+
154+ foreach ($pending as $id) {
155+ if (!isset($this->conns[$id])) continue;
156+ $res = $this->handle($id);
157+ if ($res === 'quit') return;
158+ if ($res === false) $this->drop($id);
159+ }
160+ }
161+ }
162+
163+ ///////////////
164+ // 接続・認証
165+
166+ private function accept($c): void {
167+ stream_set_blocking($c, true);
168+ stream_set_timeout($c, 30);
169+ // 読み込みのバッファを無くす(SSLリクエストと一緒にTLSのClientHelloまで読んでしまうと、TLSを開始出来ない)
170+ stream_set_read_buffer($c, 0);
171+ $id = $this->nextId++;
172+ $scramble = '';
173+ for ($i = 0; $i < 20; $i++) $scramble .= chr(random_int(33, 126));
174+
175+ $caps = self::SERVER_CAPS | ($this->certFile !== '' ? self::CLIENT_SSL : 0);
176+ $this->conns[$id] = [
177+ 'sock' => $c, 'id' => $id, 'state' => 'new', 'seq' => 0, 'tls' => false, 'caps' => 0,
178+ 'user' => '', 'plugin' => '', 'scramble' => $scramble, 'db' => '', 'charset' => 45,
179+ 'fullAuth' => false, 'stmts' => [], 'inTrans' => false,
180+ ];
181+
182+ $g = chr(10).self::VERSION."\0".pack('V', $id).substr($scramble, 0, 8)."\0"
183+ .pack('v', $caps & 0xFFFF).chr(255).pack('v', self::STATUS_AUTOCOMMIT).pack('v', $caps >> 16)
184+ .chr(21).str_repeat("\0", 10).substr($scramble, 8)."\0".$this->defaultPlugin."\0";
185+ $this->send($id, $g);
186+ }
187+
188+ private function drop(int $id): void {
189+ @fclose($this->conns[$id]['sock']);
190+ unset($this->conns[$id]);
191+ }
192+
193+ /**
194+ * 1つのパケットを処理する
195+ *
196+ * @return bool|string false=接続を切る、'quit'=サーバーを止める
197+ */
198+ private function handle(int $id): bool|string {
199+ $cn = &$this->conns[$id];
200+
201+ if ($cn['state'] === 'new') {
202+ // MockServer::stop()の終了のリクエスト
203+ $head = $this->readRaw($id, 4);
204+ if ($head === null) return false;
205+ if ($head === 'QUIT') return 'quit';
206+ $pkt = $this->readRest($id, $head);
207+ if ($pkt === null) return false;
208+ return $this->handshake($id, $pkt);
209+ }
210+
211+ $pkt = $this->recv($id);
212+ if ($pkt === null || $pkt === '') return false;
213+ return $this->command($id, $pkt);
214+ }
215+
216+ private function handshake(int $id, string $pkt): bool {
217+ $cn = &$this->conns[$id];
218+ $caps = unpack('V', $pkt)[1];
219+
220+ // SSLリクエスト(32バイト)
221+ if (($caps & self::CLIENT_SSL) && strlen($pkt) === 32) {
222+ if ($this->certFile === '') return false;
223+ $s = $cn['sock'];
224+ stream_context_set_option($s, 'ssl', 'local_cert', $this->certFile);
225+ stream_context_set_option($s, 'ssl', 'local_pk', $this->keyFile);
226+ stream_context_set_option($s, 'ssl', 'verify_peer', false);
227+ if (!@stream_socket_enable_crypto($s, true, STREAM_CRYPTO_METHOD_TLS_SERVER)) return false;
228+ $cn['tls'] = true;
229+ $pkt = $this->recv($id);
230+ if ($pkt === null) return false;
231+ $caps = unpack('V', $pkt)[1];
232+ }
233+
234+ if (!($caps & self::CLIENT_PROTOCOL_41)) {
235+ $this->err($id, 1251, '08004', 'Client does not support authentication protocol requested by server');
236+ return false;
237+ }
238+
239+ $cn['caps'] = $caps;
240+ $cn['charset'] = ord($pkt[8]);
241+ $pos = 32;
242+ $end = strpos($pkt, "\0", $pos);
243+ $user = substr($pkt, $pos, $end - $pos);
244+ $pos = $end + 1;
245+ if ($caps & self::CLIENT_PLUGIN_AUTH_LENENC) {
246+ $auth = $this->lenencStr($pkt, $pos);
247+ } else if ($caps & self::CLIENT_SECURE_CONNECTION) {
248+ $len = ord($pkt[$pos]);
249+ $auth = substr($pkt, $pos + 1, $len);
250+ $pos += 1 + $len;
251+ } else {
252+ $end = strpos($pkt, "\0", $pos);
253+ $auth = substr($pkt, $pos, $end - $pos);
254+ $pos = $end + 1;
255+ }
256+ $db = '';
257+ if ($caps & self::CLIENT_CONNECT_WITH_DB) {
258+ $end = strpos($pkt, "\0", $pos);
259+ $db = substr($pkt, $pos, ($end === false ? strlen($pkt) : $end) - $pos);
260+ $pos = $end === false ? strlen($pkt) : $end + 1;
261+ }
262+ $clientPlugin = '';
263+ if ($caps & self::CLIENT_PLUGIN_AUTH) {
264+ $end = strpos($pkt, "\0", $pos);
265+ $clientPlugin = substr($pkt, $pos, ($end === false ? strlen($pkt) : $end) - $pos);
266+ }
267+
268+ $cn['user'] = $user;
269+ $known = self::USERS[$user] ?? null;
270+ $using = $auth === '' || $auth === "\0" ? 'NO' : 'YES';
271+ if (!$known) {
272+ $this->err($id, 1045, '28000', "Access denied for user '{$user}'@'localhost' (using password: {$using})");
273+ return false;
274+ }
275+ [$pw, $plugin] = $known;
276+ $cn['plugin'] = $plugin;
277+
278+ // クライアントのプラグインが違えば、切り替えを求める
279+ if ($clientPlugin !== $plugin) {
280+ for ($i = 0, $cn['scramble'] = ''; $i < 20; $i++) $cn['scramble'] .= chr(random_int(33, 126));
281+ $this->send($id, "\xFE{$plugin}\0{$cn['scramble']}\0");
282+ $auth = $this->recv($id);
283+ if ($auth === null) return false;
284+ }
285+
286+ if (!$this->verify($id, $plugin, $pw, $auth)) {
287+ $this->err($id, 1045, '28000', "Access denied for user '{$user}'@'localhost' (using password: {$using})");
288+ return false;
289+ }
290+
291+ if ($db === 'nodb') {
292+ $this->err($id, 1049, '42000', "Unknown database '{$db}'");
293+ return false;
294+ }
295+ $cn['db'] = $db;
296+ $cn['state'] = 'cmd';
297+ $this->ok($id);
298+ return true;
299+ }
300+
301+ private function verify(int $id, string $plugin, string $pw, ?string $auth): bool {
302+ $cn = &$this->conns[$id];
303+ $scramble = $cn['scramble'];
304+ if ($auth === null) return false;
305+
306+ switch ($plugin) {
307+ case 'mysql_native_password':
308+ if ($pw === '') return $auth === '';
309+ $s1 = sha1($pw, true);
310+ return $auth === ($s1 ^ sha1($scramble.sha1($s1, true), true));
311+
312+ case 'caching_sha2_password':
313+ if ($pw === '') return $auth === '';
314+ $s1 = hash('sha256', $pw, true);
315+ $fast = $s1 ^ hash('sha256', hash('sha256', $s1, true).$scramble, true);
316+ if (isset($this->sha2Cache[$cn['user']])) {
317+ if ($auth !== $fast) return false;
318+ $this->send($id, "\x01\x03");
319+ return true;
320+ }
321+ // キャッシュに無い:完全な認証
322+ $this->send($id, "\x01\x04");
323+ $res = $this->recv($id);
324+ if ($res === null) return false;
325+ if ($cn['tls']) {
326+ $ok = $res === $pw."\0";
327+ } else {
328+ if ($res === "\x02") {
329+ if ($this->pubPem === '') return false;
330+ $this->send($id, "\x01".$this->pubPem);
331+ $res = $this->recv($id);
332+ }
333+ $ok = $this->decrypt((string)$res, $scramble) === $pw."\0";
334+ }
335+ if ($ok) {
336+ $this->sha2Cache[$cn['user']] = true;
337+ $cn['fullAuth'] = true;
338+ }
339+ return $ok;
340+
341+ case 'sha256_password':
342+ if ($pw === '') return $auth === "\0" || $auth === '';
343+ if ($cn['tls']) return $auth === $pw."\0";
344+ if ($auth === "\x01") {
345+ if ($this->pubPem === '') return false;
346+ $this->send($id, "\x01".$this->pubPem);
347+ $auth = $this->recv($id);
348+ }
349+ $cn['fullAuth'] = true;
350+ return $this->decrypt((string)$auth, $scramble) === $pw."\0";
351+
352+ case 'mysql_clear_password':
353+ return $cn['tls'] && $auth === $pw."\0";
354+ }
355+
356+ return false;
357+ }
358+
359+ private function decrypt(string $data, string $scramble): ?string {
360+ if (!$this->privKey || !openssl_private_decrypt($data, $out, $this->privKey, OPENSSL_PKCS1_OAEP_PADDING)) return null;
361+ $res = '';
362+ for ($i = 0; $i < strlen($out); $i++) $res .= $out[$i] ^ $scramble[$i % strlen($scramble)];
363+ return $res;
364+ }
365+
366+ ///////////////
367+ // コマンド
368+
369+ private function command(int $id, string $pkt): bool {
370+ $cn = &$this->conns[$id];
371+ $cmd = ord($pkt[0]);
372+ $data = (string)substr($pkt, 1);
373+
374+ switch ($cmd) {
375+ case 0x01: // COM_QUIT
376+ return false;
377+ case 0x02: // COM_INIT_DB
378+ if ($data === 'nodb') {
379+ $this->err($id, 1049, '42000', "Unknown database '{$data}'");
380+ } else {
381+ $cn['db'] = $data;
382+ $this->ok($id);
383+ }
384+ return true;
385+ case 0x03: // COM_QUERY
386+ return $this->query($id, $data);
387+ case 0x0E: // COM_PING
388+ $this->ok($id);
389+ return true;
390+ case 0x16: // COM_STMT_PREPARE
391+ $this->prepare($id, $data);
392+ return true;
393+ case 0x17: // COM_STMT_EXECUTE
394+ $this->execute($id, $data);
395+ return true;
396+ case 0x19: // COM_STMT_CLOSE(返事なし)
397+ unset($cn['stmts'][unpack('V', $data)[1]]);
398+ return true;
399+ case 0x1A: // COM_STMT_RESET
400+ $this->ok($id);
401+ return true;
402+ }
403+
404+ $this->err($id, 1047, '08S01', 'Unknown command');
405+ return true;
406+ }
407+
408+ private function query(int $id, string $sql): bool {
409+ $cn = &$this->conns[$id];
410+ $sql = rtrim(trim($sql), ';');
411+
412+ if (preg_match('/^SELECT\s+LB_DISCONNECT\(\)/i', $sql)) return false;
413+ if (preg_match('/LB_SLEEP\(([\d.]+)\)/i', $sql, $m)) {
414+ usleep((int)((float)$m[1] * 1000000));
415+ $sql = preg_replace('/LB_SLEEP\([\d.]+\)/i', '0', $sql);
416+ }
417+
418+ if (preg_match('/^CALL\s+lb_multi\(\)$/i', $sql)) {
419+ $this->resultSet($id, $this->pdo->query("SELECT 1 AS a"), false, self::STATUS_MORE_RESULTS);
420+ $this->resultSet($id, $this->pdo->query("SELECT '二' AS b"), false, self::STATUS_MORE_RESULTS);
421+ $this->ok($id);
422+ return true;
423+ }
424+
425+ if (preg_match('/^(START\s+TRANSACTION|BEGIN)$/i', $sql)) {
426+ if ($this->pdo->inTransaction()) $this->pdo->commit();
427+ $this->pdo->beginTransaction();
428+ $cn['inTrans'] = true;
429+ $this->ok($id);
430+ return true;
431+ }
432+ if (preg_match('/^(COMMIT|ROLLBACK)$/i', $sql, $m)) {
433+ if ($this->pdo->inTransaction()) strtoupper($m[1]) === 'COMMIT' ? $this->pdo->commit() : $this->pdo->rollBack();
434+ $cn['inTrans'] = false;
435+ $this->ok($id);
436+ return true;
437+ }
438+ if (preg_match('/^SET\s+NAMES\s+\'?(\w+)/i', $sql, $m)) {
439+ $cs = array_search(strtolower($m[1]), self::CHARSETS, true);
440+ if ($cs !== false) $cn['charset'] = $cs;
441+ $this->ok($id);
442+ return true;
443+ }
444+ if (preg_match('/^SET\s/i', $sql)) {
445+ $this->ok($id);
446+ return true;
447+ }
448+ if (preg_match('/^USE\s+`?(\w+)`?$/i', $sql, $m)) {
449+ return $this->command($id, "\x02".$m[1]);
450+ }
451+
452+ try {
453+ $st = $this->pdo->prepare($this->rewrite($id, $sql));
454+ $st->execute();
455+ } catch (\PDOException $e) {
456+ $this->sqlError($id, $e);
457+ return true;
458+ }
459+
460+ $this->finish($id, $st, $sql, false);
461+ return true;
462+ }
463+
464+ private function prepare(int $id, string $sql): void {
465+ $cn = &$this->conns[$id];
466+ $sql = rtrim(trim($sql), ';');
467+ [$n, $marks] = $this->placeholders($sql);
468+
469+ try {
470+ $st = $this->pdo->prepare($this->rewrite($id, $sql));
471+ $cols = [];
472+ if (preg_match('/^\s*(SELECT|WITH|VALUES|PRAGMA)/i', $sql)) {
473+ $st->execute(array_fill(0, $n, null));
474+ $cols = $this->columns($st, []);
475+ $st->closeCursor();
476+ }
477+ } catch (\PDOException $e) {
478+ $this->sqlError($id, $e);
479+ return;
480+ }
481+
482+ $sid = $this->nextStmt++;
483+ $cn['stmts'][$sid] = ['sql' => $sql, 'n' => $n, 'marks' => $marks, 'types' => array_fill(0, $n, [self::T_VAR_STRING, 0])];
484+
485+ $this->send($id, "\x00".pack('V', $sid).pack('v', count($cols)).pack('v', $n)."\x00".pack('v', 0));
486+ if ($n > 0) {
487+ for ($i = 0; $i < $n; $i++) $this->send($id, $this->colDef(['name' => '?', 'table' => '', 'type' => self::T_VAR_STRING, 'flags' => 0, 'decimals' => 0, 'charset' => 63]));
488+ $this->eof($id);
489+ }
490+ if ($cols) {
491+ foreach ($cols as $c) $this->send($id, $this->colDef($c));
492+ $this->eof($id);
493+ }
494+ }
495+
496+ private function execute(int $id, string $data): void {
497+ $cn = &$this->conns[$id];
498+ $sid = unpack('V', $data)[1];
499+ if (!isset($cn['stmts'][$sid])) {
500+ $this->err($id, 1243, 'HY000', "Unknown prepared statement handler ({$sid}) given to mysqld_stmt_execute");
501+ return;
502+ }
503+ $stmt = &$cn['stmts'][$sid];
504+ $n = $stmt['n'];
505+ $pos = 9;
506+ $values = [];
507+
508+ if ($n > 0) {
509+ $bitmap = substr($data, $pos, intdiv($n + 7, 8));
510+ $pos += strlen($bitmap);
511+ $bound = ord($data[$pos]);
512+ $pos++;
513+ if ($bound) {
514+ for ($i = 0; $i < $n; $i++) {
515+ $stmt['types'][$i] = [ord($data[$pos]), ord($data[$pos + 1])];
516+ $pos += 2;
517+ }
518+ }
519+ for ($i = 0; $i < $n; $i++) {
520+ if (ord($bitmap[$i >> 3]) & (1 << ($i & 7))) {
521+ $values[] = null;
522+ continue;
523+ }
524+ $values[] = $this->readParam($data, $pos, $stmt['types'][$i][0], ($stmt['types'][$i][1] & 0x80) !== 0);
525+ }
526+ }
527+
528+ // 浮動小数点数はSQLiteにREALとして渡す
529+ $sql = $stmt['sql'];
530+ foreach (array_reverse(array_keys($values)) as $i) {
531+ if (is_float($values[$i])) $sql = substr_replace($sql, 'CAST(? AS REAL)', $stmt['marks'][$i], 1);
532+ }
533+
534+ try {
535+ $st = $this->pdo->prepare($this->rewrite($id, $sql));
536+ foreach ($values as $i => $v) {
537+ $type = match (true) {
538+ $v === null => \PDO::PARAM_NULL,
539+ is_int($v) => \PDO::PARAM_INT,
540+ is_string($v) && !mb_check_encoding($v, 'UTF-8') => \PDO::PARAM_LOB,
541+ default => \PDO::PARAM_STR,
542+ };
543+ $st->bindValue($i + 1, is_float($v) ? var_export($v, true) : $v, $type);
544+ }
545+ $st->execute();
546+ } catch (\PDOException $e) {
547+ $this->sqlError($id, $e);
548+ return;
549+ }
550+
551+ $this->finish($id, $st, $stmt['sql'], true);
552+ }
553+
554+ private function readParam(string $d, int &$pos, int $type, bool $unsigned): mixed {
555+ switch ($type) {
556+ case self::T_TINY:
557+ $v = ord($d[$pos]);
558+ $pos += 1;
559+ return $unsigned || $v < 0x80 ? $v : $v - 0x100;
560+ case self::T_SHORT:
561+ case self::T_YEAR:
562+ $v = unpack('v', $d, $pos)[1];
563+ $pos += 2;
564+ return $unsigned || $v < 0x8000 ? $v : $v - 0x10000;
565+ case self::T_LONG:
566+ case self::T_INT24:
567+ $v = unpack('V', $d, $pos)[1];
568+ $pos += 4;
569+ return $unsigned || $v < 0x80000000 ? $v : $v - 0x100000000;
570+ case self::T_LONGLONG:
571+ $v = unpack('P', $d, $pos)[1];
572+ $pos += 8;
573+ return $unsigned && $v < 0 ? sprintf('%u', $v) : $v;
574+ case self::T_FLOAT:
575+ $v = unpack('g', $d, $pos)[1];
576+ $pos += 4;
577+ return $v;
578+ case self::T_DOUBLE:
579+ $v = unpack('e', $d, $pos)[1];
580+ $pos += 8;
581+ return $v;
582+ case self::T_NULL:
583+ return null;
584+ case self::T_DATE:
585+ case self::T_DATETIME:
586+ case self::T_TIMESTAMP:
587+ $len = ord($d[$pos]);
588+ $b = substr($d, $pos + 1, $len).str_repeat("\0", 11);
589+ $pos += 1 + $len;
590+ $s = sprintf('%04d-%02d-%02d', unpack('v', $b)[1], ord($b[2]), ord($b[3]));
591+ if ($type === self::T_DATE) return $s;
592+ $us = unpack('V', $b, 7)[1];
593+ return $s.sprintf(' %02d:%02d:%02d', ord($b[4]), ord($b[5]), ord($b[6])).($us ? sprintf('.%06d', $us) : '');
594+ case self::T_TIME:
595+ $len = ord($d[$pos]);
596+ $b = substr($d, $pos + 1, $len).str_repeat("\0", 12);
597+ $pos += 1 + $len;
598+ $us = unpack('V', $b, 8)[1];
599+ return (ord($b[0]) ? '-' : '').sprintf('%02d:%02d:%02d', unpack('V', $b, 1)[1] * 24 + ord($b[5]), ord($b[6]), ord($b[7])).($us ? sprintf('.%06d', $us) : '');
600+ }
601+
602+ return $this->lenencStr($d, $pos);
603+ }
604+
605+ /** 結果セット又はOK */
606+ private function finish(int $id, \PDOStatement $st, string $sql, bool $binary): void {
607+ if ($st->columnCount() > 0) {
608+ $this->resultSet($id, $st, $binary);
609+ return;
610+ }
611+ $insertId = preg_match('/^\s*(INSERT|REPLACE)\b/i', $sql) ? (int)$this->pdo->lastInsertId() : 0;
612+ $this->ok($id, $st->rowCount(), $insertId);
613+ }
614+
615+ ///////////////
616+ // SQLite → MySQL
617+
618+ /** MySQLの書き方をSQLiteにする、特別な変数を値にする */
619+ private function rewrite(int $id, string $sql): string {
620+ $cn = $this->conns[$id];
621+ $q = fn(string $s): string => $this->pdo->quote($s);
622+ $sql = preg_replace('/\b(?:TINY|SMALL|MEDIUM|BIG)?INT(?:EGER)?(?:\(\d+\))?(?:\s+UNSIGNED)?(?:\s+NOT\s+NULL)?\s+AUTO_INCREMENT(?:\s+PRIMARY\s+KEY)?/i', 'INTEGER PRIMARY KEY AUTOINCREMENT', $sql);
623+ $sql = preg_replace('/\)\s*(ENGINE|DEFAULT\s+CHARSET|CHARSET|COLLATE)\s*=.*$/is', ')', $sql);
624+
625+ return strtr($sql, [
626+ 'DATABASE()' => $cn['db'] === '' ? 'NULL' : $q($cn['db']),
627+ 'CONNECTION_ID()' => (string)$cn['id'],
628+ 'VERSION()' => $q(self::VERSION),
629+ '@@version' => $q(self::VERSION),
630+ '@@lb_tls' => $cn['tls'] ? '1' : '0',
631+ '@@lb_plugin' => $q($cn['plugin']),
632+ '@@lb_full_auth' => $cn['fullAuth'] ? '1' : '0',
633+ '@@lb_caps' => (string)$cn['caps'],
634+ '@@character_set_client' => $q(self::CHARSETS[$cn['charset']] ?? (string)$cn['charset']),
635+ ]);
636+ }
637+
638+ /** 「?」の数と位置(文字列・識別子・コメントの中は数えない) */
639+ private function placeholders(string $sql): array {
640+ $marks = [];
641+ $len = strlen($sql);
642+ for ($i = 0; $i < $len; $i++) {
643+ $c = $sql[$i];
644+ if ($c === "'" || $c === '"' || $c === '`') {
645+ for ($i++; $i < $len && $sql[$i] !== $c; $i++) if ($sql[$i] === '\\') $i++;
646+ } else if ($c === '-' && ($sql[$i + 1] ?? '') === '-') {
647+ while ($i < $len && $sql[$i] !== "\n") $i++;
648+ } else if ($c === '/' && ($sql[$i + 1] ?? '') === '*') {
649+ $end = strpos($sql, '*/', $i + 2);
650+ $i = $end === false ? $len : $end + 1;
651+ } else if ($c === '?') {
652+ $marks[] = $i;
653+ }
654+ }
655+ return [count($marks), $marks];
656+ }
657+
658+ /** 列の説明(宣言された型から、無ければ最初の値から) */
659+ private function columns(\PDOStatement $st, array $rows): array {
660+ $cols = [];
661+ for ($i = 0; $i < $st->columnCount(); $i++) {
662+ $meta = $st->getColumnMeta($i) ?: [];
663+ $decl = strtoupper((string)($meta['sqlite:decl_type'] ?? ''));
664+ $col = ['name' => (string)($meta['name'] ?? ''), 'table' => (string)($meta['table'] ?? ''), 'flags' => 0, 'decimals' => 0, 'charset' => 45];
665+
666+ if (str_contains($decl, 'UNSIGNED')) $col['flags'] |= 0x20;
667+ $col['type'] = match (true) {
668+ $decl === '' => null,
669+ str_starts_with($decl, 'TINYINT') || str_starts_with($decl, 'BOOL') => self::T_TINY,
670+ str_starts_with($decl, 'SMALLINT') => self::T_SHORT,
671+ str_starts_with($decl, 'MEDIUMINT') => self::T_INT24,
672+ str_starts_with($decl, 'BIGINT') => self::T_LONGLONG,
673+ str_starts_with($decl, 'INT') => self::T_LONG,
674+ str_starts_with($decl, 'FLOAT') => self::T_FLOAT,
675+ str_starts_with($decl, 'DOUBLE') || str_starts_with($decl, 'REAL') => self::T_DOUBLE,
676+ str_starts_with($decl, 'DECIMAL') || str_starts_with($decl, 'NUMERIC') => self::T_DECIMAL,
677+ str_starts_with($decl, 'DATETIME') => self::T_DATETIME,
678+ str_starts_with($decl, 'TIMESTAMP') => self::T_TIMESTAMP,
679+ str_starts_with($decl, 'DATE') => self::T_DATE,
680+ str_starts_with($decl, 'TIME') => self::T_TIME,
681+ str_starts_with($decl, 'YEAR') => self::T_YEAR,
682+ str_starts_with($decl, 'JSON') => self::T_JSON,
683+ str_contains($decl, 'BLOB') || str_contains($decl, 'BINARY') => self::T_BLOB,
684+ str_contains($decl, 'TEXT') => self::T_BLOB,
685+ str_starts_with($decl, 'CHAR') => self::T_STRING,
686+ default => self::T_VAR_STRING,
687+ };
688+ if (str_contains($decl, 'BLOB') || str_contains($decl, 'BINARY')) {
689+ $col['charset'] = 63;
690+ $col['flags'] |= 0x80;
691+ }
692+ if (in_array($col['type'], [self::T_FLOAT, self::T_DOUBLE], true)) $col['decimals'] = 31;
693+ if (preg_match('/\((\d+)(?:,\s*(\d+))?\)/', $decl, $m)) {
694+ if ($col['type'] === self::T_DECIMAL) $col['decimals'] = (int)($m[2] ?? 0);
695+ if (in_array($col['type'], [self::T_FLOAT, self::T_DOUBLE], true) && isset($m[2])) $col['decimals'] = (int)$m[2];
696+ if (in_array($col['type'], [self::T_DATETIME, self::T_TIMESTAMP, self::T_TIME], true)) $col['decimals'] = (int)$m[1];
697+ }
698+
699+ if ($col['type'] === null) {
700+ $col['type'] = self::T_NULL;
701+ foreach ($rows as $r) {
702+ if ($r[$i] === null) continue;
703+ $col['type'] = is_int($r[$i]) ? self::T_LONGLONG : (is_float($r[$i]) ? self::T_DOUBLE : self::T_VAR_STRING);
704+ if ($col['type'] === self::T_DOUBLE) $col['decimals'] = 31;
705+ break;
706+ }
707+ if ($col['type'] === self::T_NULL && empty($rows)) $col['type'] = self::T_VAR_STRING;
708+ }
709+ $cols[] = $col;
710+ }
711+ return $cols;
712+ }
713+
714+ private function resultSet(int $id, \PDOStatement $st, bool $binary, int $extra = 0): void {
715+ $rows = $st->fetchAll(\PDO::FETCH_NUM);
716+ $cols = $this->columns($st, $rows);
717+
718+ $this->send($id, $this->lenenc(count($cols)));
719+ foreach ($cols as $c) $this->send($id, $this->colDef($c));
720+ $this->eof($id);
721+
722+ foreach ($rows as $r) {
723+ if ($binary) {
724+ $bitmap = str_repeat("\0", intdiv(count($cols) + 9, 8));
725+ $vals = '';
726+ foreach ($cols as $i => $c) {
727+ if ($r[$i] === null) {
728+ $b = $i + 2;
729+ $bitmap[$b >> 3] = chr(ord($bitmap[$b >> 3]) | (1 << ($b & 7)));
730+ continue;
731+ }
732+ $vals .= $this->binValue($r[$i], $c);
733+ }
734+ $this->send($id, "\x00".$bitmap.$vals);
735+ } else {
736+ $out = '';
737+ foreach ($cols as $i => $c) {
738+ $out .= $r[$i] === null ? "\xFB" : $this->lenencStrOut($this->textValue($r[$i], $c));
739+ }
740+ $this->send($id, $out);
741+ }
742+ }
743+ $this->eof($id, $extra);
744+ }
745+
746+ private function textValue(mixed $v, array $c): string {
747+ switch ($c['type']) {
748+ case self::T_DECIMAL:
749+ return sprintf('%.'.$c['decimals'].'F', (float)$v);
750+ case self::T_FLOAT:
751+ return (string)(float)sprintf('%.6g', (float)$v);
752+ case self::T_DOUBLE:
753+ return (string)(float)$v;
754+ case self::T_DATETIME:
755+ case self::T_TIMESTAMP:
756+ $t = $this->splitDateTime((string)$v);
757+ return sprintf('%04d-%02d-%02d %02d:%02d:%02d', ...array_slice($t, 0, 6)).$this->frac($t[6], $c['decimals']);
758+ case self::T_TIME:
759+ $t = $this->splitTime((string)$v);
760+ return ($t[0] ? '-' : '').sprintf('%02d:%02d:%02d', $t[1], $t[2], $t[3]).$this->frac($t[4], $c['decimals']);
761+ }
762+ return (string)$v;
763+ }
764+
765+ private function binValue(mixed $v, array $c): string {
766+ switch ($c['type']) {
767+ case self::T_TINY:
768+ return chr((int)$v & 0xFF);
769+ case self::T_SHORT:
770+ case self::T_YEAR:
771+ return pack('v', (int)$v & 0xFFFF);
772+ case self::T_LONG:
773+ case self::T_INT24:
774+ return pack('V', (int)$v & 0xFFFFFFFF);
775+ case self::T_LONGLONG:
776+ return pack('P', (int)$v);
777+ case self::T_FLOAT:
778+ return pack('g', (float)$v);
779+ case self::T_DOUBLE:
780+ return pack('e', (float)$v);
781+ case self::T_DATE:
782+ case self::T_DATETIME:
783+ case self::T_TIMESTAMP:
784+ $t = $this->splitDateTime((string)$v);
785+ if ($c['type'] === self::T_DATE || ($t[3] === 0 && $t[4] === 0 && $t[5] === 0 && $t[6] === 0)) {
786+ if ($t[0] === 0 && $t[1] === 0 && $t[2] === 0) return "\x00";
787+ return "\x04".pack('v', $t[0]).chr($t[1]).chr($t[2]);
788+ }
789+ if ($t[6] === 0) return "\x07".pack('v', $t[0]).chr($t[1]).chr($t[2]).chr($t[3]).chr($t[4]).chr($t[5]);
790+ return "\x0B".pack('v', $t[0]).chr($t[1]).chr($t[2]).chr($t[3]).chr($t[4]).chr($t[5]).pack('V', $t[6]);
791+ case self::T_TIME:
792+ $t = $this->splitTime((string)$v);
793+ $days = intdiv($t[1], 24);
794+ $body = chr($t[0]).pack('V', $days).chr($t[1] % 24).chr($t[2]).chr($t[3]);
795+ if ($t[4] === 0) return ($t[1] === 0 && $t[2] === 0 && $t[3] === 0) ? "\x00" : "\x08".$body;
796+ return "\x0C".$body.pack('V', $t[4]);
797+ }
798+ return $this->lenencStrOut($this->textValue($v, $c));
799+ }
800+
801+ /** [年, 月, 日, 時, 分, 秒, マイクロ秒] */
802+ private function splitDateTime(string $v): array {
803+ preg_match('/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?)?/', $v, $m);
804+ return [(int)($m[1] ?? 0), (int)($m[2] ?? 0), (int)($m[3] ?? 0), (int)($m[4] ?? 0), (int)($m[5] ?? 0), (int)($m[6] ?? 0), (int)str_pad($m[7] ?? '0', 6, '0')];
805+ }
806+
807+ /** [負数, 時, 分, 秒, マイクロ秒] */
808+ private function splitTime(string $v): array {
809+ preg_match('/^(-)?(\d+):(\d{2}):(\d{2})(?:\.(\d{1,6}))?/', $v, $m);
810+ return [!empty($m[1]) ? 1 : 0, (int)($m[2] ?? 0), (int)($m[3] ?? 0), (int)($m[4] ?? 0), (int)str_pad($m[5] ?? '0', 6, '0')];
811+ }
812+
813+ private function frac(int $us, int $dec): string {
814+ return $dec > 0 && $dec <= 6 ? '.'.substr(sprintf('%06d', $us), 0, $dec) : '';
815+ }
816+
817+ private function sqlError(int $id, \PDOException $e): void {
818+ $msg = $e->getMessage();
819+ $db = $this->conns[$id]['db'];
820+ if (preg_match('/no such table: (\S+)/', $msg, $m)) {
821+ $this->err($id, 1146, '42S02', "Table '{$db}.{$m[1]}' doesn't exist");
822+ } else if (preg_match('/UNIQUE constraint failed: (\S+)/', $msg, $m)) {
823+ $this->err($id, 1062, '23000', "Duplicate entry for key '{$m[1]}'");
824+ } else if (preg_match('/NOT NULL constraint failed: \w+\.(\w+)/', $msg, $m)) {
825+ $this->err($id, 1048, '23000', "Column '{$m[1]}' cannot be null");
826+ } else if (preg_match('/no such column: (\S+)/', $msg, $m)) {
827+ $this->err($id, 1054, '42S22', "Unknown column '{$m[1]}' in 'field list'");
828+ } else if (str_contains($msg, 'syntax error')) {
829+ $this->err($id, 1064, '42000', 'You have an error in your SQL syntax; '.preg_replace('/^.*?: \d+ /', '', $msg));
830+ } else {
831+ $this->err($id, 1105, 'HY000', preg_replace('/^SQLSTATE\[\w+\]: [^:]*: \d+ /', '', $msg));
832+ }
833+ }
834+
835+ ///////////////
836+ // パケット
837+
838+ private function status(int $id, int $extra = 0): int {
839+ return self::STATUS_AUTOCOMMIT | ($this->conns[$id]['inTrans'] ? self::STATUS_IN_TRANS : 0) | $extra;
840+ }
841+
842+ private function ok(int $id, int $affected = 0, int $insertId = 0): void {
843+ $this->send($id, "\x00".$this->lenenc($affected).$this->lenenc($insertId).pack('v', $this->status($id)).pack('v', 0));
844+ }
845+
846+ private function eof(int $id, int $extra = 0): void {
847+ $this->send($id, "\xFE".pack('v', 0).pack('v', $this->status($id, $extra)));
848+ }
849+
850+ private function err(int $id, int $code, string $state, string $msg): void {
851+ $this->send($id, "\xFF".pack('v', $code).'#'.$state.$msg);
852+ }
853+
854+ private function colDef(array $c): string {
855+ return $this->lenencStrOut('def').$this->lenencStrOut($c['table'] === '' ? '' : 'lb').$this->lenencStrOut($c['table'])
856+ .$this->lenencStrOut($c['table']).$this->lenencStrOut($c['name']).$this->lenencStrOut($c['name'])
857+ ."\x0C".pack('v', $c['charset']).pack('V', 255).chr($c['type']).pack('v', $c['flags']).chr($c['decimals'])."\0\0";
858+ }
859+
860+ private function lenenc(int $v): string {
861+ if ($v < 251) return chr($v);
862+ if ($v < 65536) return "\xFC".pack('v', $v);
863+ if ($v < 16777216) return "\xFD".substr(pack('V', $v), 0, 3);
864+ return "\xFE".pack('P', $v);
865+ }
866+
867+ private function lenencStrOut(string $s): string {
868+ return $this->lenenc(strlen($s)).$s;
869+ }
870+
871+ private function lenencStr(string $d, int &$pos): string {
872+ $f = ord($d[$pos] ?? "\0");
873+ $pos++;
874+ if ($f < 251) {
875+ $len = $f;
876+ } else {
877+ $size = [0xFC => 2, 0xFD => 3, 0xFE => 8][$f] ?? 0;
878+ $len = unpack('P', str_pad(substr($d, $pos, $size), 8, "\0"))[1];
879+ $pos += $size;
880+ }
881+ $s = (string)substr($d, $pos, $len);
882+ $pos += $len;
883+ return $s;
884+ }
885+
886+ private function send(int $id, string $data): void {
887+ $cn = &$this->conns[$id];
888+ $pos = 0;
889+ $total = strlen($data);
890+ do {
891+ $len = min($total - $pos, 0xFFFFFF);
892+ $out = substr(pack('V', $len), 0, 3).chr($cn['seq']).substr($data, $pos, $len);
893+ $pos += $len;
894+ $cn['seq'] = ($cn['seq'] + 1) & 0xFF;
895+ for ($w = 0; $w < strlen($out);) {
896+ $n = @fwrite($cn['sock'], substr($out, $w, 1048576));
897+ if ($n === false || $n === 0) return;
898+ $w += $n;
899+ }
900+ } while ($len === 0xFFFFFF);
901+ }
902+
903+ private function readRaw(int $id, int $n): ?string {
904+ $buf = '';
905+ while (strlen($buf) < $n) {
906+ $x = @fread($this->conns[$id]['sock'], $n - strlen($buf));
907+ if ($x === false || $x === '') return null;
908+ $buf .= $x;
909+ }
910+ return $buf;
911+ }
912+
913+ private function readRest(int $id, string $head): ?string {
914+ $data = '';
915+ while (true) {
916+ $len = unpack('V', substr($head, 0, 3)."\0")[1];
917+ $this->conns[$id]['seq'] = (ord($head[3]) + 1) & 0xFF;
918+ if ($len > 0) {
919+ $chunk = $this->readRaw($id, $len);
920+ if ($chunk === null) return null;
921+ $data .= $chunk;
922+ }
923+ if ($len !== 0xFFFFFF) return $data;
924+ $head = $this->readRaw($id, 4);
925+ if ($head === null) return null;
926+ }
927+ }
928+
929+ private function recv(int $id): ?string {
930+ $head = $this->readRaw($id, 4);
931+ return $head === null ? null : $this->readRest($id, $head);
932+ }
933+}
934+
@@ -0,0 +1,617 @@
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\Mysql;
46+use Std\Test\Helper\MockServer;
47+
48+$test = new Tester([
49+ 'colorOutput' => true,
50+ 'verboseOutput' => true
51+]);
52+
53+enum LibDbColor: string {
54+ case Red = 'あか';
55+}
56+
57+// 例外を受け取る(コード・SQLSTATE・メッセージを確認する為)
58+$catch = function(callable $fn): ?\Throwable {
59+ try {
60+ $fn();
61+ } catch (\Throwable $e) {
62+ return $e;
63+ }
64+ return null;
65+};
66+
67+// TLS・RSA用の自己署名証明書(作れない環境ではnull)
68+$makeCert = function(): ?array {
69+ if (!function_exists('openssl_pkey_new')) return null;
70+ $key = @openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
71+ if (!$key) return null;
72+ $csr = @openssl_csr_new(['commonName' => '127.0.0.1'], $key);
73+ $crt = $csr ? @openssl_csr_sign($csr, null, $key, 1) : false;
74+ if (!$crt || !openssl_x509_export($crt, $certPem) || !openssl_pkey_export($key, $keyPem)) return null;
75+
76+ $base = sys_get_temp_dir().'/lbmysql_'.bin2hex(random_bytes(6));
77+ file_put_contents("{$base}-cert.pem", $certPem);
78+ file_put_contents("{$base}-key.pem", $keyPem);
79+ file_put_contents("{$base}-pub.pem", openssl_pkey_get_details($key)['key']);
80+ return ['cert' => "{$base}-cert.pem", 'key' => "{$base}-key.pem", 'pub' => "{$base}-pub.pem"];
81+};
82+
83+$test->describe('Db(ドライバの選択)', function($test) use ($catch): void {
84+ $test->it('DB_ENABLEDが無効なら設定なしで作れないはず', function($test): void {
85+ if (defined('DB_ENABLED') && DB_ENABLED) {
86+ $test->assertNotNull(new Db());
87+ return;
88+ }
89+ $test->assertThrows(fn() => new Db(), \Exception::class);
90+ $test->assertThrows(fn() => new Mysql(), \Exception::class);
91+ });
92+
93+ $test->it('ドライバを選べるはず(省略するとmysql)', function($test): void {
94+ $db = new Db(['username' => 'lb']);
95+ $test->assertSame('mysql', $db->driverName);
96+ $test->assertTrue($db->driver() instanceof Mysql);
97+ $test->assertTrue((new Db(['driver' => 'MariaDB']))->driver() instanceof Mysql);
98+ $test->assertFalse($db->isConnected());
99+ });
100+
101+ $test->it('未対応のドライバは例外のはず', function($test) use ($catch): void {
102+ $e = $catch(fn() => new Db(['driver' => 'oracle']));
103+ $test->assertTrue($e instanceof \RuntimeException);
104+ $test->assertStringContains('oracle', $e->getMessage());
105+ $test->assertTrue(Db::isSupported('mysql'));
106+ $test->assertTrue(Db::isSupported('MARIADB'));
107+ $test->assertFalse(Db::isSupported('oracle'));
108+ });
109+
110+ $test->it('未対応の文字セットは例外のはず', function($test): void {
111+ $test->assertThrows(fn() => new Mysql(['charset' => 'klingon']), \InvalidArgumentException::class);
112+ });
113+
114+ $test->it('識別子を囲めるはず', function($test): void {
115+ $db = new Db([]);
116+ $test->assertSame('`users`', $db->quoteIdentifier('users'));
117+ $test->assertSame('`a``b`', $db->quoteIdentifier('a`b'));
118+ });
119+
120+ $test->it('接続していなければ例外のはず', function($test) use ($catch): void {
121+ $my = new Mysql([]);
122+ foreach ([fn() => $my->query('SELECT 1'), fn() => $my->prepare('SELECT 1'), fn() => $my->execute(1), fn() => $my->selectDatabase('lb')] as $fn) {
123+ $e = $catch($fn);
124+ $test->assertTrue($e instanceof DbException);
125+ $test->assertStringContains('接続していません', $e->getMessage());
126+ }
127+ $test->assertFalse($my->ping());
128+ $test->assertFalse($my->inTransaction());
129+ $test->assertFalse($my->demolish(1));
130+ });
131+
132+ $test->it('サーバーが無ければ接続に失敗するはず', function($test) use ($catch): void {
133+ $probe = stream_socket_server('tcp://127.0.0.1:0');
134+ $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1);
135+ fclose($probe);
136+
137+ $e = $catch(fn() => (new Mysql(['port' => $port, 'timeout' => 2]))->connect());
138+ $test->assertTrue($e instanceof DbException);
139+ $test->assertStringContains('ソケットに接続に失敗', $e->getMessage());
140+ });
141+});
142+
143+$cert = $makeCert();
144+
145+$test->describe('Mysql(偽物のMySQLサーバー)', function($test) use ($catch, $cert): void {
146+ $srv = MockServer::start('mysql-server.php', ['mysql_native_password', $cert['cert'] ?? '', $cert['key'] ?? '']);
147+ if ($srv === null) {
148+ $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効、又はpdo_sqliteが無い?)');
149+ return;
150+ }
151+
152+ $info = fn(array $o = []): array => $o + [
153+ 'host' => '127.0.0.1',
154+ 'port' => $srv->port,
155+ 'username' => 'lb',
156+ 'password' => 'ひみつ',
157+ 'dbname' => 'lb',
158+ 'timeout' => 5,
159+ ];
160+ $open = function(array $o = []) use ($info): Mysql {
161+ $my = new Mysql($info($o));
162+ $my->connect();
163+ return $my;
164+ };
165+ $one = fn(Mysql $my, string $sql) => array_values($my->query($sql)['rows'][0] ?? [])[0] ?? null;
166+ // プリペアドステートメントで1回だけ実行する
167+ // 証明書(TLS・RSA)が必要なテスト:作れない環境ではスキップ
168+ $itCert = fn(string $desc, callable $fn) => $cert === null
169+ ? $test->skip($desc, '証明書を作れない(opensslが無い、又はopenssl.cnfが見えない?)')
170+ : $test->it($desc, $fn);
171+ $run = function(Mysql $my, string $sql, array $params = []): array {
172+ $id = $my->prepare($sql);
173+ try {
174+ return $my->execute($id, $params);
175+ } finally {
176+ $my->demolish($id);
177+ }
178+ };
179+
180+ $test->it('接続して切断出来るはず', function($test) use ($open, $one): void {
181+ $my = $open();
182+ $test->assertTrue($my->isConnected());
183+ $test->assertTrue($my->connect()); // 2回目は何もしない
184+ $info = $my->getServerInfo();
185+ $test->assertSame(10, $info['protocol']);
186+ $test->assertSame('8.4.0-lbmock', $info['version']);
187+ $test->assertSame('mysql_native_password', $info['authPlugin']);
188+ $test->assertFalse(isset($info['scramble']));
189+ $test->assertSame('lb', $one($my, 'SELECT DATABASE()'));
190+ $test->assertSame($info['threadId'], $one($my, 'SELECT CONNECTION_ID()'));
191+ $test->assertSame('utf8mb4', $one($my, 'SELECT @@character_set_client'));
192+ $test->assertTrue($my->ping());
193+
194+ $my->close();
195+ $test->assertFalse($my->isConnected());
196+ $test->assertFalse($my->ping());
197+ $my->close(); // 2回目は何もしない
198+ });
199+
200+ $test->it('正しい機能フラグを送るはず(TLSを求めない、PROTOCOL_41等)', function($test) use ($open, $one): void {
201+ $caps = $one($open(), 'SELECT @@lb_caps');
202+ $test->assertSame(0, $caps & 0x800, 'TLSなしでCLIENT_SSLを送った');
203+ foreach ([0x200, 0x8000, 0x2000, 0x20000, 0x80000, 0x200000, 0x8] as $flag) {
204+ $test->assertSame($flag, $caps & $flag, sprintf('0x%X が無い', $flag));
205+ }
206+ $test->assertSame(0, $one($open(['dbname' => '']), 'SELECT @@lb_caps') & 0x8, 'データベースなしでCONNECT_WITH_DBを送った');
207+ });
208+
209+ $test->it('間違ったパスワード・ユーザー・データベースは例外のはず', function($test) use ($info, $catch): void {
210+ foreach ([['password' => 'ちがう'], ['username' => 'nobody'], ['password' => '']] as $o) {
211+ $my = new Mysql($info($o));
212+ $e = $catch(fn() => $my->connect());
213+ $test->assertTrue($e instanceof DbException);
214+ $test->assertSame(1045, $e->getCode());
215+ $test->assertSame('28000', $e->sqlState);
216+ $test->assertStringContains('認証に失敗: 1045 - Access denied', $e->getMessage());
217+ $test->assertFalse($my->isConnected());
218+ }
219+
220+ $e = $catch(fn() => (new Mysql($info(['dbname' => 'nodb'])))->connect());
221+ $test->assertSame(1049, $e->getCode());
222+ });
223+
224+ $test->it('パスワードなしのユーザーで接続出来るはず', function($test) use ($open, $one): void {
225+ $test->assertSame(1, $one($open(['username' => 'nopw', 'password' => '']), 'SELECT 1'));
226+ });
227+
228+ $test->it('文字セットを指定出来るはず', function($test) use ($open, $one): void {
229+ $test->assertSame('sjis', $one($open(['charset' => 'sjis']), 'SELECT @@character_set_client'));
230+ $test->assertSame('cp932', $one($open(['charset' => 'CP932']), 'SELECT @@character_set_client'));
231+ });
232+
233+ $test->it('データベースを選択出来るはず', function($test) use ($open, $one, $catch): void {
234+ $my = $open(['dbname' => '']);
235+ $test->assertNull($one($my, 'SELECT DATABASE()'));
236+ $test->assertTrue($my->selectDatabase('other'));
237+ $test->assertSame('other', $my->dbname);
238+ $test->assertSame('other', $one($my, 'SELECT DATABASE()'));
239+
240+ $e = $catch(fn() => $my->selectDatabase('nodb'));
241+ $test->assertSame(1049, $e->getCode());
242+ $test->assertStringContains("データベースの選択に失敗: 1049 - Unknown database 'nodb'", $e->getMessage());
243+ $test->assertTrue($my->isConnected());
244+ });
245+
246+ $itCert('caching_sha2_password(Auth Switch、RSA、高速認証)で接続出来るはず', function($test) use ($open, $one, $cert, $info, $catch): void {
247+ // 1回目:キャッシュに無いので、公開鍵を求めて暗号化する
248+ $my = $open(['username' => 'sha2', 'password' => 'sha2ひみつ']);
249+ $test->assertSame('caching_sha2_password', $my->getServerInfo()['authPlugin']);
250+ $test->assertSame(1, $one($my, 'SELECT @@lb_full_auth'));
251+ // 2回目:高速認証
252+ $test->assertSame(0, $one($open(['username' => 'sha2', 'password' => 'sha2ひみつ']), 'SELECT @@lb_full_auth'));
253+ // 間違ったパスワード
254+ $e = $catch(fn() => $open(['username' => 'sha2', 'password' => 'x']));
255+ $test->assertSame(1045, $e->getCode());
256+ });
257+
258+ $itCert('sha256_password(設定した公開鍵、又はサーバーから)で接続出来るはず', function($test) use ($open, $one, $cert): void {
259+ $test->assertSame('sha256_password', $one($open(['username' => 'sha256', 'password' => 'sha256ひみつ']), 'SELECT @@lb_plugin'));
260+ $test->assertSame(1, $one($open(['username' => 'sha256', 'password' => 'sha256ひみつ', 'pubkey' => $cert['pub']]), 'SELECT @@lb_full_auth'));
261+ });
262+
263+ $itCert('TLSで接続出来るはず(証明書を確認する)', function($test) use ($open, $one, $cert, $catch): void {
264+ $tls = ['cafile' => $cert['cert']];
265+ $my = $open(['tls' => $tls]);
266+ $test->assertSame(1, $one($my, 'SELECT @@lb_tls'));
267+ $test->assertSame('諏訪子', $my->query("SELECT '諏訪子' AS n")['rows'][0]['n']);
268+ $test->assertSame(0, $one($open(), 'SELECT @@lb_tls'));
269+
270+ // TLSならパスワードをそのまま送れる
271+ $test->assertSame(1, $one($open(['tls' => $tls, 'username' => 'clear', 'password' => 'clearひみつ']), 'SELECT @@lb_tls'));
272+ $test->assertSame(1, $one($open(['tls' => $tls, 'username' => 'sha256', 'password' => 'sha256ひみつ']), 'SELECT 1'));
273+
274+ // 信頼していない証明書
275+ $e = $catch(fn() => $open(['tls' => true]));
276+ $test->assertTrue($e instanceof DbException);
277+ $test->assertStringContains('TLSの開始に失敗', $e->getMessage());
278+ });
279+
280+ $test->it('TLSなしでmysql_clear_passwordを使わないはず', function($test) use ($open, $catch): void {
281+ $e = $catch(fn() => $open(['username' => 'clear', 'password' => 'clearひみつ']));
282+ $test->assertTrue($e instanceof DbException);
283+ $test->assertStringContains('TLS', $e->getMessage());
284+ });
285+
286+ $test->it('テーブルを作って、データを入れて読めるはず(テキストプロトコル)', function($test) use ($open): void {
287+ $my = $open();
288+ $my->query('DROP TABLE IF EXISTS items');
289+ $res = $my->query('CREATE TABLE items (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(10,2), ratio DOUBLE, f FLOAT, tiny TINYINT, small SMALLINT, big BIGINT, d DATE, dt DATETIME(6), tm TIME, y YEAR, b BLOB, txt TEXT, j JSON, note VARCHAR(10)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
290+ $test->assertSame(0, $res['affectedRows']);
291+ $test->assertSame([], $res['rows']);
292+
293+ $res = $my->query("INSERT INTO items (name, price, ratio, f, tiny, small, big, d, dt, tm, y, b, txt, j) VALUES ('諏訪子', 12.5, 0.1, 1.5, -5, -300, 9007199254740993, '2026-11-12', '2026-11-12 10:20:30.123456', '-838:59:59', 2026, x'00ff10', 'テキスト', '{\"a\":1}')");
294+ $test->assertSame(1, $res['affectedRows']);
295+ $test->assertSame(1, $res['insertId']);
296+ $test->assertSame(2, $my->query("INSERT INTO items (name) VALUES ('神奈子')")['insertId']);
297+
298+ $res = $my->query('SELECT * FROM items ORDER BY id');
299+ $test->assertCount(2, $res['rows']);
300+ $test->assertCount(16, $res['fields']);
301+ $test->assertSame('items', $res['fields'][0]['table']);
302+ $test->assertSame('id', $res['fields'][0]['name']);
303+ $test->assertSame(Mysql::TYPE_LONG, $res['fields'][0]['type']);
304+ $test->assertSame(Mysql::TYPE_NEWDECIMAL, $res['fields'][2]['type']);
305+ $test->assertSame(2, $res['fields'][2]['decimals']);
306+
307+ $test->assertSame([
308+ 'id' => 1, 'name' => '諏訪子', 'price' => '12.50', 'ratio' => 0.1, 'f' => 1.5, 'tiny' => -5, 'small' => -300,
309+ 'big' => 9007199254740993, 'd' => '2026-11-12', 'dt' => '2026-11-12 10:20:30.123456', 'tm' => '-838:59:59',
310+ 'y' => 2026, 'b' => "\x00\xff\x10", 'txt' => 'テキスト', 'j' => '{"a":1}', 'note' => null,
311+ ], $res['rows'][0]);
312+ $test->assertNull($res['rows'][1]['price']);
313+
314+ $res = $my->query("UPDATE items SET note = 'x'");
315+ $test->assertSame(2, $res['affectedRows']);
316+ $test->assertSame(0, $res['insertId']);
317+ });
318+
319+ $test->it('プリペアドステートメントの結果はテキストと同じはず(バイナリプロトコル)', function($test) use ($open, $run): void {
320+ $my = $open();
321+ $text = $my->query('SELECT * FROM items ORDER BY id')['rows'];
322+ $bin = $run($my, 'SELECT * FROM items WHERE id > ? ORDER BY id', [0]);
323+ $test->assertSame($text, $bin['rows']);
324+ $test->assertSame('items', $bin['fields'][0]['table']);
325+
326+ $test->assertSame('神奈子', $run($my, 'SELECT name FROM items WHERE id = ?', [2])['rows'][0]['name']);
327+ $test->assertSame([], $run($my, 'SELECT name FROM items WHERE id = ?', [99])['rows']);
328+ });
329+
330+ $test->it('日付と時間の色々な形を読めるはず', function($test) use ($open, $run): void {
331+ $my = $open();
332+ $my->query('DROP TABLE IF EXISTS dates');
333+ $my->query('CREATE TABLE dates (id INT AUTO_INCREMENT PRIMARY KEY, d DATE, dt DATETIME, dt3 DATETIME(3), tm TIME, tm6 TIME(6))');
334+ $my->query("INSERT INTO dates (d, dt, dt3, tm, tm6) VALUES ('0000-00-00', '2026-01-02 00:00:00', '2026-01-02 03:04:05.5', '00:00:00', '123:45:06.000007')");
335+ $my->query("INSERT INTO dates (d, dt, dt3, tm, tm6) VALUES ('1999-12-31', '0000-00-00 00:00:00', '2026-01-02 03:04:05', '-01:02:03', '-00:00:00.5')");
336+
337+ $text = $my->query('SELECT d, dt, dt3, tm, tm6 FROM dates ORDER BY id')['rows'];
338+ $test->assertSame([
339+ ['d' => '0000-00-00', 'dt' => '2026-01-02 00:00:00', 'dt3' => '2026-01-02 03:04:05.500', 'tm' => '00:00:00', 'tm6' => '123:45:06.000007'],
340+ ['d' => '1999-12-31', 'dt' => '0000-00-00 00:00:00', 'dt3' => '2026-01-02 03:04:05.000', 'tm' => '-01:02:03', 'tm6' => '-00:00:00.500000'],
341+ ], $text);
342+ $test->assertSame($text, $run($my, 'SELECT d, dt, dt3, tm, tm6 FROM dates WHERE id > ? ORDER BY id', [0])['rows']);
343+ });
344+
345+ $test->it('PHPの色々な型をパラメータとして送れるはず', function($test) use ($open, $run, $catch): void {
346+ $my = $open();
347+ $cases = [
348+ [42, 42],
349+ [-42, -42],
350+ [PHP_INT_MAX, PHP_INT_MAX],
351+ [PHP_INT_MIN, PHP_INT_MIN],
352+ [1.25, 1.25],
353+ [-0.1, -0.1],
354+ [true, 1],
355+ [false, 0],
356+ [null, null],
357+ ['', ''],
358+ ['諏訪子', '諏訪子'],
359+ ["\x00\x01バイナリ\xff", "\x00\x01バイナリ\xff"],
360+ [new \DateTimeImmutable('2026-11-12 10:20:30.5'), '2026-11-12 10:20:30.500000'],
361+ [LibDbColor::Red, 'あか'],
362+ ];
363+ foreach ($cases as [$in, $out]) {
364+ $test->assertSame($out, $run($my, 'SELECT ? AS v', [$in])['rows'][0]['v'], 'パラメータ:'.var_export($in, true));
365+ }
366+
367+ $res = $run($my, 'SELECT ? AS a, ? AS b, ? AS c, ? AS d, ? AS e, ? AS f, ? AS g, ? AS h, ? AS i', [1, null, 'x', null, null, null, null, null, 9]);
368+ $test->assertSame(['a' => 1, 'b' => null, 'c' => 'x', 'd' => null, 'e' => null, 'f' => null, 'g' => null, 'h' => null, 'i' => 9], $res['rows'][0]);
369+
370+ // 連想配列でも順番通り
371+ $test->assertSame(['a' => 1, 'b' => 2], $run($my, 'SELECT ? AS a, ? AS b', ['x' => 1, 'y' => 2])['rows'][0]);
372+
373+ $e = $catch(fn() => $run($my, 'SELECT ? AS v', [[1, 2]]));
374+ $test->assertTrue($e instanceof DbException);
375+ $test->assertStringContains('未対応', $e->getMessage());
376+ });
377+
378+ $test->it('プリペアドステートメントを何回も実行出来るはず', function($test) use ($open, $one, $catch): void {
379+ $my = $open();
380+ $my->query('DROP TABLE IF EXISTS logs');
381+ $my->query('CREATE TABLE logs (id INT AUTO_INCREMENT PRIMARY KEY, msg TEXT, n INT)');
382+
383+ $id = $my->prepare('INSERT INTO logs (msg, n) VALUES (?, ?)');
384+ $ids = [];
385+ foreach (['一', '二', '三'] as $i => $msg) {
386+ $ids[] = $my->execute($id, [$msg, $i])['insertId'];
387+ }
388+ $test->assertSame([1, 2, 3], $ids);
389+ $test->assertSame(3, $one($my, 'SELECT COUNT(*) FROM logs'));
390+
391+ $e = $catch(fn() => $my->execute($id, ['足りない']));
392+ $test->assertStringContains('パラメータ数が一致しません: 期待 2, 取得 1', $e->getMessage());
393+
394+ $test->assertTrue($my->demolish($id));
395+ $test->assertFalse($my->demolish($id));
396+ $e = $catch(fn() => $my->execute($id, ['x', 1]));
397+ $test->assertStringContains("不正なステートメントID: {$id}", $e->getMessage());
398+ });
399+
400+ $test->it('SQLのエラーは例外で、その後も使えるはず', function($test) use ($open, $one, $catch): void {
401+ $my = $open();
402+ $e = $catch(fn() => $my->query('SELECT * FROM nope'));
403+ $test->assertTrue($e instanceof DbException);
404+ $test->assertSame(1146, $e->getCode());
405+ $test->assertSame('42S02', $e->sqlState);
406+ $test->assertSame("クエリに失敗: 1146 - Table 'lb.nope' doesn't exist", $e->getMessage());
407+
408+ $e = $catch(fn() => $my->query('SELEC 1'));
409+ $test->assertSame(1064, $e->getCode());
410+
411+ $e = $catch(fn() => $my->prepare('SELECT * FROM nope WHERE id = ?'));
412+ $test->assertStringContains('準備に失敗: 1146', $e->getMessage());
413+
414+ $my->query('DROP TABLE IF EXISTS uniq');
415+ $my->query('CREATE TABLE uniq (name VARCHAR(10) UNIQUE)');
416+ $my->query("INSERT INTO uniq VALUES ('a')");
417+ $id = $my->prepare('INSERT INTO uniq VALUES (?)');
418+ $e = $catch(fn() => $my->execute($id, ['a']));
419+ $test->assertSame(1062, $e->getCode());
420+ $test->assertSame('23000', $e->sqlState);
421+ $test->assertStringContains('実行に失敗: 1062', $e->getMessage());
422+
423+ $test->assertTrue($my->isConnected());
424+ $test->assertSame(1, $one($my, 'SELECT COUNT(*) FROM uniq'));
425+ });
426+
427+ $test->it('長い値(長さエンコードの全ての大きさ)を送受信出来るはず', function($test) use ($open, $run): void {
428+ $my = $open();
429+ $my->query('DROP TABLE IF EXISTS big');
430+ $my->query('CREATE TABLE big (id INT AUTO_INCREMENT PRIMARY KEY, v LONGBLOB)');
431+ foreach ([250, 251, 300, 65535, 65536, 70000] as $len) {
432+ $v = str_repeat('あ', intdiv($len, 3)).str_repeat('x', $len % 3);
433+ $id = $run($my, 'INSERT INTO big (v) VALUES (?)', [$v])['insertId'];
434+ $test->assertSame($v, $my->query("SELECT v FROM big WHERE id = {$id}")['rows'][0]['v'], "{$len}バイト(テキスト)");
435+ $test->assertSame($v, $run($my, 'SELECT v FROM big WHERE id = ?', [$id])['rows'][0]['v'], "{$len}バイト(バイナリ)");
436+ }
437+ });
438+
439+ $test->it('16MiB以上のパケットを送受信出来るはず', function($test) use ($open, $run): void {
440+ $my = $open(['timeout' => 30]);
441+ $v = str_repeat("0123456789abcdef", 0x110000); // 17MiB
442+ $id = $run($my, 'INSERT INTO big (v) VALUES (?)', [$v])['insertId'];
443+ $test->assertSame(strlen($v), strlen($my->query("SELECT v FROM big WHERE id = {$id}")['rows'][0]['v']));
444+ $test->assertTrue($v === $run($my, 'SELECT v FROM big WHERE id = ?', [$id])['rows'][0]['v']);
445+ // 次のコマンドの順序番号が正しいか
446+ $test->assertTrue($my->ping());
447+ });
448+
449+ $test->it('結果が複数ある場合はmoreに入るはず(CALL)', function($test) use ($open, $one): void {
450+ $my = $open();
451+ $res = $my->query('CALL lb_multi()');
452+ $test->assertSame([['a' => 1]], $res['rows']);
453+ $test->assertCount(2, $res['more']);
454+ $test->assertSame([['b' => '二']], $res['more'][0]['rows']);
455+ $test->assertSame([], $res['more'][1]['rows']);
456+ // 全部読んだので、次のクエリが使える
457+ $test->assertSame(2, $one($my, 'SELECT 2'));
458+ $test->assertFalse(isset($my->query('SELECT 1')['more']));
459+ });
460+
461+ $test->it('トランザクションを使えるはず', function($test) use ($open, $one): void {
462+ $my = $open();
463+ $my->query('DROP TABLE IF EXISTS tx');
464+ $my->query('CREATE TABLE tx (n INT)');
465+ $test->assertFalse($my->inTransaction());
466+
467+ $test->assertTrue($my->beginTransaction());
468+ $test->assertTrue($my->inTransaction());
469+ $my->query('INSERT INTO tx VALUES (1)');
470+ $test->assertTrue($my->rollback());
471+ $test->assertFalse($my->inTransaction());
472+ $test->assertSame(0, $one($my, 'SELECT COUNT(*) FROM tx'));
473+
474+ $my->beginTransaction();
475+ $my->query('INSERT INTO tx VALUES (2)');
476+ $test->assertTrue($my->commit());
477+ $test->assertFalse($my->inTransaction());
478+ $test->assertSame(2, $one($my, 'SELECT n FROM tx'));
479+ });
480+
481+ $test->it('接続が切れたら例外で、また接続出来るはず', function($test) use ($open, $one, $catch): void {
482+ $my = $open();
483+ $e = $catch(fn() => $my->query('SELECT LB_DISCONNECT()'));
484+ $test->assertTrue($e instanceof DbException);
485+ $test->assertStringContains('サーバーとの接続が切れました', $e->getMessage());
486+ $test->assertFalse($my->isConnected());
487+ $test->assertFalse($my->ping());
488+
489+ $test->assertTrue($my->connect());
490+ $test->assertSame(1, $one($my, 'SELECT 1'));
491+ });
492+
493+ $test->it('返事が遅すぎたらタイムアウトするはず', function($test) use ($open, $catch): void {
494+ $my = $open(['timeout' => 1]);
495+ $e = $catch(fn() => $my->query('SELECT LB_SLEEP(2)'));
496+ $test->assertTrue($e instanceof DbException);
497+ $test->assertStringContains('タイムアウト', $e->getMessage());
498+ $test->assertFalse($my->isConnected());
499+ });
500+
501+ $test->it('デバッグモードでパケットを記録するはず', function($test) use ($open): void {
502+ $my = $open();
503+ ob_start();
504+ $my->setDebug(true)->query('SELECT 1 AS a');
505+ $out = ob_get_clean();
506+ $my->setDebug(false);
507+
508+ $log = $my->getPacketLog();
509+ $test->assertSame('SEND', $log[0]['direction']);
510+ $test->assertSame("\x03SELECT 1 AS a", $log[0]['data']);
511+ $test->assertSame(0, $log[0]['seqNum']);
512+ $test->assertSame('RECV', $log[1]['direction']);
513+ $test->assertSame(1, $log[1]['seqNum']);
514+ $test->assertStringContains('=== SEND パケット', $out);
515+ $test->assertStringContains('解釈: フィールド説明パケット', $out);
516+
517+ $file = 'lbtest_mysql_'.bin2hex(random_bytes(4)).'.txt';
518+ $bytes = $my->savePacketLogToFile($file);
519+ $test->assertTrue(is_int($bytes) && $bytes > 0);
520+ $saved = (string)file_get_contents(ROOT.'/log/'.$file);
521+ @unlink(ROOT.'/log/'.$file);
522+ $test->assertStringContains('=== パケット #0', $saved);
523+ $test->assertStringContains('ASCII: .SELECT 1 AS a', $saved);
524+ });
525+
526+ $test->it('Db経由で使えるはず(自動で接続、パラメータ、トランザクション)', function($test) use ($info, $catch): void {
527+ $db = new Db($info());
528+ $test->assertFalse($db->isConnected());
529+ $db->query('DROP TABLE IF EXISTS users');
530+ $test->assertTrue($db->isConnected());
531+ $db->query('CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(20), points INT)');
532+
533+ $id = $db->query('INSERT INTO users (name, points) VALUES (?, ?)', ['諏訪子', 100])['insertId'];
534+ $test->assertSame(1, $id);
535+ $db->query('INSERT INTO users (name, points) VALUES (?, ?)', ['神奈子', 0]);
536+ $test->assertSame([['name' => '諏訪子']], $db->query('SELECT name FROM users WHERE points > ?', [50])['rows']);
537+
538+ // コミット(戻り値はそのまま)
539+ $res = $db->transaction(function(Db $db): string {
540+ $db->query('UPDATE users SET points = points - ? WHERE id = ?', [30, 1]);
541+ $db->query('UPDATE users SET points = points + ? WHERE id = ?', [30, 2]);
542+ return '完了';
543+ });
544+ $test->assertSame('完了', $res);
545+ $test->assertFalse($db->inTransaction());
546+ $test->assertSame([70, 30], array_column($db->query('SELECT points FROM users ORDER BY id')['rows'], 'points'));
547+
548+ // 例外ならロールバックして投げ直す
549+ $e = $catch(fn() => $db->transaction(function(Db $db): void {
550+ $db->query('UPDATE users SET points = 0');
551+ throw new \LogicException('やめた');
552+ }));
553+ $test->assertTrue($e instanceof \LogicException);
554+ $test->assertFalse($db->inTransaction());
555+ $test->assertSame([70, 30], array_column($db->query('SELECT points FROM users ORDER BY id')['rows'], 'points'));
556+
557+ // 準備したステートメントを繰り返す
558+ $st = $db->prepare('SELECT name FROM users WHERE id = ?');
559+ $test->assertSame('諏訪子', $db->execute($st, [1])['rows'][0]['name']);
560+ $test->assertSame('神奈子', $db->execute($st, [2])['rows'][0]['name']);
561+ $test->assertTrue($db->demolish($st));
562+
563+ // query()のパラメータ付きはステートメントを解放する(失敗しても)
564+ $e = $catch(fn() => $db->query('SELECT * FROM nope WHERE id = ?', [1]));
565+ $test->assertSame(1146, $e->getCode());
566+ $test->assertTrue($db->ping());
567+
568+ $db->beginTransaction();
569+ $test->assertTrue($db->inTransaction());
570+ $db->rollback();
571+ $db->close();
572+ $test->assertFalse($db->isConnected());
573+ });
574+
575+ $srv->stop();
576+});
577+
578+$test->describe('Mysql(caching_sha2_passwordが既定、TLSなしのサーバー)', function($test) use ($catch): void {
579+ $srv = MockServer::start('mysql-server.php', ['caching_sha2_password']);
580+ if ($srv === null) {
581+ $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効、又はpdo_sqliteが無い?)');
582+ return;
583+ }
584+ $info = fn(array $o = []): array => $o + ['host' => '127.0.0.1', 'port' => $srv->port, 'username' => 'lb', 'password' => 'ひみつ', 'timeout' => 5];
585+
586+ $test->it('mysql_native_passwordのユーザーに切り替えられるはず', function($test) use ($info): void {
587+ $my = new Mysql($info());
588+ $my->connect();
589+ $test->assertSame('caching_sha2_password', $my->getServerInfo()['plugin']);
590+ $test->assertSame('mysql_native_password', $my->getServerInfo()['authPlugin']);
591+ });
592+
593+ $test->it('caching_sha2_passwordの空のパスワードは追加の認証が無いはず', function($test) use ($info): void {
594+ $my = new Mysql($info(['username' => 'nopw', 'password' => '']));
595+ $test->assertTrue($my->connect());
596+ });
597+
598+ $test->it('TLSに対応していないサーバーでTLSを求めたら例外のはず', function($test) use ($info, $catch): void {
599+ $e = $catch(fn() => (new Mysql($info(['tls' => true])))->connect());
600+ $test->assertTrue($e instanceof DbException);
601+ $test->assertStringContains('サーバーがTLSに対応していません', $e->getMessage());
602+ });
603+
604+ $test->it('サーバーが公開鍵を出せなければ認証に失敗するはず', function($test) use ($info, $catch): void {
605+ $e = $catch(fn() => (new Mysql($info(['username' => 'sha2', 'password' => 'sha2ひみつ'])))->connect());
606+ $test->assertTrue($e instanceof DbException);
607+ $test->assertFalse(str_contains($e->getMessage(), 'sha2ひみつ'), 'パスワードがメッセージに入った');
608+ });
609+
610+ $srv->stop();
611+});
612+
613+if ($cert !== null) {
614+ foreach ($cert as $f) @unlink($f);
615+}
616+
617+$test->printSummary();
変更 util.php
@@ -45,6 +45,7 @@
4545 case Mailer;
4646 case Image;
4747 case Cron;
48+ case Db;
4849 }
4950
5051 class Result {
@@ -293,6 +294,10 @@
293294 break;
294295 case LogType::Cron:
295296 $logfile .= 'cron_log.txt';
297+ $success = true;
298+ break;
299+ case LogType::Db:
300+ $logfile .= 'db_log.txt';
296301 $success = true;
297302 break;
298303 default: