MySQLドライバー
守矢諏訪子 <suwako@076.moe>、2026-11-12 12:02
コミット 33021bffe2979002f1c5686ff81eb3fec501f2b6
・親 fc1e074
・ファイルを見る
11個のファイル、+3933 -7
変更
README-JP.md
| @@ -207,6 +207,23 @@ | ||
| 207 | 207 | |
| 208 | 208 | テンプレートやコードでは、URLは `ass_url('article/a.png')`、ディスク上のファイルは `ROOT.ass_path('article/a.png')` を使います。 |
| 209 | 209 | |
| 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 | + | |
| 210 | 227 | ### コメント |
| 211 | 228 | `config/config.php` で `COMMENTS_ENABLED` を `true` にすると、全部のブログ記事の下にコメント欄が表示されます(書き込みには `AUTH_ENABLED` も必要)。 |
| 212 | 229 | * ログイン中のユーザーはコメント、返信、いいねが出来ます(Markdown:太字、斜体、取り消し線、コード、引用、リスト、リンク)。自分のコメントは削除出来、管理者とスタッフは全部のコメントを削除出来ます。JavaScript は不要です。 |
変更
README.md
| @@ -205,6 +205,23 @@ | ||
| 205 | 205 | |
| 206 | 206 | 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. |
| 207 | 207 | |
| 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 | + | |
| 208 | 225 | ### Comments |
| 209 | 226 | Set `COMMENTS_ENABLED` to `true` in `config/config.php` to show a comment section under every blog post (posting needs `AUTH_ENABLED`). |
| 210 | 227 | * 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 @@ | ||
| 73 | 73 | 'pubkey' => ROOT.'/public/static/pub.pem', |
| 74 | 74 | 'privkey' => ROOT.'/data/priv.pem', |
| 75 | 75 | ]); |
| 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 | +]); | |
| 76 | 92 | define('OPENPROVIDER', [ |
| 77 | 93 | 'username' => '', |
| 78 | 94 | 'password' => '', |
| @@ -88,6 +104,7 @@ | ||
| 88 | 104 | define('CURL_ENABLED', true); |
| 89 | 105 | define('AUTH_ENABLED', false); |
| 90 | 106 | define('OPENPROVIDER_ENABLED', false); |
| 107 | +define('DB_ENABLED', false); // データベース(DBINFOを参照) | |
| 91 | 108 | define('AUTH_REGISTER_ENABLED', false); |
| 92 | 109 | define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要) |
| 93 | 110 | 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 @@ | ||
| 466 | 466 | ?string $message = null): Tester { |
| 467 | 467 | try { |
| 468 | 468 | call_user_func($callback); |
| 469 | - | |
| 470 | - if ($message === null) { | |
| 471 | - $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした"; | |
| 472 | - } | |
| 473 | - | |
| 474 | - throw new AssertionFailedException($message); | |
| 475 | 469 | } catch (\Throwable $e) { |
| 476 | 470 | if (!($e instanceof $exceptionClass)) { |
| 477 | 471 | if ($message === null) { |
| @@ -481,9 +475,16 @@ | ||
| 481 | 475 | |
| 482 | 476 | throw new AssertionFailedException($message); |
| 483 | 477 | } |
| 478 | + | |
| 479 | + return $this; | |
| 484 | 480 | } |
| 485 | 481 | |
| 486 | - return $this; | |
| 482 | + // 以前はtryの中で投げていたので、\Exceptionを期待するとこの例外自体が合格になった | |
| 483 | + if ($message === null) { | |
| 484 | + $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした"; | |
| 485 | + } | |
| 486 | + | |
| 487 | + throw new AssertionFailedException($message); | |
| 487 | 488 | } |
| 488 | 489 | |
| 489 | 490 | /** |
| @@ -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 @@ | ||
| 45 | 45 | case Mailer; |
| 46 | 46 | case Image; |
| 47 | 47 | case Cron; |
| 48 | + case Db; | |
| 48 | 49 | } |
| 49 | 50 | |
| 50 | 51 | class Result { |
| @@ -293,6 +294,10 @@ | ||
| 293 | 294 | break; |
| 294 | 295 | case LogType::Cron: |
| 295 | 296 | $logfile .= 'cron_log.txt'; |
| 297 | + $success = true; | |
| 298 | + break; | |
| 299 | + case LogType::Db: | |
| 300 | + $logfile .= 'db_log.txt'; | |
| 296 | 301 | $success = true; |
| 297 | 302 | break; |
| 298 | 303 | default: |