DB交換ツール

守矢諏訪子 <suwako@076.moe>、2026-11-13 07:31
コミット 6c02f90d41148e264ab32ed3fb019204fb4c0b05 ・親 e1e5ab1 ・ファイルを見る

18個のファイル、+3446 -9

変更 README-JP.md
@@ -229,6 +229,27 @@
229229 * PostgreSQL:`?` のプレースホルダは `$1`、`$2`… にします(文字列・識別子・コメントの外のみ。`?` そのもの(jsonb の演算子等)は `??` と書く)。`$1` もそのまま使えます。挿入した ID は無いので、`RETURNING` を使って下さい。トランザクションの中でエラーが起きた後の `COMMIT` は、PostgreSQL がロールバックするので `DbException`(SQLSTATE 25P02)を投げます。`COPY`、`LISTEN` の通知、実行中のクエリの取り消しは未対応です。PostgreSQL 16 で確認しました。
230230 * テストは偽物のサーバーを使います。PostgreSQL のテストを本物のサーバーでも実行するには、`LB_TEST_PGSQL=pgsql://ユーザー:パスワード@ホスト:ポート/データベース` を設定して下さい(TLS は `?cafile=/path/ca.pem` を追加)。名前が `lb_test_` で始まるテーブルを作って消します。
231231
232+### データベースの変換
233+`Std\Lib\Db\Convert` は、MySQL・MariaDB、PostgreSQL、SQLite、SQL のダンプのファイル、`data/` の Little Beast の JSON のデータの間で、テーブルと行をどの方向にもコピーします。ドライバーと同じく PHP だけで書いてあります。
234+```php
235+use Std\Lib\Db;
236+use Std\Lib\Db\Convert;
237+use Std\Lib\Db\Convert\{DbSource, DbTarget, DumpSource, DumpTarget, JsonSource, JsonTarget};
238+
239+// data/(JSON)→ SQLite
240+$report = Convert::run(new JsonSource(ROOT.'/data'), new DbTarget(new Db(['driver' => 'sqlite', 'path' => 'data/site.sqlite'])));
241+// PostgreSQL → MySQL のダンプのファイル
242+Convert::run(new DbSource(new Db(['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => 5432, 'username' => 'lb', 'password' => '…', 'dbname' => 'lb'])),
243+ new DumpTarget('/tmp/lb.sql', 'mysql'));
244+// mysqldump・pg_dump のファイル → PostgreSQL(既に有るテーブルは置き換える)
245+Convert::run(new DumpSource('/tmp/dump.sql'), new DbTarget(new Db()), ['drop' => true]);
246+```
247+* 読む元:`DbSource`(接続した `Db`)、`DumpSource`(`mysqldump`、`pg_dump`(プレーンの形式、`COPY` 又は `--inserts`)、`sqlite3 .dump`、`DumpTarget` で作った `.sql` のファイル(`.sql.gz` は PHP の zlib が必要)、方言は自動で判定)、`JsonSource`(データのディレクトリ)。書く先:`DbTarget`、`DumpTarget`(方言は `mysql`・`pgsql`・`sqlite`、`mysql`・`psql`・`sqlite3` のコマンドで読み込めます)、`JsonTarget`。
248+* オプション:`tables`(このテーブルだけ)、`drop`(書く先の同じ名前のテーブルを消す、無ければ既に有るテーブルはエラー)、`progress`(`fn(string $table, int $rows)`)。結果は `tables`(テーブル毎のコピーした行数)と `warnings`(変換しなかった物)。
249+* 列、主キー、ユニーク・普通のインデックス、`NOT NULL`、既定値、自動の番号(`AUTO_INCREMENT`、`SERIAL`・IDENTITY、SQLite の `INTEGER PRIMARY KEY`)を変換し、番号はコピーした行の続きから始まります。型は書く先の一番近い型にします(例:`TINYINT(1)` と `boolean`、`JSON` と `jsonb`、`BLOB` と `bytea`)。`ENUM`・`SET` は文字列、PostgreSQL の配列は JSON にします。時差の有る日時は UTC で保存します。外部キー、ビュー、トリガー、関数、全文・式のインデックスは変換しません(`warnings` に出ます)。
250+* JSON のデータ:ユーザー、コメント、通知、Cron のジョブ、フォロワー、ActivityPub の投稿は名前が `lb_` で始まるテーブルになり、他のファイル(`cache/`、`ratelimit/`、ロック・一時ファイル、SQLite のデータベースを除く)は `lb_files` に入るので、戻すと同じファイルになります。他のテーブルは `data/db/{テーブル}.json` になります。`data/` が空・無い場合は、テーブルが無いだけです。`JsonTarget` は、2番目の引数(上書き)が `true` か `drop` が無ければ、空でないディレクトリには書きません。
251+* サイトが使っているデータベースを読む・書く場合は、先に書き込みを止めて下さい(又は新しいデータベースに変換)。変換中に元をロックしません。
252+
232253 ### TLS 1.3・1.2(php_openssl は不要)
233254 HTTPS のリクエスト(`Curl`、ActivityPub)、SMTP の STARTTLS(`Mailer`)、MySQL・PostgreSQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント `Std\Lib\Tls`(`php_gmp` が必要)を使うので、`php_openssl` は不要です。RSA の鍵と署名(ActivityPub)は `Std\Lib\Crypto\Rsa` を使い、今までの PEM の鍵もそのまま使えます。
234255 * TLS 1.3 を優先し、TLS 1.2 にしか対応していないサーバーとは安全な暗号スイートだけで TLS 1.2 を使います(ECDHE と AES-GCM 又は ChaCha20-Poly1305、サーバーが対応していれば Extended Master Secret、再ネゴシエーションはしない)。CBC、RSA の鍵交換、DHE、TLS 1.1 以前は未対応です。TLS 1.3 に対応しているサーバーとの通信を攻撃者が TLS 1.2 に下げた場合は分かります。TLS 1.2 を拒否するには、オプションの `'min_version' => '1.3'` を使って下さい。
変更 README.md
@@ -227,6 +227,27 @@
227227 * PostgreSQL: `?` placeholders become `$1`, `$2`… (outside strings, identifiers and comments; write `??` for a literal `?`, such as the jsonb operator), and `$1` can be used directly too. There is no insert ID, so use `RETURNING`. A `COMMIT` after an error in the transaction throws `DbException` (SQLSTATE 25P02), because PostgreSQL rolls the transaction back. `COPY`, `LISTEN` notifications and cancelling a running query are not supported. Tested with PostgreSQL 16.
228228 * The tests use a mock server. To run the PostgreSQL tests against a real server as well, set `LB_TEST_PGSQL=pgsql://user:password@host:port/database` (add `?cafile=/path/ca.pem` for TLS); they create and drop tables whose names start with `lb_test_`.
229229
230+### Converting databases
231+`Std\Lib\Db\Convert` copies tables and rows between MySQL/MariaDB, PostgreSQL, SQLite, SQL dump files and Little Beast's own JSON data in `data/`, in any direction. It is pure PHP, like the drivers.
232+```php
233+use Std\Lib\Db;
234+use Std\Lib\Db\Convert;
235+use Std\Lib\Db\Convert\{DbSource, DbTarget, DumpSource, DumpTarget, JsonSource, JsonTarget};
236+
237+// data/ (JSON) → SQLite
238+$report = Convert::run(new JsonSource(ROOT.'/data'), new DbTarget(new Db(['driver' => 'sqlite', 'path' => 'data/site.sqlite'])));
239+// PostgreSQL → MySQL dump file
240+Convert::run(new DbSource(new Db(['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => 5432, 'username' => 'lb', 'password' => '…', 'dbname' => 'lb'])),
241+ new DumpTarget('/tmp/lb.sql', 'mysql'));
242+// mysqldump or pg_dump file → PostgreSQL, replacing tables that already exist
243+Convert::run(new DumpSource('/tmp/dump.sql'), new DbTarget(new Db()), ['drop' => true]);
244+```
245+* Sources: `DbSource` (a connected `Db`), `DumpSource` (a `.sql` file, or `.sql.gz` with PHP's zlib, from `mysqldump`, `pg_dump` (plain format, `COPY` or `--inserts`), `sqlite3 .dump` or `DumpTarget`; the dialect is detected) and `JsonSource` (a data directory). Targets: `DbTarget`, `DumpTarget` (dialect `mysql`, `pgsql` or `sqlite`; the file can be loaded with `mysql`, `psql` or `sqlite3`) and `JsonTarget`.
246+* Options: `tables` (only these tables), `drop` (drop tables with the same name in the target; without it, a table that already exists is an error) and `progress` (`fn(string $table, int $rows)`). The result has `tables` (rows copied per table) and `warnings` (things that were not converted).
247+* Columns, primary keys, unique and plain indexes, `NOT NULL`, defaults and auto increment (`AUTO_INCREMENT`, `SERIAL`/identity, SQLite's `INTEGER PRIMARY KEY`) are converted, and the next number continues after the copied rows. Types are mapped to the nearest type of the target (for example `TINYINT(1)` and `boolean`, `JSON` and `jsonb`, `BLOB` and `bytea`); `ENUM`/`SET` become text and PostgreSQL arrays become JSON. Times with a time zone are stored in UTC. Foreign keys, views, triggers, functions, full-text and expression indexes are not converted (they are listed in `warnings`).
248+* JSON data: users, comments, notifications, Cron jobs, followers and ActivityPub notes become tables whose names start with `lb_`, and every other file (except `cache/`, `ratelimit/`, lock and temporary files and SQLite databases) goes into `lb_files`, so converting back gives the same files. Other tables become `data/db/{table}.json`. An empty or missing `data/` directory simply has no tables. `JsonTarget` refuses to write into a directory that is not empty unless its second argument (overwrite) is `true` or `drop` is set.
249+* When the source or target is the database the site is using, stop writes to it first (or convert into a new database), as the conversion does not lock the source.
250+
230251 ### TLS 1.3 and 1.2 (no php_openssl)
231252 HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL and PostgreSQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working.
232253 * TLS 1.3 is preferred. Servers that only speak TLS 1.2 get TLS 1.2 with secure cipher suites only (ECDHE with AES-GCM or ChaCha20-Poly1305, Extended Master Secret when the server supports it, no renegotiation); CBC, RSA key exchange and DHE are not supported, and neither is TLS 1.1 or older. A TLS 1.3 server that is pushed down to TLS 1.2 by an attacker is detected. Use the option `'min_version' => '1.3'` to refuse TLS 1.2.
@@ -0,0 +1,488 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib\Db;
39+
40+use Std\Lib\Db\Convert\Source;
41+use Std\Lib\Db\Convert\Target;
42+use Std\Lib\Db\Sqlite\Blob;
43+
44+/**
45+ * データベースの変換(MySQL・PostgreSQL・SQLite・SQLのダンプ・LittleBeastのJSONのデータ)
46+ *
47+ * Source(DbSource、DumpSource、JsonSource)から読んで、Target(DbTarget、DumpTarget、JsonTarget)に書く。
48+ * テーブルの定義は共通の形にしてから、書く先の方言のCREATE TABLEにする:
49+ * name、columns(name、type、size、scale、unsigned、notNull、default、autoinc、tz、collate)、pk(列の名前)、
50+ * indexes(name、columns、unique)
51+ * 共通の型:int(bits:16、32、64)、real、decimal、varchar、char、text、blob、bool、date、datetime(tz)、time、json、uuid
52+ * 値:null、int、float、string、bool、Sqlite\Blob(バイナリ)。日付は「Y-m-d H:i:s」(時差が有ればUTC)。
53+ * 外部キー、ビュー、トリガー、関数は変換しない。
54+ *
55+ * $report = Convert::run(new DbSource(new Db(['driver' => 'pgsql', …])), new DbTarget(new Db(['driver' => 'sqlite', 'path' => 'data/x.sqlite'])));
56+ */
57+final class Convert {
58+ public const TYPES = ['int', 'real', 'decimal', 'varchar', 'char', 'text', 'blob', 'bool', 'date', 'datetime', 'time', 'json', 'uuid'];
59+
60+ /**
61+ * 変換する
62+ *
63+ * @param Source $src
64+ * @param Target $dst
65+ * @param array $opt tables(変換するテーブルの名前の配列、nullは全部)、drop(書く先の同じ名前のテーブルを消す)、
66+ * progress(fn(string $table, int $rows))
67+ * @return array tables(名前 => 行数)、warnings
68+ * @throws \Throwable 失敗した場合(書く先は途中までの状態)
69+ */
70+ public static function run(Source $src, Target $dst, array $opt = []): array {
71+ $only = isset($opt['tables']) ? array_map('strtolower', (array)$opt['tables']) : null;
72+ $progress = $opt['progress'] ?? null;
73+ $warnings = [];
74+ $tables = [];
75+ foreach ($src->tables() as $t) {
76+ if ($only !== null && !in_array(strtolower($t['name']), $only, true)) continue;
77+ $tables[strtolower($t['name'])] = self::normalizeTable($t);
78+ }
79+ // MySQLのインデックスの有るTEXT・BLOBは長さが必要(VARCHAR・VARBINARYにする)
80+ if (property_exists($dst, 'dialect') && $dst->dialect === 'mysql' && method_exists($src, 'maxLengths')) {
81+ foreach ($tables as $k => $t) {
82+ $indexed = array_map('strtolower', array_merge($t['pk'], ...array_map(fn($i) => $i['columns'], $t['indexes'] ?: [['columns' => []]])));
83+ $need = false;
84+ foreach ($t['columns'] as $c) if (in_array(strtolower($c['name']), $indexed, true) && in_array($c['type'], ['text', 'blob'], true)) $need = true;
85+ if (!$need) continue;
86+ $lens = $src->maxLengths($t);
87+ foreach ($t['columns'] as $i => $c) {
88+ if (in_array(strtolower($c['name']), $indexed, true) && in_array($c['type'], ['text', 'blob'], true)) {
89+ $len = (int)($lens[$c['name']] ?? 0);
90+ if ($len > ($c['type'] === 'text' ? 768 : 3072)) $warnings[] = "インデックスには長過ぎる値が有ります:{$t['name']}.{$c['name']}({$len})";
91+ $tables[$k]['columns'][$i]['size'] = max($c['size'] ?? 0, $len);
92+ }
93+ }
94+ }
95+ }
96+ $counts = array_fill_keys(array_map(fn($t) => $t['name'], $tables), 0);
97+ try {
98+ $dst->begin(!empty($opt['drop']));
99+ foreach ($tables as $t) $dst->createTable($t);
100+ $src->rows(array_keys($tables), function(string $name, array $rows) use ($tables, $dst, &$counts, $progress): void {
101+ $t = $tables[strtolower($name)] ?? null;
102+ if ($t === null || empty($rows)) return;
103+ $norm = [];
104+ foreach ($rows as $r) $norm[] = self::normalizeRow($t, $r);
105+ try {
106+ $dst->insertRows($t, $norm);
107+ } catch (DbException $e) {
108+ throw new DbException("{$t['name']}の行(".($counts[$t['name']] + 1).'行目から):'.$e->getMessage(), $e->getCode(), $e->sqlState, $e);
109+ }
110+ $counts[$t['name']] += count($norm);
111+ if ($progress) $progress($t['name'], $counts[$t['name']]);
112+ });
113+ foreach ($tables as $t) $dst->finishTable($t);
114+ $dst->end();
115+ } catch (\Throwable $e) {
116+ $dst->abort();
117+ throw $e;
118+ }
119+ return ['tables' => $counts, 'warnings' => array_values(array_unique(array_merge($warnings, $src->warnings(), $dst->warnings())))];
120+ }
121+
122+ /** テーブルの定義を確認して、足りない値を補う */
123+ public static function normalizeTable(array $t): array {
124+ $t += ['pk' => [], 'indexes' => []];
125+ $names = [];
126+ foreach ($t['columns'] as $i => $c) {
127+ $c += ['size' => null, 'scale' => null, 'unsigned' => false, 'notNull' => false, 'default' => null, 'autoinc' => false, 'tz' => false, 'collate' => null, 'array' => false, 'binary' => false];
128+ if ($c['type'] === 'int') $c['size'] ??= 64;
129+ if (!in_array($c['type'], self::TYPES, true)) throw new \InvalidArgumentException("不明な型:{$c['type']}({$t['name']}.{$c['name']})");
130+ $t['columns'][$i] = $c;
131+ $names[strtolower($c['name'])] = true;
132+ }
133+ foreach ($t['pk'] as $n) if (!isset($names[strtolower($n)])) throw new \InvalidArgumentException("主キーの列がありません:{$t['name']}.{$n}");
134+ foreach ($t['indexes'] as $k => $idx) {
135+ $t['indexes'][$k] += ['unique' => false];
136+ foreach ($idx['columns'] as $n) if (!isset($names[strtolower($n)])) throw new \InvalidArgumentException("インデックスの列がありません:{$t['name']}.{$n}");
137+ }
138+ return $t;
139+ }
140+
141+ /**
142+ * 行の値を共通の形にする(型に合わせる)
143+ *
144+ * @param array $t テーブル
145+ * @param array $row 列の順番の値
146+ * @return array
147+ */
148+ public static function normalizeRow(array $t, array $row): array {
149+ $out = [];
150+ foreach ($t['columns'] as $i => $c) {
151+ $v = $row[$i] ?? null;
152+ $out[] = $v === null ? null : self::normalizeValue($v, $c);
153+ }
154+ return $out;
155+ }
156+
157+ public static function normalizeValue(mixed $v, array $c): mixed {
158+ if (is_array($v)) $v = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
159+ switch ($c['type']) {
160+ case 'bool':
161+ if (is_bool($v)) return $v;
162+ if (is_string($v)) {
163+ $l = strtolower(trim($v));
164+ if (in_array($l, ['t', 'true', 'y', 'yes', 'on', '1'], true)) return true;
165+ if (in_array($l, ['f', 'false', 'n', 'no', 'off', '0', ''], true)) return false;
166+ }
167+ return (bool)(is_object($v) ? (string)$v : $v);
168+ case 'int':
169+ if (is_int($v)) return $v;
170+ if (is_bool($v)) return (int)$v;
171+ if (is_float($v) && floor($v) === $v && abs($v) < 9.2e18) return (int)$v;
172+ if (is_string($v) && preg_match('/^\s*[+-]?\d+\s*$/', $v) && (string)(int)trim($v) === ltrim(trim($v), '+')) return (int)trim($v);
173+ return is_object($v) ? (string)$v : $v;
174+ case 'real':
175+ if (is_float($v) || is_int($v)) return (float)$v;
176+ if (is_string($v) && is_numeric(trim($v))) return (float)trim($v);
177+ if (is_string($v) && in_array(strtolower($v), ['nan', 'infinity', '-infinity', 'inf', '-inf'], true)) return match (strtolower($v)) { 'nan' => NAN, 'infinity', 'inf' => INF, default => -INF };
178+ return is_object($v) ? (string)$v : $v;
179+ case 'decimal':
180+ if (is_float($v)) return rtrim(rtrim(sprintf('%.'.max(0, (int)($c['scale'] ?? 10)).'F', $v), '0'), '.') ?: '0';
181+ return is_bool($v) ? (string)(int)$v : (string)$v;
182+ case 'blob':
183+ if ($v instanceof Blob) return $v;
184+ return new Blob(is_bool($v) ? (string)(int)$v : (string)$v);
185+ case 'datetime':
186+ case 'date':
187+ case 'time':
188+ return self::normalizeTime((string)$v, $c['type']);
189+ default:
190+ if ($v instanceof Blob) return $v->data;
191+ if (is_bool($v)) return $c['type'] === 'json' ? ($v ? 'true' : 'false') : (string)(int)$v;
192+ if (is_float($v)) return Sqlite\Value::realToText($v);
193+ return (string)$v;
194+ }
195+ }
196+
197+ /** 日付・時間の文字列(時差が有ればUTCにする、0000-00-00はnull) */
198+ public static function normalizeTime(string $v, string $type): ?string {
199+ $v = trim($v);
200+ if ($v === '' || str_starts_with($v, '0000-00-00')) return $type === 'time' ? $v : null;
201+ if ($type === 'time') return $v;
202+ if (preg_match('/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}(?::\d{2})?)(\.\d+)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$/i', $v, $m)) {
203+ if ($type === 'date') return $m[1];
204+ $time = ($m[2] ?? '') === '' ? '00:00:00' : (strlen($m[2]) === 5 ? $m[2].':00' : $m[2]);
205+ // 「.000000」(MySQLのDATETIME(6))は無し
206+ $frac = rtrim($m[3] ?? '', '0');
207+ if ($frac === '.') $frac = '';
208+ if (!empty($m[4])) {
209+ $d = new \DateTimeImmutable($m[1].' '.$time.' '.(strtoupper($m[4]) === 'Z' ? '+00:00' : (strlen($m[4]) === 3 ? $m[4].':00' : $m[4])));
210+ $d = $d->setTimezone(new \DateTimeZone('UTC'));
211+ return $d->format('Y-m-d H:i:s').$frac;
212+ }
213+ return $m[1].' '.$time.$frac;
214+ }
215+ // 数値(UNIXの秒)
216+ if (is_numeric($v)) return gmdate($type === 'date' ? 'Y-m-d' : 'Y-m-d H:i:s', (int)$v);
217+ return $v;
218+ }
219+
220+ ///////////////
221+ // 方言
222+
223+ /** 識別子を囲む */
224+ public static function quote(string $dialect, string $name): string {
225+ if ($dialect === 'mysql') return '`'.str_replace('`', '``', $name).'`';
226+ return '"'.str_replace('"', '""', $name).'"';
227+ }
228+
229+ /**
230+ * 列の型(方言のSQL)
231+ */
232+ public static function columnType(string $dialect, array $c, bool $indexed = false): string {
233+ $size = $c['size'];
234+ switch ($dialect) {
235+ case 'mysql':
236+ return match ($c['type']) {
237+ 'int' => ($c['size'] <= 16 ? 'SMALLINT' : ($c['size'] <= 32 ? 'INT' : 'BIGINT')).($c['unsigned'] ? ' UNSIGNED' : ''),
238+ 'real' => 'DOUBLE',
239+ 'decimal' => $size ? 'DECIMAL('.min(65, $size).','.min(30, (int)$c['scale']).')' : 'DECIMAL(65,30)',
240+ 'varchar' => $size && $size <= 16383 ? "VARCHAR({$size})" : ($indexed ? 'VARCHAR(768)' : 'LONGTEXT'),
241+ 'char' => $size && $size <= 255 ? "CHAR({$size})" : 'VARCHAR(255)',
242+ 'text' => $indexed ? 'VARCHAR('.max(255, min(768, (int)$size)).')' : 'LONGTEXT',
243+ 'blob' => $indexed ? 'VARBINARY('.max(255, min(3072, (int)$size)).')' : 'LONGBLOB',
244+ 'bool' => 'TINYINT(1)',
245+ 'date' => 'DATE',
246+ 'datetime' => 'DATETIME(6)',
247+ 'time' => 'TIME(6)',
248+ 'json' => 'JSON',
249+ 'uuid' => 'CHAR(36)',
250+ };
251+ case 'pgsql':
252+ return match ($c['type']) {
253+ 'int' => $c['size'] <= 16 ? ($c['unsigned'] ? 'INTEGER' : 'SMALLINT') : ($c['size'] <= 32 ? ($c['unsigned'] ? 'BIGINT' : 'INTEGER') : ($c['unsigned'] ? 'NUMERIC(20)' : 'BIGINT')),
254+ 'real' => 'DOUBLE PRECISION',
255+ 'decimal' => $size ? "NUMERIC({$size},".(int)$c['scale'].')' : 'NUMERIC',
256+ 'varchar' => $size ? "VARCHAR({$size})" : 'TEXT',
257+ 'char' => $size ? "CHAR({$size})" : 'TEXT',
258+ 'text' => 'TEXT',
259+ 'blob' => 'BYTEA',
260+ 'bool' => 'BOOLEAN',
261+ 'date' => 'DATE',
262+ 'datetime' => $c['tz'] ? 'TIMESTAMPTZ' : 'TIMESTAMP',
263+ 'time' => 'TIME',
264+ 'json' => !empty($c['binary']) ? 'JSONB' : 'JSON',
265+ 'uuid' => 'UUID',
266+ };
267+ default:
268+ return match ($c['type']) {
269+ 'int' => 'INTEGER',
270+ 'real' => 'REAL',
271+ 'decimal' => $size ? "NUMERIC({$size},".(int)$c['scale'].')' : 'NUMERIC',
272+ 'varchar' => $size ? "VARCHAR({$size})" : 'TEXT',
273+ 'char' => $size ? "CHAR({$size})" : 'TEXT',
274+ 'text' => 'TEXT',
275+ 'blob' => 'BLOB',
276+ 'bool' => 'BOOLEAN',
277+ 'date' => 'DATE',
278+ 'datetime' => $c['tz'] ? 'TIMESTAMPTZ' : 'DATETIME',
279+ 'time' => 'TIME',
280+ 'json' => 'JSON',
281+ 'uuid' => 'UUID',
282+ };
283+ }
284+ }
285+
286+ /**
287+ * CREATE TABLE(インデックスは別)
288+ *
289+ * @param string $dialect mysql、pgsql、sqlite
290+ * @param array $t テーブル
291+ * @param array $warnings 警告を足す
292+ * @return string
293+ */
294+ public static function createTable(string $dialect, array $t, array &$warnings = []): string {
295+ $q = fn(string $n) => self::quote($dialect, $n);
296+ $indexed = [];
297+ foreach ($t['pk'] as $n) $indexed[strtolower($n)] = true;
298+ foreach ($t['indexes'] as $idx) foreach ($idx['columns'] as $n) $indexed[strtolower($n)] = true;
299+ $single = count($t['pk']) === 1 ? strtolower($t['pk'][0]) : null;
300+ $lines = [];
301+ $pkInline = false;
302+ foreach ($t['columns'] as $c) {
303+ $isPk = $single !== null && strtolower($c['name']) === $single;
304+ $auto = $c['autoinc'] && $isPk && $c['type'] === 'int';
305+ if ($c['autoinc'] && !$auto) $warnings[] = "自動の番号は主キーの整数の列だけです:{$t['name']}.{$c['name']}";
306+ if ($dialect === 'sqlite' && $auto) {
307+ $lines[] = $q($c['name']).' INTEGER PRIMARY KEY';
308+ $pkInline = true;
309+ continue;
310+ }
311+ if ($dialect === 'pgsql' && $auto) {
312+ $lines[] = $q($c['name']).' '.($c['size'] <= 32 && !$c['unsigned'] ? 'SERIAL' : 'BIGSERIAL').' PRIMARY KEY';
313+ $pkInline = true;
314+ continue;
315+ }
316+ $line = $q($c['name']).' '.self::columnType($dialect, $c, isset($indexed[strtolower($c['name'])]));
317+ if ($dialect === 'mysql' && $c['collate'] && in_array($c['type'], ['varchar', 'char', 'text'], true)) $line .= ' COLLATE '.$c['collate'];
318+ if ($c['notNull'] || ($isPk && $dialect !== 'sqlite')) $line .= ' NOT NULL';
319+ if ($dialect === 'mysql' && $auto) $line .= ' AUTO_INCREMENT';
320+ if ($c['default'] !== null) {
321+ $d = self::defaultSql($dialect, $c);
322+ if ($d === null) $warnings[] = "DEFAULTを変換できません:{$t['name']}.{$c['name']}";
323+ else $line .= ' DEFAULT '.$d;
324+ }
325+ $lines[] = $line;
326+ }
327+ if (!$pkInline && $t['pk']) $lines[] = 'PRIMARY KEY ('.implode(', ', array_map($q, $t['pk'])).')';
328+ $sql = 'CREATE TABLE '.$q($t['name'])." (\n ".implode(",\n ", $lines)."\n)";
329+ if ($dialect === 'mysql') $sql .= ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE='.($t['collate'] ?? 'utf8mb4_bin');
330+ return $sql;
331+ }
332+
333+ /** DEFAULTの値(変換できなければnull) */
334+ private static function defaultSql(string $dialect, array $c): ?string {
335+ $d = $c['default'];
336+ if (array_key_exists('now', $d)) {
337+ if (!in_array($c['type'], ['datetime', 'date', 'time'], true)) return null;
338+ if ($dialect === 'mysql' && $c['type'] !== 'datetime') return null;
339+ return $dialect === 'mysql' ? 'CURRENT_TIMESTAMP(6)' : ($c['type'] === 'date' ? 'CURRENT_DATE' : ($c['type'] === 'time' ? 'CURRENT_TIME' : 'CURRENT_TIMESTAMP'));
340+ }
341+ if (!array_key_exists('value', $d)) return null;
342+ $v = $d['value'];
343+ if ($v === null) return 'NULL';
344+ // MySQLのTEXT・BLOBにはDEFAULTを付けられない
345+ if ($dialect === 'mysql' && in_array($c['type'], ['text', 'blob', 'json'], true)) return null;
346+ try {
347+ $v = self::normalizeValue($v, $c);
348+ } catch (\Throwable $e) {
349+ return null;
350+ }
351+ if ($v === null) return 'NULL';
352+ return self::literal($dialect, $v, $c);
353+ }
354+
355+ /**
356+ * 値のSQLのリテラル
357+ */
358+ public static function literal(string $dialect, mixed $v, ?array $c = null): string {
359+ if ($v === null) return 'NULL';
360+ if (is_bool($v)) return $dialect === 'pgsql' ? ($v ? 'TRUE' : 'FALSE') : ($v ? '1' : '0');
361+ if (is_int($v)) return (string)$v;
362+ if (is_float($v)) {
363+ if (is_nan($v) || is_infinite($v)) {
364+ if ($dialect === 'pgsql') return "'".(is_nan($v) ? 'NaN' : ($v > 0 ? 'Infinity' : '-Infinity'))."'";
365+ return $dialect === 'sqlite' && is_infinite($v) ? ($v > 0 ? '9e999' : '-9e999') : 'NULL';
366+ }
367+ $s = var_export($v, true);
368+ return str_contains($s, '.') || str_contains($s, 'E') ? $s : $s.'.0';
369+ }
370+ if ($v instanceof Blob) {
371+ $hex = bin2hex($v->data);
372+ if ($dialect === 'pgsql') return "'\\x{$hex}'::bytea";
373+ return $dialect === 'mysql' ? ($hex === '' ? "''" : "0x{$hex}") : "X'{$hex}'";
374+ }
375+ $s = (string)$v;
376+ if ($dialect === 'mysql') return "'".strtr($s, ["\\" => "\\\\", "'" => "\\'", "\0" => "\\0", "\n" => "\\n", "\r" => "\\r", "\x1A" => "\\Z"])."'";
377+ return "'".str_replace("'", "''", $s)."'";
378+ }
379+
380+ /** インデックスの名前(PostgreSQL・SQLiteはデータベース全体で一意なので、テーブルの名前を付ける) */
381+ public static function indexName(string $dialect, array $t, array $idx): string {
382+ $n = $idx['name'] !== '' && $idx['name'] !== null ? $idx['name'] : $t['name'].'_'.implode('_', $idx['columns']).'_idx';
383+ if ($dialect !== 'mysql' && stripos($n, $t['name']) !== 0) $n = $t['name'].'_'.$n;
384+ return $n;
385+ }
386+
387+ /** CREATE INDEX */
388+ public static function createIndex(string $dialect, array $t, array $idx): string {
389+ $q = fn(string $n) => self::quote($dialect, $n);
390+ return 'CREATE '.($idx['unique'] ? 'UNIQUE ' : '').'INDEX '.$q(self::indexName($dialect, $t, $idx)).' ON '.$q($t['name'])
391+ .' ('.implode(', ', array_map($q, $idx['columns'])).')';
392+ }
393+
394+ ///////////////
395+ // 型の名前から共通の型
396+
397+ /**
398+ * 型の名前(どの方言でも)を共通の型にする
399+ *
400+ * @param string $type 例:varchar(255)、int unsigned、timestamp with time zone、TINYINT(1)
401+ * @return array type、size、scale、unsigned、tz
402+ */
403+ public static function parseType(string $type, string $dialect = ''): array {
404+ $t = strtolower(trim($type));
405+ $t = preg_replace('/\s+/', ' ', $t);
406+ $unsigned = (bool)preg_match('/\bunsigned\b/', $t);
407+ $t = trim(preg_replace('/\b(unsigned|zerofill|signed)\b/', '', $t));
408+ $array = str_ends_with($t, '[]');
409+ if ($array) return ['type' => 'json', 'size' => null, 'scale' => null, 'unsigned' => false, 'tz' => false, 'array' => true];
410+ $args = [];
411+ if (preg_match('/\(([^)]*)\)/', $t, $m)) $args = array_map('trim', explode(',', $m[1]));
412+ $base = trim(preg_replace('/\([^)]*\)/', '', $t));
413+ $size = isset($args[0]) && is_numeric($args[0]) ? (int)$args[0] : null;
414+ $scale = isset($args[1]) && is_numeric($args[1]) ? (int)$args[1] : null;
415+ $r = fn(string $type, ?int $size = null, ?int $scale = null, bool $tz = false) => ['type' => $type, 'size' => $size, 'scale' => $scale, 'unsigned' => $unsigned, 'tz' => $tz];
416+ switch (true) {
417+ case $base === 'tinyint' && $size === 1:
418+ case in_array($base, ['bool', 'boolean', 'bit'], true) && ($base !== 'bit' || ($size ?? 1) === 1):
419+ return $r('bool');
420+ case in_array($base, ['tinyint', 'smallint', 'int2', 'smallserial', 'serial2'], true):
421+ return $r('int', $base === 'tinyint' && $unsigned ? 16 : ($unsigned ? 32 : 16));
422+ case in_array($base, ['mediumint', 'int', 'integer', 'int4', 'serial', 'serial4'], true):
423+ return $r('int', $unsigned || $base === 'integer' && $dialect === 'sqlite' ? 64 : 32);
424+ case in_array($base, ['bigint', 'int8', 'bigserial', 'serial8'], true) || $base === 'year':
425+ return $r('int', $base === 'year' ? 16 : 64);
426+ case in_array($base, ['float', 'double', 'double precision', 'real', 'float4', 'float8'], true):
427+ return $r('real');
428+ case in_array($base, ['decimal', 'numeric', 'dec', 'fixed', 'money'], true):
429+ return $r('decimal', $size, $scale);
430+ case in_array($base, ['varchar', 'character varying', 'nvarchar', 'varchar2', 'varying character', 'native character', 'nchar varying'], true):
431+ return $r('varchar', $size);
432+ case in_array($base, ['char', 'character', 'nchar', 'bpchar'], true):
433+ return $r('char', $size ?? 1);
434+ case in_array($base, ['text', 'tinytext', 'mediumtext', 'longtext', 'clob', 'citext', 'name', 'string', 'enum', 'set', 'inet', 'cidr', 'macaddr', 'xml', 'interval', 'tsvector'], true):
435+ return $r('text');
436+ case in_array($base, ['blob', 'tinyblob', 'mediumblob', 'longblob', 'bytea', 'binary', 'varbinary', 'bit varying', 'image'], true) || $base === 'bit':
437+ return $r('blob', in_array($base, ['binary', 'varbinary'], true) ? $size : null);
438+ case $base === 'date':
439+ return $r('date');
440+ case in_array($base, ['datetime', 'timestamp', 'timestamp without time zone', 'smalldatetime', 'datetime2'], true):
441+ return $r('datetime');
442+ case in_array($base, ['timestamptz', 'timestamp with time zone', 'datetimeoffset'], true):
443+ return $r('datetime', null, null, true);
444+ case in_array($base, ['time', 'time without time zone', 'timetz', 'time with time zone'], true):
445+ return $r('time');
446+ case in_array($base, ['json', 'jsonb'], true):
447+ return $r('json') + ['binary' => $base === 'jsonb'];
448+ case $base === 'uuid' || $base === 'uniqueidentifier':
449+ return $r('uuid');
450+ }
451+ // SQLiteの親和性の規則
452+ if ($base === '') return $r($dialect === 'sqlite' ? 'text' : 'text');
453+ if (str_contains($base, 'int')) return $r('int', 64);
454+ if (str_contains($base, 'char') || str_contains($base, 'clob') || str_contains($base, 'text')) return $r('text');
455+ if (str_contains($base, 'blob')) return $r('blob');
456+ if (str_contains($base, 'real') || str_contains($base, 'floa') || str_contains($base, 'doub')) return $r('real');
457+ if (str_contains($base, 'date') || str_contains($base, 'time')) return $r('datetime');
458+ return $r($dialect === 'sqlite' ? 'decimal' : 'text');
459+ }
460+
461+ /**
462+ * DEFAULTの式を共通の形にする
463+ *
464+ * @param string|null $expr SQLの式(例:'abc'、0、CURRENT_TIMESTAMP、now()、nextval('seq'::regclass))
465+ * @return array|null ['value' => 値]、['now' => true]、['seq' => true](自動の番号)、変換できなければnull
466+ */
467+ public static function parseDefault(?string $expr): ?array {
468+ if ($expr === null) return null;
469+ $e = trim($expr);
470+ while (preg_match('/^\((.*)\)$/s', $e, $m)) $e = trim($m[1]);
471+ // キャスト('abc'::character varying)
472+ $e = preg_replace("/::[a-z_ ]+(\\([^)]*\\))?(\\[\\])?$/i", '', $e);
473+ if (preg_match('/^nextval\(/i', $e)) return ['seq' => true];
474+ if (preg_match('/^(current_timestamp|now|localtimestamp|current_date|current_time|getdate|sysdate)(\s*\(\s*\d*\s*\))?$/i', $e)) return ['now' => true];
475+ if (preg_match("/^datetime\\s*\\(\\s*'now'/i", $e)) return ['now' => true];
476+ if (strcasecmp($e, 'null') === 0) return ['value' => null];
477+ if (strcasecmp($e, 'true') === 0) return ['value' => true];
478+ if (strcasecmp($e, 'false') === 0) return ['value' => false];
479+ if (preg_match('/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/', $e)) return ['value' => preg_match('/^[+-]?\d+$/', $e) ? (int)$e : (float)$e];
480+ if (preg_match("/^[Ee]?'((?:[^']|'')*)'$/s", $e, $m)) {
481+ $s = str_replace("''", "'", $m[1]);
482+ if ($e[0] === 'E' || $e[0] === 'e') $s = stripcslashes($s);
483+ return ['value' => $s];
484+ }
485+ if (preg_match('/^b\'([01]*)\'$/i', $e, $m)) return ['value' => bindec($m[1]) !== 0];
486+ return null;
487+ }
488+}
@@ -0,0 +1,246 @@
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\Convert;
39+
40+use Std\Lib\Db;
41+use Std\Lib\Db\Convert;
42+use Std\Lib\Db\DbException;
43+
44+/**
45+ * データベース(MySQL・PostgreSQL・SQLite)から読む
46+ *
47+ * MySQLはSHOW CREATE TABLE、PostgreSQLはinformation_schemaとpg_catalog、SQLiteはsqlite_masterでテーブルの定義を読む。
48+ */
49+final class DbSource implements Source {
50+ public string $dialect;
51+ private array $warnings = [];
52+ private ?array $tables = null;
53+
54+ /**
55+ * @param Db $db 接続(まだ接続していなくても可)
56+ * @param int $batch 1回に読む行の数
57+ */
58+ public function __construct(private Db $db, private int $batch = 1000) {
59+ $this->dialect = self::dialectOf($db);
60+ }
61+
62+ public static function dialectOf(Db $db): string {
63+ return match ($db->driverName) {
64+ 'mysql', 'mariadb' => 'mysql',
65+ 'pgsql', 'postgres', 'postgresql' => 'pgsql',
66+ default => 'sqlite',
67+ };
68+ }
69+
70+ public function warnings(): array {
71+ return array_values(array_unique($this->warnings));
72+ }
73+
74+ /**
75+ * テーブルの名前
76+ *
77+ * @return array
78+ */
79+ public static function tableNames(Db $db): array {
80+ switch (self::dialectOf($db)) {
81+ case 'mysql':
82+ $out = [];
83+ foreach ($db->query('SHOW FULL TABLES')['rows'] as $r) {
84+ $v = array_values($r);
85+ if (($v[1] ?? 'BASE TABLE') === 'BASE TABLE') $out[] = (string)$v[0];
86+ }
87+ return $out;
88+ case 'pgsql':
89+ return array_column($db->query("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name")['rows'], 'table_name');
90+ default:
91+ return array_column($db->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name")['rows'], 'name');
92+ }
93+ }
94+
95+ public function tables(): array {
96+ if ($this->tables !== null) return $this->tables;
97+ $out = [];
98+ foreach (self::tableNames($this->db) as $name) {
99+ $t = match ($this->dialect) {
100+ 'mysql' => $this->mysqlTable($name),
101+ 'pgsql' => $this->pgsqlTable($name),
102+ default => $this->sqliteTable($name),
103+ };
104+ if ($t !== null) $out[] = $t;
105+ }
106+ return $this->tables = $out;
107+ }
108+
109+ private function mysqlTable(string $name): ?array {
110+ $r = $this->db->query('SHOW CREATE TABLE '.Convert::quote('mysql', $name))['rows'][0] ?? null;
111+ $sql = $r === null ? '' : (string)(array_values($r)[1] ?? '');
112+ return Sql::createTable($sql, 'mysql', $this->warnings);
113+ }
114+
115+ private function sqliteTable(string $name): ?array {
116+ $sql = (string)($this->db->query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", [$name])['rows'][0]['sql'] ?? '');
117+ $t = Sql::createTable($sql, 'sqlite', $this->warnings);
118+ if ($t === null) return null;
119+ // INTEGER PRIMARY KEYはrowid(自動の番号)
120+ if (count($t['pk']) === 1) {
121+ foreach ($t['columns'] as $i => $c) {
122+ if (strcasecmp($c['name'], $t['pk'][0]) === 0 && $c['type'] === 'int') {
123+ $info = $this->db->query('PRAGMA table_info('.Convert::quote('sqlite', $name).')')['rows'];
124+ foreach ($info as $col) {
125+ if (strcasecmp($col['name'], $c['name']) === 0 && strtoupper($col['type']) === 'INTEGER') $t['columns'][$i]['autoinc'] = true;
126+ }
127+ }
128+ }
129+ }
130+ foreach ($this->db->query("SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL ORDER BY name", [$name])['rows'] as $r) {
131+ $idx = self::parseIndex((string)$r['sql'], 'sqlite');
132+ if ($idx === null) {
133+ $this->warnings[] = "式・部分のインデックスは変換しません:{$r['name']}";
134+ continue;
135+ }
136+ $t['indexes'][] = $idx;
137+ }
138+ return $t;
139+ }
140+
141+ /** CREATE [UNIQUE] INDEX 名前 ON テーブル (列) */
142+ public static function parseIndex(string $sql, string $dialect): ?array {
143+ $tok = Sql::tokens($sql, $dialect);
144+ $p = 1;
145+ $unique = ($tok[$p]['u'] ?? '') === 'UNIQUE';
146+ if ($unique) $p++;
147+ $p++;
148+ if (($tok[$p]['u'] ?? '') === 'IF') $p += 3;
149+ $name = Sql::name($tok, $p);
150+ $p++;
151+ Sql::name($tok, $p);
152+ if (($tok[$p]['u'] ?? '') === 'USING') $p += 2;
153+ $cols = Sql::columnList($tok, $p);
154+ if (in_array(null, $cols, true) || ($tok[$p]['u'] ?? '') === 'WHERE') return null;
155+ return ['name' => $name, 'columns' => $cols, 'unique' => $unique];
156+ }
157+
158+ private function pgsqlTable(string $name): array {
159+ $t = ['name' => $name, 'columns' => [], 'pk' => [], 'indexes' => []];
160+ $cols = $this->db->query('SELECT column_name, data_type, udt_name, character_maximum_length, numeric_precision, numeric_scale, is_nullable, column_default, is_identity
161+ FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position', [$name])['rows'];
162+ foreach ($cols as $c) {
163+ $dt = strtolower((string)$c['data_type']);
164+ $type = match (true) {
165+ $dt === 'array' => 'json[]',
166+ $dt === 'user-defined' => (string)$c['udt_name'],
167+ $dt === 'character varying' && $c['character_maximum_length'] !== null => "varchar({$c['character_maximum_length']})",
168+ $dt === 'character' => 'char('.($c['character_maximum_length'] ?? 1).')',
169+ $dt === 'numeric' && $c['numeric_precision'] !== null => "numeric({$c['numeric_precision']},".(int)$c['numeric_scale'].')',
170+ default => $dt,
171+ };
172+ $col = Convert::parseType($type, 'pgsql') + ['name' => $c['column_name'], 'notNull' => $c['is_nullable'] === 'NO', 'default' => null, 'autoinc' => $c['is_identity'] === 'YES'];
173+ if (in_array($dt, ['user-defined'], true) && !in_array($c['udt_name'], ['citext', 'uuid', 'json', 'jsonb'], true)) $col['type'] = 'text';
174+ if ($c['column_default'] !== null) {
175+ $d = Convert::parseDefault((string)$c['column_default']);
176+ if ($d !== null && isset($d['seq'])) $col['autoinc'] = true;
177+ else if ($d === null) $this->warnings[] = "DEFAULTを変換できません:{$name}.{$c['column_name']}({$c['column_default']})";
178+ else $col['default'] = $d;
179+ }
180+ $t['columns'][] = $col;
181+ }
182+ $idx = $this->db->query("SELECT i.relname AS name, ix.indisunique AS uniq, ix.indisprimary AS prim, ix.indexprs IS NOT NULL OR ix.indpred IS NOT NULL AS expr,
183+ array_to_string(ARRAY(SELECT a.attname FROM unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum ORDER BY k.n), ',') AS cols
184+ FROM pg_index ix JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_namespace ns ON ns.oid = t.relnamespace
185+ WHERE t.relname = ? AND ns.nspname = current_schema() ORDER BY i.relname", [$name])['rows'];
186+ foreach ($idx as $r) {
187+ if ($r['expr']) {
188+ $this->warnings[] = "式・部分のインデックスは変換しません:{$r['name']}";
189+ continue;
190+ }
191+ $list = explode(',', (string)$r['cols']);
192+ if ($r['prim']) $t['pk'] = $list;
193+ else $t['indexes'][] = ['name' => $r['name'], 'columns' => $list, 'unique' => (bool)$r['uniq']];
194+ }
195+ return $t;
196+ }
197+
198+ /** 列毎の一番長い値(インデックスの有る文字列の列、MySQLのVARCHARの長さ用) */
199+ public function maxLengths(array $t): array {
200+ $out = [];
201+ $q = fn(string $n) => Convert::quote($this->dialect, $n);
202+ $fn = $this->dialect === 'mysql' ? 'CHAR_LENGTH' : 'LENGTH';
203+ foreach ($t['columns'] as $c) {
204+ if (!in_array($c['type'], ['text', 'varchar', 'blob'], true)) continue;
205+ $out[$c['name']] = (int)(array_values($this->db->query("SELECT MAX({$fn}({$q($c['name'])})) FROM {$q($t['name'])}")['rows'][0] ?? [])[0] ?? 0);
206+ }
207+ return $out;
208+ }
209+
210+ public function rows(array $tables, callable $sink): void {
211+ $q = fn(string $n) => Convert::quote($this->dialect, $n);
212+ foreach ($this->tables() as $t) {
213+ if (!in_array(strtolower($t['name']), $tables, true)) continue;
214+ $cols = implode(', ', array_map(fn($c) => $q($c['name']), $t['columns']));
215+ $key = count($t['pk']) === 1 ? $t['pk'][0] : null;
216+ $from = $q($t['name']);
217+ if ($key !== null) {
218+ // 主キーの順番で、少しずつ
219+ $last = null;
220+ while (true) {
221+ $sql = "SELECT {$cols} FROM {$from}".($last !== null ? " WHERE {$q($key)} > ?" : '')." ORDER BY {$q($key)} LIMIT {$this->batch}";
222+ $rows = $this->db->query($sql, $last !== null ? [$last] : [])['rows'];
223+ if (empty($rows)) break;
224+ $sink($t['name'], array_map('array_values', $rows));
225+ $lastRow = end($rows);
226+ $last = $lastRow[$this->keyName($rows, $key)];
227+ if (count($rows) < $this->batch) break;
228+ }
229+ } else {
230+ $order = $t['pk'] ? ' ORDER BY '.implode(', ', array_map($q, $t['pk'])) : '';
231+ for ($off = 0; ; $off += $this->batch) {
232+ $rows = $this->db->query("SELECT {$cols} FROM {$from}{$order} LIMIT {$this->batch} OFFSET {$off}")['rows'];
233+ if (empty($rows)) break;
234+ $sink($t['name'], array_map('array_values', $rows));
235+ if (count($rows) < $this->batch) break;
236+ }
237+ }
238+ }
239+ }
240+
241+ /** 結果の行の主キーの名前(大文字と小文字) */
242+ private function keyName(array $rows, string $key): string {
243+ foreach (array_keys($rows[0]) as $k) if (strcasecmp((string)$k, $key) === 0) return (string)$k;
244+ return $key;
245+ }
246+}
@@ -0,0 +1,177 @@
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\Convert;
39+
40+use Std\Lib\Db;
41+use Std\Lib\Db\Convert;
42+use Std\Lib\Db\DbException;
43+use Std\Lib\Db\Sqlite\Blob;
44+
45+/**
46+ * データベース(MySQL・PostgreSQL・SQLite)に書く
47+ *
48+ * テーブルを作って、行を入れてから、インデックスを作る。PostgreSQLの自動の番号(SERIAL)は最後に続きの番号にする。
49+ */
50+final class DbTarget implements Target {
51+ public string $dialect;
52+ private array $warnings = [];
53+ private array $stmts = [];
54+ private bool $inTx = false;
55+ private int $pending = 0;
56+
57+ public function __construct(private Db $db) {
58+ $this->dialect = DbSource::dialectOf($db);
59+ }
60+
61+ public function warnings(): array {
62+ return array_values(array_unique($this->warnings));
63+ }
64+
65+ private function q(string $n): string {
66+ return Convert::quote($this->dialect, $n);
67+ }
68+
69+ public function begin(bool $drop): void {
70+ $this->drop = $drop;
71+ $this->existing = array_map('strtolower', DbSource::tableNames($this->db));
72+ if ($this->dialect === 'mysql') $this->db->query('SET FOREIGN_KEY_CHECKS = 0');
73+ }
74+
75+ private bool $drop = false;
76+ private array $existing = [];
77+
78+ public function createTable(array $t): void {
79+ if (in_array(strtolower($t['name']), $this->existing, true)) {
80+ if (!$this->drop) throw new DbException("テーブル{$t['name']}は既に有ります(消す場合は「drop」)", 0, 'HY000');
81+ $this->db->query('DROP TABLE '.$this->q($t['name']).($this->dialect === 'pgsql' ? ' CASCADE' : ''));
82+ }
83+ $this->db->query(Convert::createTable($this->dialect, $t, $this->warnings));
84+ }
85+
86+ public function insertRows(array $t, array $rows): void {
87+ $nc = count($t['columns']);
88+ $per = max(1, min(500, intdiv(30000, max(1, $nc))));
89+ $drv = $this->db->driver();
90+ $drv->connect();
91+ if (!$this->inTx) {
92+ $drv->beginTransaction();
93+ $this->inTx = true;
94+ }
95+ foreach (array_chunk($rows, $per) as $chunk) {
96+ $n = count($chunk);
97+ $key = $t['name']."\0".$n;
98+ if (!isset($this->stmts[$key])) {
99+ $ph = '('.implode(', ', array_fill(0, $nc, '?')).')';
100+ $sql = 'INSERT INTO '.$this->q($t['name']).' ('.implode(', ', array_map(fn($c) => $this->q($c['name']), $t['columns'])).') VALUES '
101+ .implode(', ', array_fill(0, $n, $ph));
102+ if (count($this->stmts) > 20) {
103+ foreach ($this->stmts as $id) $drv->demolish($id);
104+ $this->stmts = [];
105+ }
106+ $this->stmts[$key] = $drv->prepare($sql);
107+ }
108+ $params = [];
109+ foreach ($chunk as $r) {
110+ foreach ($t['columns'] as $i => $c) $params[] = $this->param($r[$i], $c, $t);
111+ }
112+ $drv->execute($this->stmts[$key], $params);
113+ $this->pending += $n;
114+ if ($this->pending >= 20000) {
115+ $drv->commit();
116+ $drv->beginTransaction();
117+ $this->pending = 0;
118+ }
119+ }
120+ }
121+
122+ private function param(mixed $v, array $c, array $t): mixed {
123+ if ($v === null) return null;
124+ if ($v instanceof Blob) return $this->dialect === 'sqlite' ? $v : $v->data;
125+ if (is_bool($v)) return $this->dialect === 'pgsql' ? $v : (int)$v;
126+ if (is_float($v) && (is_nan($v) || is_infinite($v)) && $this->dialect !== 'pgsql') {
127+ if ($this->dialect === 'mysql' || is_nan($v)) {
128+ $this->warnings[] = "NaN・無限はNULLにしました:{$t['name']}.{$c['name']}";
129+ return null;
130+ }
131+ }
132+ if (is_string($v) && $this->dialect === 'pgsql' && str_contains($v, "\0")) {
133+ $this->warnings[] = "PostgreSQLの文字列に0のバイトは入れられないので、消しました:{$t['name']}.{$c['name']}";
134+ return str_replace("\0", '', $v);
135+ }
136+ return $v;
137+ }
138+
139+ public function finishTable(array $t): void {
140+ $drv = $this->db->driver();
141+ if ($this->inTx) {
142+ $drv->commit();
143+ $this->inTx = false;
144+ $this->pending = 0;
145+ }
146+ foreach ($t['indexes'] as $idx) $this->db->query(Convert::createIndex($this->dialect, $t, $idx));
147+ if ($this->dialect === 'pgsql') {
148+ foreach ($t['columns'] as $c) {
149+ if (!$c['autoinc'] || count($t['pk']) !== 1 || strcasecmp($t['pk'][0], $c['name']) !== 0) continue;
150+ $this->db->query("SELECT setval(pg_get_serial_sequence(?, ?), COALESCE(MAX({$this->q($c['name'])}), 1), MAX({$this->q($c['name'])}) IS NOT NULL) FROM {$this->q($t['name'])}",
151+ [$this->q($t['name']), $c['name']]);
152+ }
153+ }
154+ }
155+
156+ public function abort(): void {
157+ $drv = $this->db->driver();
158+ try {
159+ if ($this->inTx && $drv->inTransaction()) $drv->rollback();
160+ } catch (\Throwable $e) {
161+ // 接続が切れた等
162+ }
163+ $this->inTx = false;
164+ $this->stmts = [];
165+ }
166+
167+ public function end(): void {
168+ $drv = $this->db->driver();
169+ foreach ($this->stmts as $id) $drv->demolish($id);
170+ $this->stmts = [];
171+ if ($this->inTx) {
172+ $drv->commit();
173+ $this->inTx = false;
174+ }
175+ if ($this->dialect === 'mysql') $this->db->query('SET FOREIGN_KEY_CHECKS = 1');
176+ }
177+}
@@ -0,0 +1,457 @@
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\Convert;
39+
40+use Std\Lib\Db\Convert;
41+use Std\Lib\Db\DbException;
42+use Std\Lib\Db\Sqlite\Blob;
43+
44+/**
45+ * SQLのダンプ(mysqldump、pg_dump(平文、COPY・INSERT)、sqlite3の.dump)から読む
46+ *
47+ * ファイルを2回読む(1回目はテーブルの定義、2回目は行)。CREATE TABLE、ALTER TABLE … ADD CONSTRAINT(主キー・一意)・
48+ * ADD PRIMARY KEY・ADD KEY・MODIFY、CREATE INDEX、シーケンス(nextval、OWNED BY、IDENTITY)、INSERT、COPYを使い、
49+ * 他の文(SET、ビュー、関数、権限等)は無視する。
50+ */
51+final class DumpSource implements Source {
52+ public string $dialect;
53+ private array $warnings = [];
54+ private ?array $tables = null;
55+
56+ /**
57+ * @param string $path ダンプのファイル(gzipで圧縮したファイルも可)
58+ * @param string|null $dialect mysql、pgsql、sqlite(nullなら最初の部分から推測)
59+ */
60+ public function __construct(private string $path, ?string $dialect = null) {
61+ if (!is_file($path) || !is_readable($path)) throw new DbException("ダンプのファイルを読めません:{$path}", 0, 'HY000');
62+ $head = $this->head(65536);
63+ if (str_starts_with($head, 'PGDMP')) throw new DbException("pg_dumpのカスタム・ディレクトリの形式には未対応です(プレーンの形式、-Fpで作って下さい):{$path}", 0, 'HY000');
64+ if (str_starts_with($head, "SQLite format 3\0")) throw new DbException("ダンプではなく、SQLiteのデータベースのファイルです:{$path}", 0, 'HY000');
65+ $this->dialect = $dialect ?? self::detect($head);
66+ }
67+
68+ private function open() {
69+ // zlibが有れば、gzopenは圧縮していないファイルもそのまま読める
70+ $gz = function_exists('gzopen');
71+ if (!$gz && str_ends_with(strtolower($this->path), '.gz')) throw new DbException("圧縮したダンプにはzlibが必要です:{$this->path}", 0, 'HY000');
72+ $fh = $gz ? @gzopen($this->path, 'rb') : @fopen($this->path, 'rb');
73+ if ($fh === false) throw new DbException("ダンプのファイルを開けません:{$this->path}", 0, 'HY000');
74+ return [$fh, $gz];
75+ }
76+
77+ private function head(int $n): string {
78+ [$fh, $gz] = $this->open();
79+ $s = $gz ? gzread($fh, $n) : fread($fh, $n);
80+ $gz ? gzclose($fh) : fclose($fh);
81+ return (string)$s;
82+ }
83+
84+ /** ダンプの方言を推測する */
85+ public static function detect(string $head): string {
86+ if (preg_match('/^-- LittleBeast.*((mysql|pgsql|sqlite))/m', $head, $m)) return $m[1];
87+ if (preg_match('/PostgreSQL database dump|^COPY .* FROM stdin;|SET search_path|pg_catalog\./mi', $head)) return 'pgsql';
88+ if (preg_match('/MySQL dump|MariaDB dump|^\/\*!\d{5}|ENGINE=|`[^`]+`/mi', $head)) return 'mysql';
89+ return 'sqlite';
90+ }
91+
92+ public function warnings(): array {
93+ return array_values(array_unique($this->warnings));
94+ }
95+
96+ /**
97+ * 文を順番に(COPYのデータはcopy)
98+ *
99+ * @return \Generator ['sql' => 文] 又は ['copy' => COPYの文, 'lines' => データの行の配列]
100+ */
101+ public function statements(): \Generator {
102+ [$fh, $gz] = $this->open();
103+ $read = $gz ? fn() => gzgets($fh) : fn() => fgets($fh);
104+ $delim = ';';
105+ $buf = '';
106+ $state = '';
107+ try {
108+ $first = true;
109+ while (($line = $read()) !== false) {
110+ if ($first) {
111+ // UTF-8のBOM
112+ if (str_starts_with($line, "\xEF\xBB\xBF")) $line = substr($line, 3);
113+ $first = false;
114+ }
115+ // COPY … FROM stdin;の後のデータ
116+ if ($state === 'copy') {
117+ if (rtrim($line, "\r\n") === '\\.') {
118+ if ($copyLines) yield ['copy' => $copySql, 'lines' => $copyLines];
119+ $copyLines = [];
120+ $state = '';
121+ continue;
122+ }
123+ $copyLines[] = rtrim($line, "\r\n");
124+ if (count($copyLines) >= 500) {
125+ yield ['copy' => $copySql, 'lines' => $copyLines];
126+ $copyLines = [];
127+ }
128+ continue;
129+ }
130+ if ($state === '' && trim($buf) === '' && preg_match('/^\s*DELIMITER\s+(\S+)\s*$/i', $line, $m)) {
131+ $delim = $m[1];
132+ continue;
133+ }
134+ $i = 0;
135+ $len = strlen($line);
136+ while ($i < $len) {
137+ if ($state === "'" || $state === '"' || $state === '`') {
138+ $bs = $this->dialect === 'mysql' && $state !== '`' || ($state === "'" && !empty($estring));
139+ $j = $i;
140+ while (true) {
141+ $k = strcspn($line, $state.($bs ? '\\' : ''), $j);
142+ $j += $k;
143+ if ($j >= $len) break;
144+ if ($line[$j] === '\\') {
145+ $j += 2;
146+ continue;
147+ }
148+ // 「''」
149+ if (($line[$j + 1] ?? '') === $state) {
150+ $j += 2;
151+ continue;
152+ }
153+ $state = '';
154+ $j++;
155+ break;
156+ }
157+ $buf .= substr($line, $i, min($j, $len) - $i);
158+ $i = min($j, $len);
159+ continue;
160+ }
161+ if ($state === '*/') {
162+ $j = strpos($line, '*/', $i);
163+ if ($j === false) {
164+ $buf .= substr($line, $i);
165+ break;
166+ }
167+ $buf .= substr($line, $i, $j + 2 - $i);
168+ $i = $j + 2;
169+ $state = '';
170+ continue;
171+ }
172+ if (str_starts_with($state, '$')) {
173+ $j = strpos($line, $state, $i);
174+ if ($j === false) {
175+ $buf .= substr($line, $i);
176+ break;
177+ }
178+ $buf .= substr($line, $i, $j + strlen($state) - $i);
179+ $i = $j + strlen($state);
180+ $state = '';
181+ continue;
182+ }
183+ $k = strcspn($line, "'\"`-#/\$".$delim[0], $i);
184+ $buf .= substr($line, $i, $k);
185+ $i += $k;
186+ if ($i >= $len) break;
187+ $c = $line[$i];
188+ if ($c === $delim[0] && substr($line, $i, strlen($delim)) === $delim) {
189+ $sql = trim($buf);
190+ $buf = '';
191+ $i += strlen($delim);
192+ if ($sql !== '') {
193+ if (preg_match('/^COPY\s.*\sFROM\s+stdin/is', $sql)) {
194+ $state = 'copy';
195+ $copySql = $sql;
196+ $copyLines = [];
197+ break;
198+ }
199+ yield ['sql' => $sql];
200+ }
201+ continue;
202+ }
203+ if ($c === "'" || $c === '"' || $c === '`') {
204+ if ($c === '`' && $this->dialect !== 'mysql' && $this->dialect !== 'sqlite') {
205+ $buf .= $c;
206+ $i++;
207+ continue;
208+ }
209+ $estring = $c === "'" && $i > 0 && ($line[$i - 1] === 'E' || $line[$i - 1] === 'e') && !ctype_alnum($line[$i - 2] ?? ' ');
210+ $state = $c;
211+ $buf .= $c;
212+ $i++;
213+ continue;
214+ }
215+ if ($c === '-' && ($line[$i + 1] ?? '') === '-' || ($c === '#' && $this->dialect === 'mysql')) {
216+ // 行の最後までのコメント
217+ break;
218+ }
219+ if ($c === '/' && ($line[$i + 1] ?? '') === '*') {
220+ $state = '*/';
221+ $buf .= '/*';
222+ $i += 2;
223+ continue;
224+ }
225+ if ($c === '$' && $this->dialect === 'pgsql' && preg_match('/\G\$([A-Za-z_][A-Za-z0-9_]*)?\$/', $line, $m, 0, $i)) {
226+ $state = $m[0];
227+ $buf .= $m[0];
228+ $i += strlen($m[0]);
229+ continue;
230+ }
231+ $buf .= $c;
232+ $i++;
233+ }
234+ if ($state === '' && $buf !== '' && !str_ends_with($buf, "\n")) $buf .= "\n";
235+ }
236+ if ($state !== '') throw new DbException("ダンプのファイルが途中で終わっています({$state}):{$this->path}", 0, 'HY000');
237+ if (trim($buf) !== '') yield ['sql' => trim($buf)];
238+ } finally {
239+ $gz ? gzclose($fh) : fclose($fh);
240+ }
241+ }
242+
243+ public function tables(): array {
244+ if ($this->tables !== null) return array_values($this->tables);
245+ $tables = [];
246+ $seqOwner = [];
247+ foreach ($this->statements() as $st) {
248+ if (!isset($st['sql'])) continue;
249+ $sql = $st['sql'];
250+ // MySQLの「/*!40101 … */」の中の文
251+ if (preg_match('/^\/\*!\d*\s*(.*)\*\/$/s', $sql, $m)) $sql = trim($m[1]);
252+ $head = strtoupper(substr(ltrim($sql), 0, 40));
253+ if (str_starts_with($head, 'CREATE') && preg_match('/^CREATE\s+(?:(?:TEMP|TEMPORARY|UNLOGGED|GLOBAL|LOCAL)\s+)*TABLE\b/i', $sql)) {
254+ if (preg_match('/\bAS\s+SELECT\b/i', $sql) && !str_contains($sql, '(')) continue;
255+ $t = Sql::createTable($sql, $this->dialect, $this->warnings);
256+ if ($t === null) continue;
257+ if (in_array(strtolower($t['name']), ['sqlite_sequence', 'sqlite_stat1', 'sqlite_stat4'], true)) continue;
258+ $tables[strtolower($t['name'])] = $t;
259+ } else if (str_starts_with($head, 'CREATE') && preg_match('/^CREATE\s+(UNIQUE\s+)?INDEX\b/i', $sql)) {
260+ $this->createIndex($sql, $tables);
261+ } else if (str_starts_with($head, 'ALTER TABLE')) {
262+ $this->alterTable($sql, $tables);
263+ } else if (preg_match('/^ALTER\s+SEQUENCE\s+(\S+)\s+OWNED\s+BY\s+(\S+)\s*$/i', $sql, $m)) {
264+ $parts = explode('.', str_replace('"', '', $m[2]));
265+ $col = array_pop($parts);
266+ $tbl = array_pop($parts);
267+ $seqOwner[strtolower(self::lastPart($m[1]))] = [strtolower((string)$tbl), $col];
268+ } else if (preg_match('/^CREATE\s+(OR\s+REPLACE\s+)?(VIEW|TRIGGER|FUNCTION|PROCEDURE|MATERIALIZED)/i', $sql, $m)) {
269+ $this->warnings[] = strtoupper($m[2]).'は変換しません';
270+ }
271+ }
272+ // nextval('seq')のDEFAULTはalterTable()でautoincにする。OWNED BYだけの場合
273+ foreach ($seqOwner as [$tbl, $col]) {
274+ if (!isset($tables[$tbl])) continue;
275+ foreach ($tables[$tbl]['columns'] as $i => $c) {
276+ if (strcasecmp($c['name'], $col) === 0 && $c['type'] === 'int' && !empty($this->nextval[$tbl][strtolower($col)])) $tables[$tbl]['columns'][$i]['autoinc'] = true;
277+ }
278+ }
279+ $this->tables = $tables;
280+ return array_values($tables);
281+ }
282+
283+ private array $nextval = [];
284+
285+ private static function lastPart(string $name): string {
286+ $p = explode('.', str_replace(['"', '`'], '', $name));
287+ return end($p);
288+ }
289+
290+ private function createIndex(string $sql, array &$tables): void {
291+ $tok = Sql::tokens($sql, $this->dialect);
292+ $p = 1;
293+ $unique = false;
294+ if (($tok[$p]['u'] ?? '') === 'UNIQUE') {
295+ $unique = true;
296+ $p++;
297+ }
298+ $p++;
299+ if (($tok[$p]['u'] ?? '') === 'CONCURRENTLY') $p++;
300+ if (($tok[$p]['u'] ?? '') === 'IF') $p += 3;
301+ $name = '';
302+ if (($tok[$p]['u'] ?? '') !== 'ON') $name = Sql::name($tok, $p);
303+ $p++;
304+ if (($tok[$p]['u'] ?? '') === 'ONLY') $p++;
305+ $table = strtolower(Sql::name($tok, $p));
306+ if (($tok[$p]['u'] ?? '') === 'USING') $p += 2;
307+ if (!isset($tables[$table])) return;
308+ $cols = Sql::columnList($tok, $p);
309+ if (in_array(null, $cols, true) || ($tok[$p]['u'] ?? '') === 'WHERE') {
310+ $this->warnings[] = "式・部分のインデックスは変換しません:{$name}";
311+ return;
312+ }
313+ $tables[$table]['indexes'][] = ['name' => $name, 'columns' => $cols, 'unique' => $unique];
314+ }
315+
316+ private function alterTable(string $sql, array &$tables): void {
317+ $tok = Sql::tokens($sql, $this->dialect);
318+ $p = 2;
319+ if (($tok[$p]['u'] ?? '') === 'IF') $p += 2;
320+ if (($tok[$p]['u'] ?? '') === 'ONLY') $p++;
321+ $table = strtolower(Sql::name($tok, $p));
322+ if (!isset($tables[$table])) return;
323+ $n = count($tok);
324+ // 「,」で区切った複数の操作
325+ $ops = [];
326+ $cur = [];
327+ $depth = 0;
328+ for (; $p < $n; $p++) {
329+ if ($tok[$p]['t'] === 'op' && $tok[$p]['v'] === '(') $depth++;
330+ if ($tok[$p]['t'] === 'op' && $tok[$p]['v'] === ')') $depth--;
331+ if ($depth === 0 && $tok[$p]['t'] === 'op' && $tok[$p]['v'] === ',') {
332+ $ops[] = $cur;
333+ $cur = [];
334+ continue;
335+ }
336+ $cur[] = $tok[$p];
337+ }
338+ if ($cur) $ops[] = $cur;
339+ foreach ($ops as $op) {
340+ $u = array_map(fn($t) => $t['u'] ?? '', $op);
341+ if (($u[0] ?? '') === 'ADD') {
342+ $k = 1;
343+ $cname = '';
344+ if (($u[$k] ?? '') === 'CONSTRAINT') {
345+ $cname = (string)($op[$k + 1]['v'] ?? '');
346+ $k += 2;
347+ }
348+ if (($u[$k] ?? '') === 'PRIMARY') {
349+ while ($k < count($op) && !($op[$k]['t'] === 'op' && $op[$k]['v'] === '(')) $k++;
350+ $tables[$table]['pk'] = array_values(array_filter(Sql::columnList($op, $k)));
351+ } else if (in_array($u[$k] ?? '', ['UNIQUE', 'KEY', 'INDEX'], true)) {
352+ $unique = $u[$k] === 'UNIQUE';
353+ $k++;
354+ if (in_array($u[$k] ?? '', ['KEY', 'INDEX'], true)) $k++;
355+ if (!($op[$k]['t'] === 'op' && $op[$k]['v'] === '(')) $cname = (string)$op[$k++]['v'];
356+ while ($k < count($op) && !($op[$k]['t'] === 'op' && $op[$k]['v'] === '(')) $k++;
357+ $cols = Sql::columnList($op, $k);
358+ if (!in_array(null, $cols, true)) $tables[$table]['indexes'][] = ['name' => $cname, 'columns' => $cols, 'unique' => $unique];
359+ } else if (($u[$k] ?? '') === 'FOREIGN') {
360+ $this->warnings[] = "外部キーは変換しません:{$tables[$table]['name']}";
361+ }
362+ } else if (($u[0] ?? '') === 'ALTER') {
363+ // ALTER COLUMN x SET DEFAULT nextval(…)、ADD GENERATED … AS IDENTITY
364+ $k = ($u[1] ?? '') === 'COLUMN' ? 2 : 1;
365+ $col = (string)($op[$k]['v'] ?? '');
366+ $rest = strtoupper(Sql::text(array_slice($op, $k + 1)));
367+ if (str_contains($rest, 'NEXTVAL') || str_contains($rest, 'IDENTITY')) {
368+ foreach ($tables[$table]['columns'] as $i => $c) {
369+ if (strcasecmp($c['name'], $col) === 0) {
370+ $tables[$table]['columns'][$i]['autoinc'] = true;
371+ $this->nextval[$table][strtolower($col)] = true;
372+ }
373+ }
374+ }
375+ } else if (($u[0] ?? '') === 'MODIFY' || ($u[0] ?? '') === 'CHANGE') {
376+ // MODIFY [COLUMN] col 型 …(phpMyAdminのAUTO_INCREMENT)
377+ $k = ($u[1] ?? '') === 'COLUMN' ? 2 : 1;
378+ if ($u[0] === 'CHANGE') $k++;
379+ $def = Sql::columnDef(array_slice($op, $k), $this->dialect, $table, $this->warnings);
380+ if ($def === null) continue;
381+ foreach ($tables[$table]['columns'] as $i => $c) {
382+ if (strcasecmp($c['name'], $def['name']) === 0) {
383+ unset($def['pk'], $def['unique']);
384+ $tables[$table]['columns'][$i] = $def + $c;
385+ }
386+ }
387+ }
388+ }
389+ }
390+
391+ public function rows(array $tables, callable $sink): void {
392+ $defs = [];
393+ foreach ($this->tables() as $t) $defs[strtolower($t['name'])] = $t;
394+ $want = array_flip($tables);
395+ foreach ($this->statements() as $st) {
396+ if (isset($st['copy'])) {
397+ if (!preg_match('/^COPY\s+(?:ONLY\s+)?(\S+)\s*(?:\(([^)]*)\))?\s+FROM/is', $st['copy'], $m)) continue;
398+ $name = strtolower(self::lastPart($m[1]));
399+ if (!isset($want[$name], $defs[$name])) continue;
400+ $cols = isset($m[2]) && trim($m[2]) !== '' ? array_map(fn($c) => trim($c, " \t\n\"`"), explode(',', $m[2])) : null;
401+ $rows = [];
402+ foreach ($st['lines'] as $line) $rows[] = Sql::copyLine($line);
403+ $sink($defs[$name]['name'], $this->arrange($defs[$name], $cols, $rows, true));
404+ continue;
405+ }
406+ $sql = $st['sql'];
407+ if (!preg_match('/^(INSERT|REPLACE)\b/i', $sql)) continue;
408+ $ins = Sql::insert($sql, $this->dialect);
409+ if ($ins === null) continue;
410+ $name = strtolower($ins['table']);
411+ if (!isset($want[$name], $defs[$name])) continue;
412+ if (!empty($ins['select'])) {
413+ $this->warnings[] = "INSERT … SELECTは変換しません:{$ins['table']}";
414+ continue;
415+ }
416+ $sink($defs[$name]['name'], $this->arrange($defs[$name], $ins['columns'], $ins['rows'], false));
417+ }
418+ }
419+
420+ /** 列の順番にする(COPYの文字列は型に合わせる) */
421+ private function arrange(array $t, ?array $cols, array $rows, bool $copy): array {
422+ $map = null;
423+ if ($cols !== null) {
424+ $idx = [];
425+ foreach ($t['columns'] as $i => $c) $idx[strtolower($c['name'])] = $i;
426+ $map = [];
427+ foreach ($cols as $k => $n) {
428+ if (!isset($idx[strtolower($n)])) throw new DbException("ダンプの列がありません:{$t['name']}.{$n}", 0, 'HY000');
429+ $map[$k] = $idx[strtolower($n)];
430+ }
431+ }
432+ $nc = count($t['columns']);
433+ $out = [];
434+ foreach ($rows as $r) {
435+ $row = array_fill(0, $nc, null);
436+ foreach ($r as $k => $v) {
437+ $i = $map === null ? $k : ($map[$k] ?? null);
438+ if ($i === null || $i >= $nc) continue;
439+ $c = $t['columns'][$i];
440+ // PostgreSQLの配列({a,"b c"})はJSONの配列
441+ if ($v !== null && !empty($c['array']) && $this->dialect === 'pgsql' && is_string($v) && str_starts_with(ltrim($v), '{') || (is_string($v) && str_starts_with($v, '[') && str_contains($v, ']={'))) {
442+ $arr = \Std\Lib\Db\Pgsql::parseArray($v);
443+ if ($arr !== null) $v = $arr;
444+ }
445+ // PostgreSQLのbytea(\x…)、COPYのbool(t、f)
446+ if ($v !== null && !$v instanceof Blob && $c['type'] === 'blob') {
447+ $s = (string)$v;
448+ if ($this->dialect === 'pgsql' && str_starts_with($s, '\\x')) $v = new Blob((string)hex2bin(substr($s, 2)));
449+ else if ($this->dialect === 'pgsql' && $copy && str_contains($s, '\\')) $v = new Blob(stripcslashes($s));
450+ }
451+ $row[$i] = $v;
452+ }
453+ $out[] = $row;
454+ }
455+ return $out;
456+ }
457+}
@@ -0,0 +1,148 @@
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\Convert;
39+
40+use Std\Lib\Db\Convert;
41+use Std\Lib\Db\DbException;
42+
43+/**
44+ * SQLのダンプのファイルに書く(MySQL、PostgreSQL、SQLiteの方言)
45+ *
46+ * mysql・psql・sqlite3のコマンド、又はDumpSourceで読める。
47+ */
48+final class DumpTarget implements Target {
49+ /** @var resource|null */
50+ private $fh = null;
51+ private array $warnings = [];
52+ private bool $drop = false;
53+ private int $perInsert = 200;
54+
55+ /**
56+ * @param string $path 書くファイル(.gzなら圧縮)
57+ * @param string $dialect mysql、pgsql、sqlite
58+ */
59+ public function __construct(private string $path, public readonly string $dialect) {
60+ if (!in_array($dialect, ['mysql', 'pgsql', 'sqlite'], true)) throw new \InvalidArgumentException("未対応の方言:{$dialect}");
61+ }
62+
63+ public function warnings(): array {
64+ return array_values(array_unique($this->warnings));
65+ }
66+
67+ private function out(string $s): void {
68+ $n = str_ends_with(strtolower($this->path), '.gz') ? gzwrite($this->fh, $s) : fwrite($this->fh, $s);
69+ if ($n === false) throw new DbException("ダンプのファイルに書けません:{$this->path}", 0, 'HY000');
70+ }
71+
72+ public function begin(bool $drop): void {
73+ $this->drop = $drop;
74+ $gz = str_ends_with(strtolower($this->path), '.gz');
75+ if ($gz && !function_exists('gzopen')) throw new DbException("圧縮したダンプにはzlibが必要です:{$this->path}", 0, 'HY000');
76+ $fh = $gz ? @gzopen($this->path, 'wb6') : @fopen($this->path, 'wb');
77+ if ($fh === false) throw new DbException("ダンプのファイルを作れません:{$this->path}", 0, 'HY000');
78+ $this->fh = $fh;
79+ $this->out("-- LittleBeastのデータベースの変換({$this->dialect})\n-- ".gmdate('Y-m-d H:i:s')." UTC\n");
80+ if ($this->dialect === 'mysql') {
81+ $this->out("SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS = 0;\nSET SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO';\nSET time_zone = '+00:00';\n");
82+ } else if ($this->dialect === 'pgsql') {
83+ $this->out("SET client_encoding = 'UTF8';\nSET standard_conforming_strings = on;\nSET TimeZone = 'UTC';\n");
84+ }
85+ $this->out("BEGIN;\n\n");
86+ }
87+
88+ public function createTable(array $t): void {
89+ $q = fn(string $n) => Convert::quote($this->dialect, $n);
90+ if ($this->drop) $this->out('DROP TABLE IF EXISTS '.$q($t['name']).($this->dialect === 'pgsql' ? ' CASCADE' : '').";\n");
91+ $this->out(Convert::createTable($this->dialect, $t, $this->warnings).";\n\n");
92+ }
93+
94+ public function insertRows(array $t, array $rows): void {
95+ $q = fn(string $n) => Convert::quote($this->dialect, $n);
96+ $head = 'INSERT INTO '.$q($t['name']).' ('.implode(', ', array_map(fn($c) => $q($c['name']), $t['columns'])).') VALUES';
97+ foreach (array_chunk($rows, $this->perInsert) as $chunk) {
98+ $vals = [];
99+ foreach ($chunk as $r) {
100+ $parts = [];
101+ foreach ($t['columns'] as $i => $c) $parts[] = $this->value($r[$i], $c, $t);
102+ $vals[] = '('.implode(', ', $parts).')';
103+ }
104+ $this->out($head."\n".implode(",\n", $vals).";\n");
105+ }
106+ }
107+
108+ private function value(mixed $v, array $c, array $t): string {
109+ if ($v === null) return 'NULL';
110+ if (is_string($v) && $this->dialect === 'pgsql' && str_contains($v, "\0")) {
111+ $this->warnings[] = "PostgreSQLの文字列に0のバイトは入れられないので、消しました:{$t['name']}.{$c['name']}";
112+ $v = str_replace("\0", '', $v);
113+ }
114+ if (is_float($v) && $this->dialect !== 'pgsql' && (is_nan($v) || is_infinite($v))) {
115+ $this->warnings[] = "NaN・無限はNULLにしました:{$t['name']}.{$c['name']}";
116+ }
117+ return Convert::literal($this->dialect, $v, $c);
118+ }
119+
120+ public function finishTable(array $t): void {
121+ $q = fn(string $n) => Convert::quote($this->dialect, $n);
122+ foreach ($t['indexes'] as $idx) $this->out(Convert::createIndex($this->dialect, $t, $idx).";\n");
123+ // 自動の番号の続き
124+ foreach ($t['columns'] as $c) {
125+ if (!$c['autoinc'] || count($t['pk']) !== 1 || strcasecmp($t['pk'][0], $c['name']) !== 0) continue;
126+ if ($this->dialect === 'pgsql') {
127+ $this->out("SELECT setval(pg_get_serial_sequence('".str_replace("'", "''", $q($t['name']))."', '".str_replace("'", "''", $c['name'])."'), "
128+ ."COALESCE(MAX({$q($c['name'])}), 1), MAX({$q($c['name'])}) IS NOT NULL) FROM {$q($t['name'])};\n");
129+ }
130+ }
131+ $this->out("\n");
132+ }
133+
134+ public function abort(): void {
135+ if ($this->fh) {
136+ str_ends_with(strtolower($this->path), '.gz') ? @gzclose($this->fh) : @fclose($this->fh);
137+ $this->fh = null;
138+ @unlink($this->path);
139+ }
140+ }
141+
142+ public function end(): void {
143+ $this->out("COMMIT;\n");
144+ if ($this->dialect === 'mysql') $this->out("SET FOREIGN_KEY_CHECKS = 1;\n");
145+ str_ends_with(strtolower($this->path), '.gz') ? gzclose($this->fh) : fclose($this->fh);
146+ $this->fh = null;
147+ }
148+}
@@ -0,0 +1,227 @@
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\Convert;
39+
40+use Std\Lib\Db\DbException;
41+use Std\Lib\Db\Sqlite\Blob;
42+
43+/**
44+ * LittleBeastのJSONのデータ(data/)から読む(Storesを参照)
45+ *
46+ * データのディレクトリが無い・空なら、テーブルも無い。SQLiteのファイル(data/site.sqlite等)は飛ばす。
47+ */
48+final class JsonSource implements Source {
49+ private array $warnings = [];
50+ private ?array $files = null;
51+ private ?array $data = null;
52+
53+ /** @param string $dir データのディレクトリ(普通はROOT/data) */
54+ public function __construct(private string $dir) {
55+ $this->dir = rtrim($dir, '/\\');
56+ }
57+
58+ public function warnings(): array {
59+ return array_values(array_unique($this->warnings));
60+ }
61+
62+ /** データのファイル(相対パス => 絶対パス) */
63+ private function files(): array {
64+ if ($this->files !== null) return $this->files;
65+ $out = [];
66+ if (is_dir($this->dir)) {
67+ $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS));
68+ foreach ($it as $f) {
69+ if (!$f->isFile()) continue;
70+ $rel = str_replace('\\', '/', substr($f->getPathname(), strlen($this->dir) + 1));
71+ $out[$rel] = $f->getPathname();
72+ }
73+ }
74+ ksort($out, SORT_STRING);
75+ return $this->files = $out;
76+ }
77+
78+ /** 全部の行を作る(データは小さいので、メモリーに) */
79+ private function load(): array {
80+ if ($this->data !== null) return $this->data;
81+ $d = array_fill_keys(array_keys(Stores::tables()), []);
82+ $generic = [];
83+ foreach ($this->files() as $rel => $abs) {
84+ foreach (Stores::SKIP as $re) {
85+ if (preg_match($re, $rel)) {
86+ if (str_starts_with($rel, 'db/') && str_ends_with($rel, '.json')) $generic[$rel] = $abs;
87+ continue 2;
88+ }
89+ }
90+ $text = (string)@file_get_contents($abs);
91+ // SQLiteのデータベース(例:data/site.sqlite)とジャーナル
92+ if (str_starts_with($text, "SQLite format 3\0") || preg_match('/-(journal|wal|shm)$/', $rel)) {
93+ $this->warnings[] = "SQLiteのファイルは変換しません:{$rel}";
94+ continue;
95+ }
96+ if (!$this->store($rel, $text, $d)) $d['lb_files'][] = [$rel, new Blob($text)];
97+ }
98+ $this->data = ['stores' => $d, 'generic' => $generic];
99+ return $this->data;
100+ }
101+
102+ /**
103+ * ファイルを決まった形のテーブルにする
104+ *
105+ * @return bool falseならlb_files
106+ */
107+ private function store(string $rel, string $text, array &$d): bool {
108+ $isJson = str_ends_with($rel, '.json');
109+ $json = $isJson ? json_decode($text, true) : null;
110+ if ($isJson && json_last_error() !== JSON_ERROR_NONE) return false;
111+ if (preg_match('#^user/(\d+)\.([A-Za-z0-9]+)\.json$#', $rel, $m)) {
112+ if (!is_array($json)) return false;
113+ $role = $json['role'] ?? null;
114+ $d['lb_users'][] = [(int)$m[1], $m[2], isset($json['email']) && is_string($json['email']) ? $json['email'] : null, is_int($role) ? $role : null, $text];
115+ return true;
116+ }
117+ if ($rel === 'comments/ap.json') {
118+ if (!is_array($json)) return false;
119+ foreach ($json as $k => $v) $d['lb_comment_ap'][] = [(string)$k, self::encode($v)];
120+ return true;
121+ }
122+ if (preg_match('#^comments/(.+)\.json$#', $rel, $m) && Stores::safeThread($m[1])) {
123+ if (!is_array($json) || (isset($json['comments']) && !is_array($json['comments']))) return false;
124+ $comments = $json['comments'] ?? [];
125+ unset($json['comments']);
126+ $d['lb_comment_threads'][] = [$m[1], self::encode((object)$json)];
127+ $seq = 0;
128+ foreach ($comments as $id => $c) $d['lb_comments'][] = [$m[1], (string)$id, $seq++, self::encode($c)];
129+ return true;
130+ }
131+ if (preg_match('#^notifications/([^/]+)\.json$#', $rel, $m) && Stores::safeName($m[1])) {
132+ if (!is_array($json) || !array_is_list($json)) return false;
133+ foreach ($json as $i => $n) $d['lb_notifications'][] = [$m[1], $i, self::encode($n)];
134+ return true;
135+ }
136+ if ($rel === 'cron/cron.json') {
137+ if (!is_array($json) || !array_is_list($json)) return false;
138+ foreach ($json as $i => $j) $d['lb_cron'][] = [$i, self::encode($j)];
139+ return true;
140+ }
141+ if ($rel === 'followers.txt' || $rel === 'following.txt') {
142+ $t = $rel === 'followers.txt' ? 'lb_followers' : 'lb_following';
143+ $lines = array_values(array_filter(array_map('trim', explode("\n", $text)), fn($l) => $l !== ''));
144+ foreach ($lines as $i => $l) $d[$t][] = [$i, $l];
145+ return true;
146+ }
147+ if (preg_match('#^ap/notes/([^/]+)\.json$#', $rel, $m) && Stores::safeName($m[1])) {
148+ if (!is_array($json)) return false;
149+ $d['lb_ap_notes'][] = [$m[1], $text];
150+ return true;
151+ }
152+ return false;
153+ }
154+
155+ public static function encode(mixed $v): string {
156+ return (string)json_encode($v, Stores::JSON_FLAGS);
157+ }
158+
159+ public function tables(): array {
160+ $data = $this->load();
161+ $out = [];
162+ foreach (Stores::tables() as $name => $t) {
163+ if (!empty($data['stores'][$name])) $out[] = $t;
164+ }
165+ foreach ($data['generic'] as $rel => $abs) {
166+ $g = $this->generic($abs);
167+ if ($g === null) {
168+ $this->warnings[] = "読めないテーブルのファイル:{$rel}";
169+ continue;
170+ }
171+ $out[] = $g['table'];
172+ }
173+ return $out;
174+ }
175+
176+ /** data/db/{テーブル}.json */
177+ private function generic(string $abs): ?array {
178+ static $cache = [];
179+ if (isset($cache[$abs])) return $cache[$abs];
180+ $j = json_decode((string)@file_get_contents($abs), true);
181+ if (!is_array($j) || !isset($j['table'], $j['columns']) || !is_array($j['columns'])) return null;
182+ $t = ['name' => (string)$j['table'], 'columns' => $j['columns'], 'pk' => $j['pk'] ?? [], 'indexes' => $j['indexes'] ?? []];
183+ foreach ($t['columns'] as $i => $c) {
184+ if (isset($c['default']['value']) && is_array($c['default']['value'])) $t['columns'][$i]['default']['value'] = self::decodeValue($c['default']['value']);
185+ }
186+ return $cache[$abs] = ['table' => $t, 'rows' => $j['rows'] ?? []];
187+ }
188+
189+ /** JSONの値({"$blob": base64}、{"$float": "NaN"}) */
190+ public static function decodeValue(mixed $v): mixed {
191+ if (is_array($v)) {
192+ if (isset($v['$blob'])) return new Blob((string)base64_decode((string)$v['$blob']));
193+ if (isset($v['$float'])) return match ($v['$float']) { 'NaN' => NAN, 'Infinity' => INF, default => -INF };
194+ return json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
195+ }
196+ return $v;
197+ }
198+
199+ public function maxLengths(array $t): array {
200+ $out = [];
201+ $this->rows([strtolower($t['name'])], function(string $name, array $rows) use ($t, &$out): void {
202+ foreach ($t['columns'] as $i => $c) {
203+ foreach ($rows as $r) {
204+ $v = $r[$i] ?? null;
205+ $len = $v instanceof Blob ? strlen($v->data) : (is_string($v) ? mb_strlen($v, 'UTF-8') : 0);
206+ $out[$c['name']] = max($out[$c['name']] ?? 0, $len);
207+ }
208+ }
209+ });
210+ return $out;
211+ }
212+
213+ public function rows(array $tables, callable $sink): void {
214+ $data = $this->load();
215+ foreach ($data['stores'] as $name => $rows) {
216+ if (!in_array($name, $tables, true) || empty($rows)) continue;
217+ foreach (array_chunk($rows, 500) as $chunk) $sink($name, $chunk);
218+ }
219+ foreach ($data['generic'] as $abs) {
220+ $g = $this->generic($abs);
221+ if ($g === null || !in_array(strtolower($g['table']['name']), $tables, true)) continue;
222+ foreach (array_chunk($g['rows'], 500) as $chunk) {
223+ $sink($g['table']['name'], array_map(fn($r) => array_map([self::class, 'decodeValue'], (array)$r), $chunk));
224+ }
225+ }
226+ }
227+}
@@ -0,0 +1,235 @@
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\Convert;
39+
40+use Std\Lib\Db\DbException;
41+use Std\Lib\Db\Sqlite\Blob;
42+
43+/**
44+ * LittleBeastのJSONのデータ(data/)に書く(Storesを参照)
45+ *
46+ * lb_のテーブルは元のファイルにし、他のテーブルはdata/db/{テーブル}.jsonにする。
47+ * 書く先のディレクトリが空でない場合は、$overwriteが必要(同じ名前のファイルは置き換え、他のファイルはそのまま)。
48+ */
49+final class JsonTarget implements Target {
50+ private array $warnings = [];
51+ private array $buf = [];
52+ private array $open = [];
53+ private array $first = [];
54+
55+ /**
56+ * @param string $dir データのディレクトリ
57+ * @param bool $overwrite 空でないディレクトリに書く
58+ */
59+ public function __construct(private string $dir, private bool $overwrite = false) {
60+ $this->dir = rtrim($dir, '/\\');
61+ }
62+
63+ public function warnings(): array {
64+ return array_values(array_unique($this->warnings));
65+ }
66+
67+ public function begin(bool $drop): void {
68+ if (is_dir($this->dir)) {
69+ $empty = count(array_diff(scandir($this->dir) ?: [], ['.', '..'])) === 0;
70+ if (!$empty && !$this->overwrite && !$drop) throw new DbException("ディレクトリが空ではありません:{$this->dir}", 0, 'HY000');
71+ } else if (!@mkdir($this->dir, 0755, true) && !is_dir($this->dir)) {
72+ throw new DbException("ディレクトリを作れません:{$this->dir}", 0, 'HY000');
73+ }
74+ }
75+
76+ private function path(string $rel): string {
77+ if (!Stores::safePath($rel)) throw new DbException("不正なパス:{$rel}", 0, 'HY000');
78+ $p = $this->dir.'/'.$rel;
79+ $d = dirname($p);
80+ if (!is_dir($d) && !@mkdir($d, 0755, true) && !is_dir($d)) throw new DbException("ディレクトリを作れません:{$d}", 0, 'HY000');
81+ return $p;
82+ }
83+
84+ private function put(string $rel, string $data): void {
85+ $p = $this->path($rel);
86+ $tmp = $p.'.'.bin2hex(random_bytes(4)).'.tmp';
87+ if (file_put_contents($tmp, $data) === false || !rename($tmp, $p)) {
88+ @unlink($tmp);
89+ throw new DbException("ファイルに書けません:{$p}", 0, 'HY000');
90+ }
91+ }
92+
93+ public function createTable(array $t): void {
94+ $l = strtolower($t['name']);
95+ if (isset(Stores::tables()[$l])) {
96+ $this->buf[$l] = [];
97+ return;
98+ }
99+ // 普通のテーブル:定義を書いて、行を追加していく
100+ $p = $this->path(Stores::tableFile($t['name']));
101+ $fh = @fopen($p.'.tmp', 'wb');
102+ if ($fh === false) throw new DbException("ファイルに書けません:{$p}", 0, 'HY000');
103+ $head = ['table' => $t['name'], 'columns' => array_map(fn($c) => array_filter($c, fn($v) => $v !== null && $v !== false) + ['name' => $c['name'], 'type' => $c['type']], $t['columns']),
104+ 'pk' => $t['pk'], 'indexes' => $t['indexes']];
105+ foreach ($head['columns'] as $i => $c) {
106+ if (isset($c['default']['value'])) $head['columns'][$i]['default']['value'] = self::jsonValue($c['default']['value']);
107+ }
108+ $json = json_encode($head, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
109+ fwrite($fh, substr($json, 0, -1).",\"rows\":[\n");
110+ $this->open[$l] = [$fh, $p];
111+ $this->first[$l] = true;
112+ }
113+
114+ /** JSONに入れる値(BLOBとNaN・無限) */
115+ public static function jsonValue(mixed $v): mixed {
116+ if ($v instanceof Blob) return ['$blob' => base64_encode($v->data)];
117+ if (is_float($v) && (is_nan($v) || is_infinite($v))) return ['$float' => is_nan($v) ? 'NaN' : ($v > 0 ? 'Infinity' : '-Infinity')];
118+ return $v;
119+ }
120+
121+ public function insertRows(array $t, array $rows): void {
122+ $l = strtolower($t['name']);
123+ if (isset($this->buf[$l])) {
124+ // 列の名前で(書く先で列の順番が違っても良い様に)
125+ foreach ($rows as $r) {
126+ $row = [];
127+ foreach ($t['columns'] as $i => $c) $row[strtolower($c['name'])] = $r[$i];
128+ $this->buf[$l][] = $row;
129+ }
130+ return;
131+ }
132+ [$fh] = $this->open[$l];
133+ $out = '';
134+ foreach ($rows as $r) {
135+ $line = json_encode(array_map([self::class, 'jsonValue'], $r), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_INVALID_UTF8_SUBSTITUTE);
136+ $out .= ($this->first[$l] ? '' : ",\n").$line;
137+ $this->first[$l] = false;
138+ }
139+ fwrite($fh, $out);
140+ }
141+
142+ public function finishTable(array $t): void {
143+ $l = strtolower($t['name']);
144+ if (!isset($this->open[$l])) return;
145+ [$fh, $p] = $this->open[$l];
146+ fwrite($fh, "\n]}\n");
147+ fclose($fh);
148+ rename($p.'.tmp', $p);
149+ unset($this->open[$l]);
150+ }
151+
152+ public function end(): void {
153+ $b = $this->buf;
154+ $col = fn(array $r, string $k) => $r[$k] ?? null;
155+ $decode = function(mixed $v, string $what) {
156+ if (is_array($v) || $v === null) return $v;
157+ $d = json_decode((string)$v, true);
158+ if (json_last_error() !== JSON_ERROR_NONE) throw new DbException("不正なJSON:{$what}", 0, 'HY000');
159+ return $d;
160+ };
161+ $seqSort = fn(array &$rows) => usort($rows, fn($a, $b) => (int)$a['seq'] <=> (int)$b['seq']);
162+
163+ foreach ($b['lb_users'] ?? [] as $r) {
164+ $name = (string)$col($r, 'username');
165+ if (!preg_match('/^[A-Za-z0-9]+$/', $name) || !is_numeric($col($r, 'id'))) throw new DbException("不正なユーザー:{$name}", 0, 'HY000');
166+ $decode($r['data'], "lb_users.{$name}");
167+ $this->put('user/'.(int)$r['id'].'.'.$name.'.json', (string)$r['data']);
168+ }
169+
170+ $threads = [];
171+ foreach ($b['lb_comment_threads'] ?? [] as $r) $threads[(string)$r['thread']] = $decode($r['data'], 'lb_comment_threads') ?: [];
172+ $comments = [];
173+ foreach ($b['lb_comments'] ?? [] as $r) $comments[(string)$r['thread']][] = $r;
174+ foreach ($comments as $thread => $list) $threads[$thread] ??= [];
175+ foreach ($threads as $thread => $doc) {
176+ if (!Stores::safeThread($thread)) throw new DbException("不正なスレッド:{$thread}", 0, 'HY000');
177+ $list = $comments[$thread] ?? [];
178+ $seqSort($list);
179+ $doc = is_array($doc) ? $doc : [];
180+ $doc['comments'] = [];
181+ foreach ($list as $r) $doc['comments'][(string)$r['id']] = $decode($r['data'], "lb_comments.{$thread}");
182+ $this->put("comments/{$thread}.json", (string)json_encode($doc, Stores::JSON_FLAGS));
183+ }
184+
185+ if (!empty($b['lb_comment_ap'])) {
186+ $map = [];
187+ foreach ($b['lb_comment_ap'] as $r) $map[(string)$r['ap_id']] = $decode($r['data'], 'lb_comment_ap');
188+ $this->put('comments/ap.json', (string)json_encode($map, Stores::JSON_FLAGS));
189+ }
190+
191+ $notes = [];
192+ foreach ($b['lb_notifications'] ?? [] as $r) $notes[(string)$r['username']][] = $r;
193+ foreach ($notes as $user => $list) {
194+ if (!Stores::safeName($user)) throw new DbException("不正なユーザー:{$user}", 0, 'HY000');
195+ $seqSort($list);
196+ $this->put("notifications/{$user}.json", (string)json_encode(array_map(fn($r) => $decode($r['data'], 'lb_notifications'), $list), Stores::JSON_FLAGS));
197+ }
198+
199+ if (isset($b['lb_cron'])) {
200+ $list = $b['lb_cron'];
201+ $seqSort($list);
202+ $this->put('cron/cron.json', (string)json_encode(array_map(fn($r) => $decode($r['data'], 'lb_cron'), $list), Stores::JSON_FLAGS));
203+ }
204+
205+ foreach (['lb_followers' => 'followers.txt', 'lb_following' => 'following.txt'] as $t => $file) {
206+ if (!isset($b[$t])) continue;
207+ $list = $b[$t];
208+ $seqSort($list);
209+ $lines = array_values(array_filter(array_map(fn($r) => trim((string)$r['actor']), $list), fn($l) => $l !== ''));
210+ $this->put($file, empty($lines) ? '' : implode("\n", $lines)."\n");
211+ }
212+
213+ foreach ($b['lb_ap_notes'] ?? [] as $r) {
214+ $id = (string)$r['id'];
215+ if (!Stores::safeName($id)) throw new DbException("不正なID:{$id}", 0, 'HY000');
216+ $decode($r['data'], "lb_ap_notes.{$id}");
217+ $this->put("ap/notes/{$id}.json", (string)$r['data']);
218+ }
219+
220+ foreach ($b['lb_files'] ?? [] as $r) {
221+ $c = $r['content'];
222+ $this->put((string)$r['path'], $c instanceof Blob ? $c->data : (string)$c);
223+ }
224+ $this->buf = [];
225+ }
226+
227+ public function abort(): void {
228+ foreach ($this->open as [$fh, $p]) {
229+ @fclose($fh);
230+ @unlink($p.'.tmp');
231+ }
232+ $this->open = [];
233+ $this->buf = [];
234+ }
235+}
@@ -0,0 +1,61 @@
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\Convert;
39+
40+/**
41+ * 変換の元(Std\Lib\Db\Convertを参照)
42+ */
43+interface Source {
44+ /**
45+ * テーブルの定義
46+ *
47+ * @return array テーブルの配列(name、columns、pk、indexes)
48+ */
49+ public function tables(): array;
50+
51+ /**
52+ * 行を読む(テーブルの順番は元が決める)
53+ *
54+ * @param array $tables 読むテーブル(小文字の名前)
55+ * @param callable $sink fn(string $table, array $rows):行は列の順番の値の配列
56+ */
57+ public function rows(array $tables, callable $sink): void;
58+
59+ /** 変換できなかった物等 */
60+ public function warnings(): array;
61+}
@@ -0,0 +1,631 @@
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\Convert;
39+
40+use Std\Lib\Db\Convert;
41+use Std\Lib\Db\DbException;
42+use Std\Lib\Db\Sqlite\Blob;
43+
44+/**
45+ * 変換用のSQLの解析(MySQL・PostgreSQL・SQLiteの方言、ダンプの文の分け方、CREATE TABLE、INSERTの値)
46+ */
47+final class Sql {
48+ // 列の定義で型の後に来る言葉
49+ private const COLUMN_WORDS = ['NOT', 'NULL', 'DEFAULT', 'PRIMARY', 'UNIQUE', 'AUTO_INCREMENT', 'AUTOINCREMENT', 'COLLATE', 'COMMENT',
50+ 'REFERENCES', 'CHECK', 'CONSTRAINT', 'GENERATED', 'ON', 'CHARSET', 'STORAGE', 'COLUMN_FORMAT', 'INVISIBLE', 'VISIBLE', 'AS',
51+ 'IDENTITY', 'KEY', 'SRID', 'ENCODE', 'COMPRESSION'];
52+
53+ ///////////////
54+ // 字句解析
55+
56+ /**
57+ * 1つの文をトークンにする
58+ *
59+ * @param string $sql
60+ * @param string $dialect mysql、pgsql、sqlite
61+ * @return array t(id、qid、str、num、blob、op、param)、v(値)、u(大文字、idのみ)
62+ */
63+ public static function tokens(string $sql, string $dialect): array {
64+ $out = [];
65+ $len = strlen($sql);
66+ $i = 0;
67+ while ($i < $len) {
68+ $c = $sql[$i];
69+ if (ctype_space($c)) {
70+ $i++;
71+ continue;
72+ }
73+ if ($c === '-' && ($sql[$i + 1] ?? '') === '-' || ($c === '#' && $dialect === 'mysql')) {
74+ $j = strpos($sql, "\n", $i);
75+ $i = $j === false ? $len : $j + 1;
76+ continue;
77+ }
78+ if ($c === '/' && ($sql[$i + 1] ?? '') === '*') {
79+ $j = strpos($sql, '*/', $i + 2);
80+ $i = $j === false ? $len : $j + 2;
81+ continue;
82+ }
83+ // E'…'、X'…'、B'…'、N'…'、_binary'…'
84+ if (($c === "'" || (ctype_alpha($c) && ($sql[$i + 1] ?? '') === "'"))) {
85+ $prefix = $c === "'" ? '' : strtoupper($c);
86+ if ($prefix !== '') $i++;
87+ $backslash = $dialect === 'mysql' || $prefix === 'E';
88+ [$v, $i] = self::readQuoted($sql, $i, "'", $backslash);
89+ if ($prefix === 'X') {
90+ $out[] = ['t' => 'blob', 'v' => (string)hex2bin(strlen($v) % 2 ? '0'.$v : $v)];
91+ } else if ($prefix === 'B') {
92+ $out[] = ['t' => 'num', 'v' => bindec($v)];
93+ } else {
94+ $out[] = ['t' => 'str', 'v' => $v];
95+ }
96+ continue;
97+ }
98+ if ($c === '"') {
99+ [$v, $i] = self::readQuoted($sql, $i, '"', $dialect === 'mysql');
100+ // MySQLの「"」は文字列(ANSI_QUOTESでない場合)
101+ $out[] = $dialect === 'mysql' ? ['t' => 'str', 'v' => $v] : ['t' => 'qid', 'v' => $v];
102+ continue;
103+ }
104+ if ($c === '`') {
105+ [$v, $i] = self::readQuoted($sql, $i, '`', false);
106+ $out[] = ['t' => 'qid', 'v' => $v];
107+ continue;
108+ }
109+ if ($c === '[' && $dialect === 'sqlite') {
110+ $j = strpos($sql, ']', $i);
111+ $out[] = ['t' => 'qid', 'v' => substr($sql, $i + 1, ($j === false ? $len : $j) - $i - 1)];
112+ $i = $j === false ? $len : $j + 1;
113+ continue;
114+ }
115+ if ($c === '$' && $dialect === 'pgsql' && preg_match('/\G\$([A-Za-z_][A-Za-z0-9_]*)?\$/', $sql, $m, 0, $i)) {
116+ $end = strpos($sql, $m[0], $i + strlen($m[0]));
117+ $body = $end === false ? substr($sql, $i + strlen($m[0])) : substr($sql, $i + strlen($m[0]), $end - $i - strlen($m[0]));
118+ $out[] = ['t' => 'str', 'v' => $body];
119+ $i = $end === false ? $len : $end + strlen($m[0]);
120+ continue;
121+ }
122+ if ($c === '0' && ($sql[$i + 1] ?? '') !== '' && strtolower($sql[$i + 1]) === 'x' && preg_match('/\G0[xX]([0-9a-fA-F]*)/', $sql, $m, 0, $i)) {
123+ $i += strlen($m[0]);
124+ $hex = $m[1];
125+ $out[] = ['t' => 'blob', 'v' => (string)hex2bin(strlen($hex) % 2 ? '0'.$hex : $hex)];
126+ continue;
127+ }
128+ if (ctype_digit($c) || ($c === '.' && ctype_digit($sql[$i + 1] ?? ''))) {
129+ preg_match('/\G(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/', $sql, $m, 0, $i);
130+ $i += strlen($m[0]);
131+ $t = $m[0];
132+ $v = ctype_digit($t) ? ((string)(int)ltrim($t, '0') === ltrim($t, '0') || ltrim($t, '0') === '' ? (int)$t : $t) : (float)$t;
133+ $out[] = ['t' => 'num', 'v' => $v, 'raw' => $t];
134+ continue;
135+ }
136+ if (ctype_alpha($c) || $c === '_' || ord($c) >= 0x80) {
137+ preg_match('/\G[A-Za-z0-9_$\x80-\xFF]+/', $sql, $m, 0, $i);
138+ $i += strlen($m[0]);
139+ // MySQLの文字セットの前置き(_binary 'x'、_utf8mb4'x')
140+ if ($m[0][0] === '_' && $dialect === 'mysql' && preg_match('/\G\s*\'/', $sql, $mm, 0, $i)) {
141+ $i += strlen($mm[0]) - 1;
142+ [$v, $i] = self::readQuoted($sql, $i, "'", true);
143+ $out[] = strtolower($m[0]) === '_binary' ? ['t' => 'blob', 'v' => $v] : ['t' => 'str', 'v' => $v];
144+ continue;
145+ }
146+ $out[] = ['t' => 'id', 'v' => $m[0], 'u' => strtoupper($m[0])];
147+ continue;
148+ }
149+ foreach (['::', '<>', '!=', '<=', '>=', '||', '->>', '->'] as $op) {
150+ if (substr($sql, $i, strlen($op)) === $op) {
151+ $out[] = ['t' => 'op', 'v' => $op];
152+ $i += strlen($op);
153+ continue 2;
154+ }
155+ }
156+ $out[] = ['t' => 'op', 'v' => $c];
157+ $i++;
158+ }
159+ return $out;
160+ }
161+
162+ /** 引用符の中($iは開く引用符の位置) */
163+ private static function readQuoted(string $s, int $i, string $q, bool $backslash): array {
164+ $len = strlen($s);
165+ $v = '';
166+ $i++;
167+ while ($i < $len) {
168+ $c = $s[$i];
169+ if ($backslash && $c === '\\') {
170+ $n = $s[$i + 1] ?? '';
171+ $v .= match ($n) { '0' => "\0", 'n' => "\n", 'r' => "\r", 't' => "\t", 'b' => "\x08", 'Z' => "\x1A", default => $n };
172+ $i += 2;
173+ continue;
174+ }
175+ if ($c === $q) {
176+ if (($s[$i + 1] ?? '') === $q) {
177+ $v .= $q;
178+ $i += 2;
179+ continue;
180+ }
181+ return [$v, $i + 1];
182+ }
183+ $j = strcspn($s, $q.($backslash ? '\\' : ''), $i);
184+ $v .= substr($s, $i, $j);
185+ $i += $j;
186+ }
187+ return [$v, $len];
188+ }
189+
190+ ///////////////
191+ // 解析
192+
193+ /** トークンの名前(スキーマ付きは最後の部分) */
194+ public static function name(array $tok, int &$p): string {
195+ $t = $tok[$p] ?? null;
196+ if ($t === null || !in_array($t['t'], ['id', 'qid', 'str'], true)) throw new DbException('SQLの解析:名前が必要です', 1, 'HY000');
197+ $p++;
198+ $n = $t['v'];
199+ while (($tok[$p]['t'] ?? '') === 'op' && $tok[$p]['v'] === '.' && in_array($tok[$p + 1]['t'] ?? '', ['id', 'qid'], true)) {
200+ $n = $tok[$p + 1]['v'];
201+ $p += 2;
202+ }
203+ return $n;
204+ }
205+
206+ private static function is(array $tok, int $p, string ...$words): bool {
207+ return ($tok[$p]['t'] ?? '') === 'id' && in_array($tok[$p]['u'], $words, true);
208+ }
209+
210+ private static function isOp(array $tok, int $p, string $op): bool {
211+ return ($tok[$p]['t'] ?? '') === 'op' && $tok[$p]['v'] === $op;
212+ }
213+
214+ /** 対応する閉じ括弧まで($pは開き括弧) */
215+ private static function skipParens(array $tok, int $p): int {
216+ $depth = 0;
217+ for ($n = count($tok); $p < $n; $p++) {
218+ if (self::isOp($tok, $p, '(')) $depth++;
219+ else if (self::isOp($tok, $p, ')') && --$depth === 0) return $p + 1;
220+ }
221+ return $p;
222+ }
223+
224+ /** トークンを元のSQLに近い形に戻す(DEFAULTの式・型用) */
225+ public static function text(array $toks): string {
226+ $out = '';
227+ foreach ($toks as $t) {
228+ $s = match ($t['t']) {
229+ 'str' => "'".str_replace("'", "''", $t['v'])."'",
230+ 'qid' => '"'.$t['v'].'"',
231+ 'blob' => "X'".bin2hex($t['v'])."'",
232+ 'num' => (string)($t['raw'] ?? $t['v']),
233+ default => $t['v'],
234+ };
235+ if ($out !== '' && !in_array($s, ['(', ')', ',', '::', '[', ']'], true) && !str_ends_with($out, '(') && !str_ends_with($out, '::')) $out .= ' ';
236+ $out .= $s;
237+ }
238+ return $out;
239+ }
240+
241+ /** (列, 列(長さ) ASC, …) の列の名前 */
242+ public static function columnList(array $tok, int &$p): array {
243+ if (!self::isOp($tok, $p, '(')) throw new DbException('SQLの解析:(が必要です', 1, 'HY000');
244+ $end = self::skipParens($tok, $p);
245+ // 要素毎のトークン
246+ $items = [[]];
247+ $depth = 0;
248+ for ($p++; $p < $end - 1; $p++) {
249+ if (self::isOp($tok, $p, '(')) $depth++;
250+ else if (self::isOp($tok, $p, ')')) $depth--;
251+ else if ($depth === 0 && self::isOp($tok, $p, ',')) {
252+ $items[] = [];
253+ continue;
254+ }
255+ $items[count($items) - 1][] = $tok[$p];
256+ }
257+ $cols = [];
258+ foreach ($items as $it) {
259+ // 列の名前の後は、MySQLの(長さ)、ASC・DESC・COLLATE・演算子クラス等だけなら列、他は式(null)
260+ $ok = !empty($it) && in_array($it[0]['t'], ['id', 'qid'], true);
261+ if ($ok && isset($it[1])) {
262+ if ($it[1]['t'] === 'op' && $it[1]['v'] === '(') $ok = ($it[2]['t'] ?? '') === 'num' && ($it[3]['v'] ?? '') === ')';
263+ else $ok = in_array($it[1]['t'], ['id', 'qid'], true);
264+ }
265+ $cols[] = $ok ? $it[0]['v'] : null;
266+ }
267+ $p = $end;
268+ return $cols;
269+ }
270+
271+ /**
272+ * CREATE TABLEを解析する
273+ *
274+ * @return array|null テーブル(Convertの形)、CREATE TABLEでなければnull
275+ */
276+ public static function createTable(string $sql, string $dialect, array &$warnings = []): ?array {
277+ $tok = self::tokens($sql, $dialect);
278+ $p = 0;
279+ if (!self::is($tok, $p++, 'CREATE')) return null;
280+ while (self::is($tok, $p, 'TEMP', 'TEMPORARY', 'UNLOGGED', 'GLOBAL', 'LOCAL', 'OR', 'REPLACE')) $p++;
281+ if (!self::is($tok, $p++, 'TABLE')) return null;
282+ if (self::is($tok, $p, 'IF')) $p += 3;
283+ $name = self::name($tok, $p);
284+ if (!self::isOp($tok, $p, '(')) return null;
285+ $end = self::skipParens($tok, $p);
286+ $t = ['name' => $name, 'columns' => [], 'pk' => [], 'indexes' => [], 'collate' => null];
287+ // 項目に分ける
288+ $items = [];
289+ $cur = [];
290+ $depth = 0;
291+ for ($k = $p + 1; $k < $end - 1; $k++) {
292+ if (self::isOp($tok, $k, '(')) $depth++;
293+ if (self::isOp($tok, $k, ')')) $depth--;
294+ if ($depth === 0 && self::isOp($tok, $k, ',')) {
295+ $items[] = $cur;
296+ $cur = [];
297+ continue;
298+ }
299+ $cur[] = $tok[$k];
300+ }
301+ if ($cur) $items[] = $cur;
302+ foreach ($items as $it) self::tableItem($t, $it, $dialect, $warnings);
303+ // テーブルのオプション(MySQL)
304+ for ($k = $end; $k < count($tok); $k++) {
305+ if (self::is($tok, $k, 'COLLATE') && isset($tok[$k + 1])) {
306+ $j = self::isOp($tok, $k + 1, '=') ? $k + 2 : $k + 1;
307+ $t['collate'] = $tok[$j]['v'] ?? null;
308+ }
309+ if (self::is($tok, $k, 'WITHOUT')) $warnings[] = "WITHOUT ROWIDは普通のテーブルにします:{$name}";
310+ }
311+ if ($dialect !== 'mysql') unset($t['collate']);
312+ return $t;
313+ }
314+
315+ /** テーブルの定義の1つの項目(列又は制約) */
316+ private static function tableItem(array &$t, array $it, string $dialect, array &$warnings): void {
317+ $p = 0;
318+ if (self::is($it, 0, 'CONSTRAINT')) $p = 2;
319+ $cname = self::is($it, 0, 'CONSTRAINT') ? (string)($it[1]['v'] ?? '') : null;
320+ if (self::is($it, $p, 'PRIMARY') && self::is($it, $p + 1, 'KEY')) {
321+ $p += 2;
322+ while (!self::isOp($it, $p, '(') && $p < count($it)) $p++;
323+ $t['pk'] = array_values(array_filter(self::columnList($it, $p)));
324+ return;
325+ }
326+ if (self::is($it, $p, 'UNIQUE', 'KEY', 'INDEX') && !self::isOp($it, $p + 1, '.')) {
327+ $unique = self::is($it, $p, 'UNIQUE');
328+ $p++;
329+ if (self::is($it, $p, 'KEY', 'INDEX')) $p++;
330+ $iname = $cname;
331+ if (!self::isOp($it, $p, '(') && $p < count($it)) $iname = (string)$it[$p++]['v'];
332+ while (!self::isOp($it, $p, '(') && $p < count($it)) $p++;
333+ $cols = self::columnList($it, $p);
334+ if (in_array(null, $cols, true)) {
335+ $warnings[] = "式のインデックスは変換しません:{$t['name']}";
336+ return;
337+ }
338+ $t['indexes'][] = ['name' => $iname ?? '', 'columns' => $cols, 'unique' => $unique];
339+ return;
340+ }
341+ if (self::is($it, $p, 'FULLTEXT', 'SPATIAL')) {
342+ $warnings[] = "全文・空間のインデックスは変換しません:{$t['name']}";
343+ return;
344+ }
345+ if (self::is($it, $p, 'FOREIGN', 'CHECK', 'EXCLUDE') || ($cname !== null)) {
346+ if (self::is($it, $p, 'FOREIGN')) $warnings[] = "外部キーは変換しません:{$t['name']}";
347+ return;
348+ }
349+ if (self::is($it, $p, 'PERIOD', 'LIKE')) return;
350+ $col = self::columnDef($it, $dialect, $t['name'], $warnings);
351+ if ($col === null) return;
352+ if (!empty($col['pk'])) $t['pk'] = [$col['name']];
353+ if (!empty($col['unique'])) $t['indexes'][] = ['name' => '', 'columns' => [$col['name']], 'unique' => true];
354+ unset($col['pk'], $col['unique']);
355+ $t['columns'][] = $col;
356+ }
357+
358+ /**
359+ * 列の定義
360+ *
361+ * @return array|null Convertの列(pk・uniqueも)
362+ */
363+ public static function columnDef(array $it, string $dialect, string $table, array &$warnings): ?array {
364+ $p = 0;
365+ if (!in_array($it[0]['t'] ?? '', ['id', 'qid', 'str'], true)) return null;
366+ $name = $it[$p++]['v'];
367+ $typeToks = [];
368+ $n = count($it);
369+ while ($p < $n) {
370+ $tk = $it[$p];
371+ if ($tk['t'] === 'id' && in_array($tk['u'], self::COLUMN_WORDS, true)) break;
372+ if ($tk['t'] === 'id' && $tk['u'] === 'CHARACTER' && self::is($it, $p + 1, 'SET')) break;
373+ if (self::isOp($it, $p, '(')) {
374+ $e = self::skipParens($it, $p);
375+ $typeToks = array_merge($typeToks, array_slice($it, $p, $e - $p));
376+ $p = $e;
377+ continue;
378+ }
379+ $typeToks[] = $tk;
380+ $p++;
381+ }
382+ $typeText = self::text($typeToks);
383+ $col = Convert::parseType($typeText, $dialect) + ['name' => $name, 'notNull' => false, 'default' => null, 'autoinc' => false, 'collate' => null];
384+ if (in_array(strtolower(preg_replace('/\(.*$/', '', $typeText)), ['serial', 'bigserial', 'smallserial', 'serial4', 'serial8', 'serial2'], true)) {
385+ $col['autoinc'] = true;
386+ $col['notNull'] = true;
387+ }
388+ while ($p < $n) {
389+ if (self::is($it, $p, 'NOT') && self::is($it, $p + 1, 'NULL')) {
390+ $col['notNull'] = true;
391+ $p += 2;
392+ } else if (self::is($it, $p, 'NULL')) {
393+ $p++;
394+ } else if (self::is($it, $p, 'PRIMARY')) {
395+ $col['pk'] = true;
396+ $p += 2;
397+ if (self::is($it, $p, 'ASC', 'DESC')) $p++;
398+ } else if (self::is($it, $p, 'UNIQUE')) {
399+ $col['unique'] = true;
400+ $p++;
401+ if (self::is($it, $p, 'KEY')) $p++;
402+ } else if (self::is($it, $p, 'AUTO_INCREMENT', 'AUTOINCREMENT')) {
403+ $col['autoinc'] = true;
404+ $p++;
405+ } else if (self::is($it, $p, 'DEFAULT')) {
406+ $p++;
407+ $s = $p;
408+ if (self::isOp($it, $p, '(')) {
409+ $p = self::skipParens($it, $p);
410+ } else {
411+ while ($p < $n && !($it[$p]['t'] === 'id' && in_array($it[$p]['u'], ['NOT', 'NULL', 'PRIMARY', 'UNIQUE', 'AUTO_INCREMENT', 'COLLATE', 'COMMENT', 'REFERENCES', 'CHECK', 'CONSTRAINT', 'ON', 'GENERATED', 'CHARACTER'], true) && $p > $s)) {
412+ if (self::isOp($it, $p, '(')) {
413+ $p = self::skipParens($it, $p);
414+ continue;
415+ }
416+ $p++;
417+ }
418+ }
419+ $expr = self::text(array_slice($it, $s, $p - $s));
420+ $d = Convert::parseDefault($expr);
421+ if ($d !== null && isset($d['seq'])) {
422+ $col['autoinc'] = true;
423+ } else if ($d === null) {
424+ $warnings[] = "DEFAULTを変換できません:{$table}.{$name}({$expr})";
425+ } else {
426+ $col['default'] = $d;
427+ }
428+ } else if (self::is($it, $p, 'GENERATED')) {
429+ // GENERATED … AS IDENTITY(自動の番号)、GENERATED ALWAYS AS (式)(生成列)
430+ $k = $p;
431+ while ($k < $n && !self::is($it, $k, 'IDENTITY') && !self::isOp($it, $k, '(')) $k++;
432+ if (self::is($it, $k, 'IDENTITY')) {
433+ $col['autoinc'] = true;
434+ $col['notNull'] = true;
435+ $p = $k + 1;
436+ if (self::isOp($it, $p, '(')) $p = self::skipParens($it, $p);
437+ } else {
438+ $warnings[] = "生成列は普通の列にします:{$table}.{$name}";
439+ $p = $k < $n ? self::skipParens($it, $k) : $n;
440+ }
441+ } else if (self::is($it, $p, 'AS') && self::isOp($it, $p + 1, '(')) {
442+ $warnings[] = "生成列は普通の列にします:{$table}.{$name}";
443+ $p = self::skipParens($it, $p + 1);
444+ } else if (self::is($it, $p, 'COLLATE')) {
445+ $col['collate'] = $dialect === 'mysql' ? (string)($it[$p + 1]['v'] ?? '') : null;
446+ $p += 2;
447+ } else if (self::is($it, $p, 'CHARACTER') && self::is($it, $p + 1, 'SET')) {
448+ $p += 3;
449+ } else if (self::is($it, $p, 'CHARSET', 'COMMENT', 'STORAGE', 'COLUMN_FORMAT', 'SRID', 'ENCODE', 'COMPRESSION')) {
450+ $p += 2;
451+ } else if (self::is($it, $p, 'ON') && self::is($it, $p + 1, 'UPDATE')) {
452+ $p += 3;
453+ if (self::isOp($it, $p, '(')) $p = self::skipParens($it, $p);
454+ } else if (self::is($it, $p, 'REFERENCES')) {
455+ $p += 2;
456+ if (self::isOp($it, $p, '(')) $p = self::skipParens($it, $p);
457+ while ($p < $n && self::is($it, $p, 'ON', 'MATCH', 'DEFERRABLE', 'INITIALLY', 'NOT', 'CASCADE', 'RESTRICT', 'SET', 'NO', 'ACTION', 'DELETE', 'UPDATE', 'NULL', 'FULL', 'SIMPLE', 'PARTIAL', 'DEFERRED', 'IMMEDIATE')) $p++;
458+ } else if (self::is($it, $p, 'CHECK')) {
459+ $p = self::skipParens($it, $p + 1);
460+ } else if (self::is($it, $p, 'CONSTRAINT')) {
461+ $p += 2;
462+ } else {
463+ $p++;
464+ }
465+ }
466+ return $col;
467+ }
468+
469+ ///////////////
470+ // 値
471+
472+ /**
473+ * INSERT INTO t [(列)] VALUES (…), (…)
474+ *
475+ * @return array|null table、columns(null=全部)、rows(PHPの値の配列の配列)
476+ */
477+ public static function insert(string $sql, string $dialect): ?array {
478+ $tok = self::tokens($sql, $dialect);
479+ $p = 0;
480+ if (self::is($tok, 0, 'REPLACE')) $p = 1;
481+ else if (self::is($tok, 0, 'INSERT')) {
482+ $p = 1;
483+ while (self::is($tok, $p, 'IGNORE', 'OR', 'REPLACE', 'LOW_PRIORITY', 'DELAYED', 'HIGH_PRIORITY', 'ABORT', 'FAIL', 'ROLLBACK')) $p++;
484+ } else {
485+ return null;
486+ }
487+ if (!self::is($tok, $p++, 'INTO')) return null;
488+ $table = self::name($tok, $p);
489+ $cols = null;
490+ if (self::isOp($tok, $p, '(')) $cols = self::columnList($tok, $p);
491+ if (!self::is($tok, $p, 'VALUES', 'VALUE')) return ['table' => $table, 'columns' => $cols, 'rows' => [], 'select' => true];
492+ $p++;
493+ $rows = [];
494+ $n = count($tok);
495+ while ($p < $n && self::isOp($tok, $p, '(')) {
496+ $end = self::skipParens($tok, $p);
497+ $row = [];
498+ $val = [];
499+ $depth = 0;
500+ for ($k = $p + 1; $k < $end - 1; $k++) {
501+ if (self::isOp($tok, $k, '(')) $depth++;
502+ if (self::isOp($tok, $k, ')')) $depth--;
503+ if ($depth === 0 && self::isOp($tok, $k, ',')) {
504+ $row[] = self::value($val);
505+ $val = [];
506+ continue;
507+ }
508+ $val[] = $tok[$k];
509+ }
510+ if ($val || $end - $p > 2) $row[] = self::value($val);
511+ $rows[] = $row;
512+ $p = $end;
513+ if (self::isOp($tok, $p, ',')) $p++;
514+ }
515+ return ['table' => $table, 'columns' => $cols, 'rows' => $rows];
516+ }
517+
518+ /** 値のトークン(リテラル、負の数、キャスト、一部の関数) */
519+ public static function value(array $v): mixed {
520+ // キャスト('x'::bytea、CAST(… AS …))を除く
521+ $cast = null;
522+ $k = count($v);
523+ while ($k >= 3 && $v[$k - 2]['t'] === 'op' && $v[$k - 2]['v'] === '::') {
524+ $cast = strtolower((string)$v[$k - 1]['v']);
525+ $v = array_slice($v, 0, $k - 2);
526+ $k = count($v);
527+ }
528+ if ($k >= 3 && $v[$k - 1]['t'] === 'op' && $v[$k - 1]['v'] === ']' && $v[$k - 2]['t'] === 'op' && $v[$k - 2]['v'] === '[') {
529+ $v = array_slice($v, 0, $k - 2);
530+ $k -= 2;
531+ }
532+ if ($k === 0) return null;
533+ if ($k === 1) {
534+ $t = $v[0];
535+ switch ($t['t']) {
536+ case 'num':
537+ return $t['v'];
538+ case 'blob':
539+ return new Blob($t['v']);
540+ case 'str':
541+ if ($cast === 'bytea' && str_starts_with($t['v'], '\\x')) return new Blob((string)hex2bin(substr($t['v'], 2)));
542+ return $t['v'];
543+ case 'id':
544+ return match ($t['u']) { 'NULL' => null, 'TRUE' => true, 'FALSE' => false, 'DEFAULT' => null, default => $t['v'] };
545+ }
546+ }
547+ if ($k === 2 && $v[0]['t'] === 'op' && ($v[0]['v'] === '-' || $v[0]['v'] === '+') && $v[1]['t'] === 'num') {
548+ if ($v[0]['v'] === '+') return $v[1]['v'];
549+ $n = $v[1]['v'];
550+ if (is_string($n)) return $n === '9223372036854775808' ? PHP_INT_MIN : '-'.$n;
551+ return -$n;
552+ }
553+ // CAST(x AS type)
554+ if ($v[0]['t'] === 'id' && $v[0]['u'] === 'CAST' && ($v[1]['v'] ?? '') === '(') {
555+ $inner = [];
556+ for ($i = 2; $i < $k && !($v[$i]['t'] === 'id' && $v[$i]['u'] === 'AS'); $i++) $inner[] = $v[$i];
557+ return self::value($inner);
558+ }
559+ // sqlite3の.dumpの文字列(replace('…','\n',char(10))、unistr('…')、||)
560+ $parts = self::split($v, '||');
561+ if (count($parts) > 1) {
562+ $out = '';
563+ foreach ($parts as $part) {
564+ $x = self::value($part);
565+ if (!is_string($x) && !is_int($x)) return self::text($v);
566+ $out .= $x;
567+ }
568+ return $out;
569+ }
570+ if ($v[0]['t'] === 'id' && ($v[1]['v'] ?? '') === '(' && $v[$k - 1]['v'] === ')' && in_array($v[0]['u'], ['REPLACE', 'CHAR', 'UNISTR'], true)) {
571+ $args = array_map([self::class, 'value'], self::split(array_slice($v, 2, $k - 3), ','));
572+ foreach ($args as $a) if (!is_string($a) && !is_int($a)) return self::text($v);
573+ if ($v[0]['u'] === 'REPLACE' && count($args) === 3) return str_replace((string)$args[1], (string)$args[2], (string)$args[0]);
574+ if ($v[0]['u'] === 'CHAR') return implode('', array_map(fn($c) => (string)mb_chr((int)$c, 'UTF-8'), $args));
575+ if ($v[0]['u'] === 'UNISTR' && count($args) === 1) {
576+ return (string)preg_replace_callback('/\\\\\\\\|\\\\(?:u?([0-9A-Fa-f]{4})|\+([0-9A-Fa-f]{6})|U([0-9A-Fa-f]{8}))/',
577+ fn($m) => $m[0] === '\\\\' ? '\\' : (string)mb_chr((int)hexdec($m[1] ?: ($m[2] ?? '') ?: ($m[3] ?? '')), 'UTF-8'), (string)$args[0]);
578+ }
579+ }
580+ // 他の関数(now()等)は文字列のまま
581+ return self::text($v);
582+ }
583+
584+ /** トークンを括弧の外の演算子で分ける */
585+ private static function split(array $v, string $op): array {
586+ $out = [[]];
587+ $depth = 0;
588+ foreach ($v as $t) {
589+ if ($t['t'] === 'op' && $t['v'] === '(') $depth++;
590+ else if ($t['t'] === 'op' && $t['v'] === ')') $depth--;
591+ else if ($depth === 0 && $t['t'] === 'op' && $t['v'] === $op) {
592+ $out[] = [];
593+ continue;
594+ }
595+ $out[count($out) - 1][] = $t;
596+ }
597+ return $out;
598+ }
599+
600+ /**
601+ * COPYのデータの1行(タブ区切り、\N=NULL、\のエスケープ)
602+ *
603+ * @return array 文字列又はnull
604+ */
605+ public static function copyLine(string $line): array {
606+ $out = [];
607+ foreach (explode("\t", $line) as $f) {
608+ if ($f === '\\N') {
609+ $out[] = null;
610+ continue;
611+ }
612+ if (!str_contains($f, '\\')) {
613+ $out[] = $f;
614+ continue;
615+ }
616+ $out[] = preg_replace_callback('/\\\\(x[0-9a-fA-F]{1,2}|[0-7]{1,3}|.)/', function(array $m): string {
617+ $c = $m[1];
618+ if ($c[0] === 'x' && strlen($c) > 1) return chr(hexdec(substr($c, 1)));
619+ if (ctype_digit($c)) return chr(octdec($c) & 0xFF);
620+ return match ($c) { 'b' => "\x08", 'f' => "\f", 'n' => "\n", 'r' => "\r", 't' => "\t", 'v' => "\v", default => $c };
621+ }, $f);
622+ }
623+ return $out;
624+ }
625+
626+ /** COPYのデータの1行を作る */
627+ public static function copyField(mixed $v): string {
628+ if ($v === null) return '\\N';
629+ return strtr((string)$v, ["\\" => "\\\\", "\t" => "\\t", "\n" => "\\n", "\r" => "\\r"]);
630+ }
631+}
@@ -0,0 +1,102 @@
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\Convert;
39+
40+/**
41+ * LittleBeastのJSONのデータ(data/)とテーブルの対応
42+ *
43+ * lb_users data/user/{ID}.{ユーザー名}.json(dataにファイルの中身)
44+ * lb_comment_threads data/comments/{スレッド}.json の「comments」以外(title等)
45+ * lb_comments 同じファイルの「comments」(seqは順番)
46+ * lb_comment_ap data/comments/ap.json(Fediverseの返信のID => スレッドとコメント)
47+ * lb_notifications data/notifications/{ユーザー名}.json(seqは順番)
48+ * lb_cron data/cron/cron.json(seqは順番)
49+ * lb_followers data/followers.txt(ブログのアクターのフォロワー)
50+ * lb_following data/following.txt
51+ * lb_ap_notes data/ap/notes/{ID}.json(dataにファイルの中身)
52+ * lb_files その他のファイル(パスと中身、キャッシュ・ロック・一時ファイルを除く)
53+ * 他のテーブルはdata/db/{テーブル名}.jsonになる(定義と行)。
54+ */
55+final class Stores {
56+ public const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT;
57+ /** 変換しないディレクトリ・ファイル(data/からの相対パス) */
58+ public const SKIP = ['#^cache/#', '#^ratelimit/#', '#^db/#', '#\.tmp$#', '#\.lock$#', '#^cron/run\.lock$#', '#(^|/)\.#'];
59+
60+ /** テーブルの定義 */
61+ public static function tables(): array {
62+ $text = fn(string $n, bool $notNull = false, ?int $size = null) => ['name' => $n, 'type' => $size ? 'varchar' : 'text', 'size' => $size, 'notNull' => $notNull];
63+ $int = fn(string $n, bool $notNull = true) => ['name' => $n, 'type' => 'int', 'size' => 64, 'notNull' => $notNull];
64+ $json = fn(string $n = 'data') => ['name' => $n, 'type' => 'json', 'notNull' => true];
65+ return [
66+ 'lb_users' => ['name' => 'lb_users', 'columns' => [$int('id'), $text('username', true, 255), $text('email'), $int('role', false), $json()],
67+ 'pk' => ['id'], 'indexes' => [['name' => 'lb_users_username', 'columns' => ['username'], 'unique' => true]]],
68+ 'lb_comment_threads' => ['name' => 'lb_comment_threads', 'columns' => [$text('thread', true, 255), $json()], 'pk' => ['thread'], 'indexes' => []],
69+ 'lb_comments' => ['name' => 'lb_comments', 'columns' => [$text('thread', true, 255), $text('id', true, 64), $int('seq'), $json()],
70+ 'pk' => ['thread', 'id'], 'indexes' => []],
71+ 'lb_comment_ap' => ['name' => 'lb_comment_ap', 'columns' => [$text('ap_id', true, 700), $json()], 'pk' => ['ap_id'], 'indexes' => []],
72+ 'lb_notifications' => ['name' => 'lb_notifications', 'columns' => [$text('username', true, 255), $int('seq'), $json()], 'pk' => ['username', 'seq'], 'indexes' => []],
73+ 'lb_cron' => ['name' => 'lb_cron', 'columns' => [$int('seq'), $json()], 'pk' => ['seq'], 'indexes' => []],
74+ 'lb_followers' => ['name' => 'lb_followers', 'columns' => [$int('seq'), $text('actor', true)], 'pk' => ['seq'], 'indexes' => []],
75+ 'lb_following' => ['name' => 'lb_following', 'columns' => [$int('seq'), $text('actor', true)], 'pk' => ['seq'], 'indexes' => []],
76+ 'lb_ap_notes' => ['name' => 'lb_ap_notes', 'columns' => [$text('id', true, 255), $json()], 'pk' => ['id'], 'indexes' => []],
77+ 'lb_files' => ['name' => 'lb_files', 'columns' => [$text('path', true, 700), ['name' => 'content', 'type' => 'blob', 'notNull' => true]], 'pk' => ['path'], 'indexes' => []],
78+ ];
79+ }
80+
81+ /** データのディレクトリからの相対パスが安全か(「..」・絶対パス・制御文字が無い) */
82+ public static function safePath(string $rel): bool {
83+ if ($rel === '' || $rel[0] === '/' || str_contains($rel, '\\') || preg_match('/[\x00-\x1F]/', $rel) || preg_match('/^[A-Za-z]:/', $rel)) return false;
84+ foreach (explode('/', $rel) as $part) if ($part === '' || $part === '.' || $part === '..') return false;
85+ return true;
86+ }
87+
88+ /** 名前(ユーザー名等)が安全か */
89+ public static function safeName(string $n): bool {
90+ return (bool)preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/', $n) && !str_contains($n, '..');
91+ }
92+
93+ /** スレッドの名前(Comments::validThreadと同じ) */
94+ public static function safeThread(string $t): bool {
95+ return (bool)preg_match('#^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*){1,2}$#', $t);
96+ }
97+
98+ /** 普通のテーブルのファイル名 */
99+ public static function tableFile(string $name): string {
100+ return 'db/'.rawurlencode($name).'.json';
101+ }
102+}
@@ -0,0 +1,72 @@
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\Convert;
39+
40+/**
41+ * 変換の先(Std\Lib\Db\Convertを参照)
42+ */
43+interface Target {
44+ /**
45+ * 書き込みを始める
46+ *
47+ * @param bool $drop 同じ名前のテーブルが有れば消す(falseなら失敗にする)
48+ */
49+ public function begin(bool $drop): void;
50+
51+ /** テーブルを作る(インデックスはfinishTable()で) */
52+ public function createTable(array $t): void;
53+
54+ /**
55+ * 行を書く
56+ *
57+ * @param array $t テーブル
58+ * @param array $rows 共通の形の値の配列の配列
59+ */
60+ public function insertRows(array $t, array $rows): void;
61+
62+ /** テーブルの最後(インデックス、自動の番号の続き) */
63+ public function finishTable(array $t): void;
64+
65+ /** 書き込みを終える */
66+ public function end(): void;
67+
68+ /** 失敗した場合(トランザクションのロールバック等) */
69+ public function abort(): void;
70+
71+ public function warnings(): array;
72+}
@@ -138,6 +138,12 @@
138138 }
139139
140140 private function parse(int $pgno): Page {
141+ if ($pgno === 1 && $this->pager->nPages === 0) {
142+ // 空のファイル:sqlite_schemaは空
143+ $p = new Page(1);
144+ $p->type = self::TABLE_LEAF;
145+ return $p;
146+ }
141147 $raw = $this->pager->read($pgno);
142148 $h = $pgno === 1 ? 100 : 0;
143149 $p = new Page($pgno);
@@ -0,0 +1,5 @@
1+{
2+ "mysqldump": "-- MySQL dump 10.13 Distrib 8.0.36, for Linux (x86_64)\n--\n-- Host: localhost Database: shop\n-- ------------------------------------------------------\n-- Server version\t8.0.36\n\n/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n/*!40101 SET NAMES utf8mb4 */;\n/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;\n/*!40103 SET TIME_ZONE='+00:00' */;\n/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;\n\n--\n-- Table structure for table `customers`\n--\n\nDROP TABLE IF EXISTS `customers`;\n/*!40101 SET @saved_cs_client = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `customers` (\n `id` int unsigned NOT NULL AUTO_INCREMENT,\n `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,\n `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,\n `active` tinyint(1) NOT NULL DEFAULT '1',\n `level` enum('free','pro') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'free',\n `balance` decimal(12,2) DEFAULT '0.00',\n `photo` mediumblob,\n `settings` json DEFAULT NULL,\n `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` datetime(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),\n `note` text COLLATE utf8mb4_unicode_ci COMMENT 'メモ',\n PRIMARY KEY (`id`),\n UNIQUE KEY `customers_email_unique` (`email`),\n KEY `idx_name_level` (`name`(20),`level`),\n FULLTEXT KEY `ft_note` (`note`)\n) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `customers`\n--\n\nLOCK TABLES `customers` WRITE;\n/*!40000 ALTER TABLE `customers` DISABLE KEYS */;\nINSERT INTO `customers` VALUES (1,'諏訪子','suwako@example.com',1,'pro',1234.50,_binary '\\0\\\\\\'\\\"\\n\\r\\Z','{\\\"a\\\": [1, 2]}','2026-01-02 03:04:05','2026-01-02 03:04:05.123','it\\'s; a \\\"test\\\"\\\\ -- not a comment'),(2,'神奈子',NULL,0,'free',-0.01,0x00FF10,NULL,NULL,NULL,'行1\\n行2\\ttab'),(3,'O\\'Brien','ob@example.com',1,'free',0.00,NULL,'null','2026-11-12 00:00:00',NULL,'/* x */ # y');\n/*!40000 ALTER TABLE `customers` ENABLE KEYS */;\nUNLOCK TABLES;\n\nDROP TABLE IF EXISTS `orders`;\nCREATE TABLE `orders` (\n `id` bigint NOT NULL AUTO_INCREMENT,\n `customer_id` int unsigned NOT NULL,\n `total` double NOT NULL DEFAULT '0',\n `status` varchar(20) NOT NULL DEFAULT 'new',\n `ordered` date DEFAULT NULL,\n PRIMARY KEY (`id`),\n KEY `orders_customer_id_foreign` (`customer_id`),\n CONSTRAINT `orders_customer_id_foreign` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE\n) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n\nLOCK TABLES `orders` WRITE;\nINSERT INTO `orders` VALUES (1,1,99.5,'paid','2026-01-03'),(2,3,1e3,'new','0000-00-00');\nUNLOCK TABLES;\n\n--\n-- Temporary view structure for view `v_customers`\n--\n/*!50001 DROP VIEW IF EXISTS `v_customers`*/;\n/*!50001 CREATE VIEW `v_customers` AS SELECT 1 AS `id`*/;\n\nDELIMITER ;;\n/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg` BEFORE INSERT ON `orders` FOR EACH ROW BEGIN\n SET NEW.status = 'new';\nEND */;;\nDELIMITER ;\n\n/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;\n-- Dump completed on 2026-11-12 0:00:00\n",
3+ "pgdump": "--\n-- PostgreSQL database dump\n--\n\n\\restrict IGblw7AgTAaQWGRowGi8gEvwjav0A41Z4KbrP31pzIodfRyIadXg22o2ex4YurD\n\n-- Dumped from database version 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)\n-- Dumped by pg_dump version 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)\n\nSET statement_timeout = 0;\nSET lock_timeout = 0;\nSET idle_in_transaction_session_timeout = 0;\nSET client_encoding = 'UTF8';\nSET standard_conforming_strings = on;\nSELECT pg_catalog.set_config('search_path', '', false);\nSET check_function_bodies = false;\nSET xmloption = content;\nSET client_min_messages = warning;\nSET row_security = off;\n\nSET default_tablespace = '';\n\nSET default_table_access_method = heap;\n\n--\n-- Name: items; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.items (\n id integer NOT NULL,\n name character varying(50) NOT NULL,\n price numeric(8,2) DEFAULT 0,\n flags boolean DEFAULT false,\n data bytea,\n meta jsonb,\n labels text[],\n at timestamp with time zone DEFAULT now(),\n day date,\n uid uuid,\n note text\n);\n\n\n--\n-- Name: items_id_seq; Type: SEQUENCE; Schema: public; Owner: -\n--\n\nCREATE SEQUENCE public.items_id_seq\n AS integer\n START WITH 1\n INCREMENT BY 1\n NO MINVALUE\n NO MAXVALUE\n CACHE 1;\n\n\n--\n-- Name: items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -\n--\n\nALTER SEQUENCE public.items_id_seq OWNED BY public.items.id;\n\n\n--\n-- Name: tags; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.tags (\n item_id integer NOT NULL,\n tag text NOT NULL,\n n bigint NOT NULL\n);\n\n\n--\n-- Name: tags_n_seq; Type: SEQUENCE; Schema: public; Owner: -\n--\n\nALTER TABLE public.tags ALTER COLUMN n ADD GENERATED BY DEFAULT AS IDENTITY (\n SEQUENCE NAME public.tags_n_seq\n START WITH 1\n INCREMENT BY 1\n NO MINVALUE\n NO MAXVALUE\n CACHE 1\n);\n\n\n--\n-- Name: items id; Type: DEFAULT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.items ALTER COLUMN id SET DEFAULT nextval('public.items_id_seq'::regclass);\n\n\n--\n-- Data for Name: items; Type: TABLE DATA; Schema: public; Owner: -\n--\n\nCOPY public.items (id, name, price, flags, data, meta, labels, at, day, uid, note) FROM stdin;\n1\tりんご\t120.50\tt\t\\\\x00ff0a5c\t{\"n\": [1, 2], \"色\": \"赤\"}\t{a,\"b c\",NULL,\"d\\\\\"e\"}\t2026-01-01 18:04:05.5+00\t2026-01-02\ta0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11\t行1\\n行2\\tタブ\\\\\n2\tみかん\t\\N\t\\N\t\\N\t\\N\t{}\t2026-11-12 00:00:00+00\t\\N\t\\N\t\\N\n3\tit's\t-0.01\tf\t\\\\x\tnull\t\\N\t2000-02-29 12:00:00+00\t2000-02-29\t\\N\t\n\\.\n\n\n--\n-- Data for Name: tags; Type: TABLE DATA; Schema: public; Owner: -\n--\n\nCOPY public.tags (item_id, tag, n) FROM stdin;\n1\t果物\t1\n1\t赤\t2\n2\t果物\t3\n\\.\n\n\n--\n-- Name: items_id_seq; Type: SEQUENCE SET; Schema: public; Owner: -\n--\n\nSELECT pg_catalog.setval('public.items_id_seq', 3, true);\n\n\n--\n-- Name: tags_n_seq; Type: SEQUENCE SET; Schema: public; Owner: -\n--\n\nSELECT pg_catalog.setval('public.tags_n_seq', 3, true);\n\n\n--\n-- Name: items items_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.items\n ADD CONSTRAINT items_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.tags\n ADD CONSTRAINT tags_pkey PRIMARY KEY (item_id, tag);\n\n\n--\n-- Name: items_day; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX items_day ON public.items USING btree (day, price);\n\n\n--\n-- Name: items_name; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE UNIQUE INDEX items_name ON public.items USING btree (lower((name)::text));\n\n\n--\n-- Name: tags tags_item_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.tags\n ADD CONSTRAINT tags_item_id_fkey FOREIGN KEY (item_id) REFERENCES public.items(id);\n\n\n--\n-- PostgreSQL database dump complete\n--\n\n\\unrestrict IGblw7AgTAaQWGRowGi8gEvwjav0A41Z4KbrP31pzIodfRyIadXg22o2ex4YurD\n\n",
4+ "sqlitedump": "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\nCREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, b BLOB, r REAL, n);\nINSERT INTO users VALUES(1,'a''b',X'00ff',1.5,NULL);\nINSERT INTO users VALUES(2,replace('x\\ny','\\n',char(10)),NULL,-2.0e-5,'z');\nINSERT INTO users VALUES(3,replace(replace('a\\r\\nb','\\r',char(13)),'\\n',char(10)),NULL,NULL,1);\nDELETE FROM sqlite_sequence;\nINSERT INTO sqlite_sequence VALUES('users',3);\nCREATE TABLE IF NOT EXISTS \"q t\"(x INT, y);\nINSERT INTO \"q t\" VALUES(1,2);\nCREATE INDEX users_name ON users(name);\nCOMMIT;\nCREATE TABLE u2 (s TEXT);\nINSERT INTO u2 VALUES(unistr('a\\u000ab\\\\c\\+01F600'));\nINSERT INTO u2 VALUES('x' || char(9) || 'y');\n"
5+}
@@ -49,6 +49,7 @@
4949 // SELECT LB_DISCONNECT() 返事をせずに接続を切る
5050 // SELECT LB_SLEEP(秒) 返事を遅らせる
5151 // CALL lb_multi() 結果を2つ返す
52+// SHOW FULL TABLES、SHOW CREATE TABLE(このサーバーで作ったテーブルの元のSQL)
5253 // @@lb_tls・@@lb_plugin・@@lb_full_auth・@@lb_caps・@@character_set_client・@@version、
5354 // DATABASE()・CONNECTION_ID()・VERSION()
5455
@@ -114,6 +115,8 @@
114115
115116 private \PDO $pdo;
116117 private array $conns = [];
118+ /** テーブル => 元のCREATE TABLE(SHOW CREATE TABLE用) */
119+ private array $ddl = [];
117120 private int $nextId = 1;
118121 private int $nextStmt = 1;
119122 private array $sha2Cache = [];
@@ -454,6 +457,24 @@
454457 if (preg_match('/^USE\s+`?(\w+)`?$/i', $sql, $m)) {
455458 return $this->command($id, "\x02".$m[1]);
456459 }
460+ // 変換(Std\Lib\Db\Convert)用:テーブルの一覧と元のCREATE TABLE
461+ if (preg_match('/^SHOW\s+FULL\s+TABLES$/i', $sql)) {
462+ $db = $cn['db'] === '' ? 'lb' : $cn['db'];
463+ $st = $this->pdo->query("SELECT name AS ".$this->pdo->quote('Tables_in_'.$db).", 'BASE TABLE' AS Table_type FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
464+ $this->resultSet($id, $st, false);
465+ return true;
466+ }
467+ if (preg_match('/^SHOW\s+CREATE\s+TABLE\s+`?([^`]+)`?$/i', $sql, $m)) {
468+ $ddl = $this->ddl[strtolower($m[1])] ?? null;
469+ if ($ddl === null) {
470+ $this->err($id, 1146, '42S02', "Table '{$cn['db']}.{$m[1]}' doesn't exist");
471+ return true;
472+ }
473+ $st = $this->pdo->prepare('SELECT ? AS "Table", ? AS "Create Table"');
474+ $st->execute([$m[1], $ddl]);
475+ $this->resultSet($id, $st, false);
476+ return true;
477+ }
457478
458479 try {
459480 $st = $this->pdo->prepare($this->rewrite($id, $sql));
@@ -462,6 +483,8 @@
462483 $this->sqlError($id, $e);
463484 return true;
464485 }
486+ if (preg_match('/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([^`\s(]+)`?/i', $sql, $m)) $this->ddl[strtolower($m[1])] = $sql;
487+ if (preg_match('/^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?`?([^`\s]+)`?/i', $sql, $m)) unset($this->ddl[strtolower($m[1])]);
465488
466489 $this->finish($id, $st, $sql, false);
467490 return true;
@@ -625,8 +648,14 @@
625648 private function rewrite(int $id, string $sql): string {
626649 $cn = $this->conns[$id];
627650 $q = fn(string $s): string => $this->pdo->quote($s);
628- $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);
651+ $auto = 0;
652+ $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, -1, $auto);
629653 $sql = preg_replace('/\)\s*(ENGINE|DEFAULT\s+CHARSET|CHARSET|COLLATE)\s*=.*$/is', ')', $sql);
654+ if (preg_match('/^\s*CREATE\s+TABLE/i', $sql)) {
655+ // SQLiteに無い物(照合順序、文字セット、DATETIME(6)のDEFAULT)、AUTO_INCREMENTの別の主キー
656+ $sql = preg_replace(['/\s+COLLATE\s+\w+/i', '/\s+CHARACTER\s+SET\s+\w+/i', '/CURRENT_TIMESTAMP\(\d*\)/i'], ['', '', 'CURRENT_TIMESTAMP'], $sql);
657+ if ($auto) $sql = preg_replace('/,\s*PRIMARY\s+KEY\s*\([^)]*\)/i', '', $sql);
658+ }
630659
631660 return strtr($sql, [
632661 'DATABASE()' => $cn['db'] === '' ? 'NULL' : $q($cn['db']),
@@ -937,4 +966,3 @@
937966 return $head === null ? null : $this->readRest($id, $head);
938967 }
939968 }
940-
@@ -58,6 +58,7 @@
5858 // lb_tls()・lb_auth()・lb_cb()(チャネルバインディングの種類)・current_user・current_database()・
5959 // pg_backend_pid()・version()
6060 // COPY … FROM STDIN、COPY … TO STDOUT
61+// information_schema.tablesのtable_name(テーブルの一覧)、setval()(何もしない)
6162
6263 require_once __DIR__.'/../../../../autoload.php';
6364
@@ -488,7 +489,7 @@
488489 $types = $this->paramTypes($sql);
489490 foreach ($given as $i => $oid) if ($oid !== 0) $types[$i] = $oid;
490491 // 構文・テーブルの確認(特別なSQLは実行の時に)
491- if (!preg_match('/^(BEGIN|START|COMMIT|END|ROLLBACK|SET|SHOW|COPY)\b|lb_\w+\(/i', $sql)) {
492+ if (!preg_match('/^(BEGIN|START|COMMIT|END|ROLLBACK|SET|SHOW|COPY)\b|lb_\w+\(|setval\(|information_schema\./i', $sql)) {
492493 try {
493494 $this->pdo->prepare($this->rewrite($id, $sql));
494495 } catch (\PDOException $e) {
@@ -613,6 +614,11 @@
613614 $this->send($id, 'c', '');
614615 return $none('COPY 1');
615616 }
617+ if (preg_match('/^SELECT\s+table_name\s+FROM\s+information_schema\.tables\b/i', $sql)) {
618+ $names = $this->pdo->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")->fetchAll(\PDO::FETCH_COLUMN);
619+ return ['cols' => [['table_name', 25]], 'rows' => array_map(fn($n) => [$n], $names), 'tag' => 'SELECT '.count($names)];
620+ }
621+ if (preg_match('/^SELECT\s+setval\(/i', $sql)) return $one('setval', 20, '1');
616622 if (preg_match('/^SELECT\s+lb_disconnect\(\)$/i', $sql)) return false;
617623 if (preg_match('/^SELECT\s+lb_fatal\(\)$/i', $sql)) throw new PgError('57P01', 'terminating connection due to administrator command', '', 'FATAL');
618624 if (preg_match('/^SELECT\s+lb_notice\(\)$/i', $sql)) {
@@ -768,14 +774,18 @@
768774
769775 // テーブルの列
770776 $cols = [];
771- if (preg_match_all('/\b(?:FROM|INTO|UPDATE|JOIN)\s+(\w+)/i', $sql, $t)) {
777+ if (preg_match_all('/\b(?:FROM|INTO|UPDATE|JOIN)\s+"?(\w+)"?/i', $sql, $t)) {
772778 foreach ($t[1] as $table) $cols += $this->columns($table);
773779 }
774- if (preg_match('/INSERT\s+INTO\s+\w+\s*\(([^)]*)\)\s*VALUES\s*\(([^)]*)\)/i', $sql, $m)) {
775- $names = array_map('trim', explode(',', $m[1]));
776- foreach (array_map('trim', explode(',', $m[2])) as $i => $v) {
777- if (preg_match('/^\$(\d+)$/', $v, $p) && isset($cols[strtolower($names[$i] ?? '')])) {
778- $types[(int)$p[1] - 1] = $toOid($cols[strtolower($names[$i])]);
780+ // INSERT INTO t (列, …) VALUES (…), (…):「"」の名前、複数の行も
781+ if (preg_match('/INSERT\s+INTO\s+"?\w+"?\s*\(([^)]*)\)\s*VALUES\s*(.*)$/is', $sql, $m)) {
782+ $names = array_map(fn($n) => strtolower(trim($n, " \t\n\"")), explode(',', $m[1]));
783+ preg_match_all('/\(([^()]*)\)/', $m[2], $tuples);
784+ foreach ($tuples[1] as $tuple) {
785+ foreach (array_map('trim', explode(',', $tuple)) as $i => $v) {
786+ if (preg_match('/^\$(\d+)$/', $v, $p) && isset($cols[$names[$i] ?? ''])) {
787+ $types[(int)$p[1] - 1] = $toOid($cols[$names[$i]]);
788+ }
779789 }
780790 }
781791 }
@@ -0,0 +1,502 @@
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\Convert;
45+use Std\Lib\Db\Convert\DbSource;
46+use Std\Lib\Db\Convert\DbTarget;
47+use Std\Lib\Db\Convert\DumpSource;
48+use Std\Lib\Db\Convert\DumpTarget;
49+use Std\Lib\Db\Convert\JsonSource;
50+use Std\Lib\Db\Convert\JsonTarget;
51+use Std\Lib\Db\Convert\Sql;
52+use Std\Lib\Db\Convert\Stores;
53+use Std\Lib\Db\DbException;
54+use Std\Lib\Db\Sqlite\Blob;
55+use Std\Test\Helper\MockServer;
56+
57+$test = new Tester([
58+ 'colorOutput' => true,
59+ 'verboseOutput' => true
60+]);
61+
62+// 本物のサーバーでも試す場合(任意、lb_test_のテーブルを作って消す):LB_TEST_PGSQL=pgsql://ユーザー:パスワード@ホスト:ポート/データベース
63+
64+$tmp = sys_get_temp_dir().'/lbconvert_'.bin2hex(random_bytes(6));
65+mkdir($tmp, 0700);
66+register_shutdown_function(function() use ($tmp): void {
67+ $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmp, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
68+ foreach ($it as $f) $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
69+ @rmdir($tmp);
70+});
71+$fixtures = json_decode((string)file_get_contents(__DIR__.'/Helper/convert.json'), true);
72+
73+$catch = function(callable $fn): ?\Throwable {
74+ try {
75+ $fn();
76+ } catch (\Throwable $e) {
77+ return $e;
78+ }
79+ return null;
80+};
81+$sqlite = fn(string $name): Db => new Db(['driver' => 'sqlite', 'path' => $tmp.'/'.$name]);
82+// 比べられる形の行(BLOBは16進数)
83+$dump = function(Db $db, string $sql): array {
84+ return array_map(fn($r) => array_map(fn($v) => $v instanceof Blob ? 'x'.bin2hex($v->data) : (is_string($v) && !mb_check_encoding($v, 'UTF-8') ? 'x'.bin2hex($v) : $v), $r), $db->query($sql)['rows']);
85+};
86+// 色々な型の元のSQLiteのデータベース
87+$sample = function(string $name) use ($sqlite): Db {
88+ $db = $sqlite($name);
89+ $db->query('CREATE TABLE users (id INTEGER PRIMARY KEY, name VARCHAR(100) NOT NULL, email TEXT, active BOOLEAN NOT NULL DEFAULT 1, score NUMERIC(10,2) DEFAULT 0,
90+ ratio REAL, created DATETIME DEFAULT CURRENT_TIMESTAMP, born DATE, avatar BLOB, prefs JSON)');
91+ $db->query('CREATE UNIQUE INDEX users_name ON users (name)');
92+ $db->query('CREATE INDEX users_email ON users (email)');
93+ $db->query('CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT, published DATETIME)');
94+ $db->query('CREATE INDEX posts_user ON posts (user_id, published)');
95+ $db->query('CREATE TABLE kv (k TEXT NOT NULL, n INTEGER NOT NULL, v TEXT, PRIMARY KEY (k, n))');
96+ $ins = 'INSERT INTO users (name, email, active, score, ratio, created, born, avatar, prefs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)';
97+ $db->query($ins, ['諏訪子', 'suwako@example.com', 1, 123.45, 0.5, '2026-01-02 03:04:05', '1990-05-06', new Blob("\x00\xff\\'\n"), '{"theme":"dark","n":[1,2]}']);
98+ $db->query($ins, ['神奈子', null, 0, -1, null, '2026-01-02 00:00:00', null, null, null]);
99+ $db->query($ins, ["it's \"q\" \\ tab\tx; -- /* */", 'x@x', 1, 0, 1e100, '2026-11-12 23:59:59', '2000-02-29', new Blob(''), '[]']);
100+ $db->query('BEGIN');
101+ for ($i = 1; $i <= 1200; $i++) $db->query('INSERT INTO posts (user_id, title, body, published) VALUES (?, ?, ?, ?)', [1 + $i % 3, "タイトル{$i}", str_repeat('本文', $i % 50), gmdate('Y-m-d H:i:s', 1767225600 + $i * 3600)]);
102+ $db->query('COMMIT');
103+ $db->query("INSERT INTO kv VALUES ('a', 1, 'x'), ('a', 2, 'y'), ('b', 1, NULL)");
104+ return $db;
105+};
106+$same = function($test, Db $a, Db $b, array $tables = ['users', 'posts', 'kv']) use ($dump): void {
107+ foreach ($tables as $t) $test->assertSame($dump($a, "SELECT * FROM {$t} ORDER BY 1, 2"), $dump($b, "SELECT * FROM {$t} ORDER BY 1, 2"), $t);
108+};
109+// LittleBeastのJSONのデータ
110+$data = function(string $dir): void {
111+ $put = function(string $rel, mixed $v) use ($dir): void {
112+ @mkdir(dirname($dir.'/'.$rel), 0755, true);
113+ file_put_contents($dir.'/'.$rel, is_string($v) ? $v : json_encode($v, Stores::JSON_FLAGS));
114+ };
115+ $put('user/1.alice.json', json_encode(['id' => 1, 'username' => 'alice', 'email' => 'a@example.com', 'role' => 3, 'prefs' => new \stdClass(), 'list' => []], JSON_PRETTY_PRINT));
116+ $put('user/2.bob.json', ['id' => 2, 'username' => 'bob', 'role' => 1]);
117+ $put('comments/blog/hello.json', ['title' => 'こんにちは', 'comments' => ['0190a1' => ['user' => 'alice', 'body' => '一'], '0190a2' => ['user' => 'bob', 'body' => '二', 'parent' => '0190a1']]]);
118+ $put('comments/tutorial/php/basics.json', ['title' => 'PHP', 'comments' => []]);
119+ $put('comments/ap.json', ['https://remote.example/notes/1' => ['thread' => 'blog/hello', 'id' => '0190a2']]);
120+ $put('notifications/alice.json', [['type' => 'reply', 'from' => 'bob'], ['type' => 'like', 'from' => 'bob']]);
121+ $put('cron/cron.json', [['name' => 'apdeliver', 'schedule' => '* * * * *'], ['name' => 'purge', 'schedule' => '0 3 * * *']]);
122+ $put('cron/state.json', ['apdeliver' => 1234]);
123+ $put('followers.txt', "https://a.example/users/x\nhttps://b.example/users/y\n");
124+ $put('ap/notes/0190b1.json', ['id' => '0190b1', 'content' => '<p>投稿</p>']);
125+ $put('ap/users/alice/private.pem', "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n");
126+ $put('ap/remote/media/x.bin', "\x00\x01\xfe\xff".str_repeat("\x80", 300));
127+ $put('broken.json', '{not json');
128+ $put('cache/foo/bar.json', '{"x":1}');
129+ $put('ratelimit/1.2.3.4.json', '{}');
130+ $put('cron/run.lock', '');
131+ $put('site.sqlite', "SQLite format 3\0".str_repeat("\0", 100));
132+};
133+// ディレクトリの全部のファイル(相対パス => 中身)
134+$files = function(string $dir): array {
135+ $out = [];
136+ if (!is_dir($dir)) return $out;
137+ foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)) as $f) {
138+ $out[str_replace('\\', '/', substr($f->getPathname(), strlen($dir) + 1))] = (string)file_get_contents($f->getPathname());
139+ }
140+ ksort($out);
141+ return $out;
142+};
143+// 変換された事を確認する(JSONは同じ値なら良い)
144+$sameFiles = function($test, array $a, array $b): void {
145+ $test->assertSame(array_keys($a), array_keys($b));
146+ foreach ($a as $rel => $x) {
147+ if (!isset($b[$rel])) continue;
148+ $ja = json_decode($x, true);
149+ if ($x !== $b[$rel] && $ja !== null) $test->assertSame($ja, json_decode($b[$rel], true), $rel);
150+ else $test->assertSame(bin2hex($x), bin2hex($b[$rel]), $rel);
151+ }
152+};
153+
154+
155+$test->describe('Convert(SQLの解析)', function($test): void {
156+ $test->it('方言毎の文字列・名前を解析出来るはず', function($test): void {
157+ $t = Sql::tokens("SELECT 'it''s', 'a\\'b', \"x\", `y`, 0x00ff, _binary 'z', X'41'", 'mysql');
158+ $vals = array_map(fn($k) => [$k['t'], $k['t'] === 'blob' ? bin2hex($k['v']) : $k['v']], array_values(array_filter($t, fn($k) => $k['t'] !== 'op')));
159+ $test->assertSame([['id', 'SELECT'], ['str', "it's"], ['str', "a'b"], ['str', 'x'], ['qid', 'y'], ['blob', '00ff'], ['blob', '7a'], ['blob', '41']], $vals);
160+ $t = Sql::tokens("SELECT 'a\\b', E'c\\nd', \"x\"\"y\", \$q\$it's\$q\$", 'pgsql');
161+ $test->assertSame(['a\\b', "c\nd", 'x"y', "it's"], array_column(array_values(array_filter($t, fn($k) => in_array($k['t'], ['str', 'qid'], true))), 'v'));
162+ });
163+
164+ $test->it('CREATE TABLEを解析出来るはず', function($test): void {
165+ $w = [];
166+ $t = Sql::createTable("CREATE TABLE `a` (
167+ `id` int unsigned NOT NULL AUTO_INCREMENT,
168+ `name` varchar(50) COLLATE utf8mb4_bin NOT NULL DEFAULT '',
169+ `price` decimal(10,2) DEFAULT '0.00',
170+ `ok` tinyint(1) NOT NULL DEFAULT '1',
171+ `at` datetime(3) DEFAULT CURRENT_TIMESTAMP(3),
172+ `pid` int DEFAULT NULL,
173+ PRIMARY KEY (`id`),
174+ UNIQUE KEY `a_name` (`name`(20)),
175+ KEY `a_low` ((lower(`name`))),
176+ CONSTRAINT `fk` FOREIGN KEY (`pid`) REFERENCES `a` (`id`)
177+ ) ENGINE=InnoDB", 'mysql', $w);
178+ $test->assertSame('a', $t['name']);
179+ $test->assertSame(['id', 'name', 'price', 'ok', 'at', 'pid'], array_column($t['columns'], 'name'));
180+ $test->assertSame(['int', 'varchar', 'decimal', 'bool', 'datetime', 'int'], array_column($t['columns'], 'type'));
181+ $test->assertTrue($t['columns'][0]['autoinc'] && $t['columns'][0]['unsigned']);
182+ $test->assertSame([50, 'utf8mb4_bin', ['value' => '']], [$t['columns'][1]['size'], $t['columns'][1]['collate'], $t['columns'][1]['default']]);
183+ $test->assertSame([10, 2], [$t['columns'][2]['size'], $t['columns'][2]['scale']]);
184+ $test->assertSame(['now' => true], $t['columns'][4]['default']);
185+ $test->assertSame(['id'], $t['pk']);
186+ $test->assertSame([['name' => 'a_name', 'columns' => ['name'], 'unique' => true]], $t['indexes']);
187+ $test->assertCount(2, $w); // 式のインデックスと外部キー
188+
189+ $t = Sql::createTable('CREATE TABLE public.b (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, s serial, t timestamp with time zone NOT NULL DEFAULT now(),
190+ u uuid, j jsonb, tags text[], d double precision, c character varying(9) UNIQUE)', 'pgsql');
191+ $test->assertSame('b', $t['name']);
192+ $test->assertSame(['int', 'int', 'datetime', 'uuid', 'json', 'json', 'real', 'varchar'], array_column($t['columns'], 'type'));
193+ $test->assertSame([true, true, false], [$t['columns'][0]['autoinc'], $t['columns'][1]['autoinc'], $t['columns'][2]['autoinc']]);
194+ $test->assertTrue($t['columns'][2]['tz'] && $t['columns'][2]['notNull']);
195+ $test->assertTrue($t['columns'][5]['array']);
196+ $test->assertSame(['id'], $t['pk']);
197+ $test->assertSame([['c']], array_column($t['indexes'], 'columns'));
198+ $test->assertNull(Sql::createTable('CREATE VIEW v AS SELECT 1', 'pgsql'));
199+ });
200+
201+ $test->it('型を共通の型にするはず', function($test): void {
202+ $p = fn(string $t, string $d = 'mysql') => array_intersect_key(Convert::parseType($t, $d), ['type' => 1, 'size' => 1, 'scale' => 1, 'unsigned' => 1, 'tz' => 1]);
203+ $test->assertSame(['type' => 'int', 'size' => 16, 'scale' => null, 'unsigned' => false, 'tz' => false], $p('smallint(6)'));
204+ $test->assertSame('bool', $p('tinyint(1)')['type']);
205+ $test->assertSame(['int', 16], [$p('tinyint unsigned')['type'], $p('tinyint unsigned')['size']]);
206+ $test->assertSame(['int', 64], [$p('int unsigned')['type'], $p('int unsigned')['size']]);
207+ $test->assertSame('text', $p("enum('a','b')")['type']);
208+ $test->assertSame('blob', $p('varbinary(16)')['type']);
209+ $test->assertSame('datetime', $p('timestamp', 'mysql')['type']);
210+ $test->assertSame(true, $p('timestamptz', 'pgsql')['tz']);
211+ $test->assertSame('real', $p('double precision', 'pgsql')['type']);
212+ $test->assertSame(['decimal', 12, 4], [$p('numeric(12,4)', 'pgsql')['type'], $p('numeric(12,4)', 'pgsql')['size'], $p('numeric(12,4)', 'pgsql')['scale']]);
213+ // SQLiteの型の親和性
214+ $test->assertSame('int', $p('INTEGER', 'sqlite')['type']);
215+ $test->assertSame('text', $p('', 'sqlite')['type']);
216+ $test->assertSame('text', $p('STRING', 'sqlite')['type']);
217+ });
218+});
219+
220+$test->describe('Convert(SQLのダンプ)', function($test) use ($tmp, $fixtures, $sqlite, $sample, $same, $dump, $catch): void {
221+ $test->it('mysqldumpのファイルを読めるはず', function($test) use ($tmp, $fixtures, $sqlite): void {
222+ file_put_contents($tmp.'/my.sql', $fixtures['mysqldump']);
223+ $src = new DumpSource($tmp.'/my.sql');
224+ $test->assertSame('mysql', $src->dialect);
225+ $r = Convert::run($src, new DbTarget($sqlite('my.sqlite')));
226+ $test->assertSame(['customers' => 3, 'orders' => 2], $r['tables']);
227+ $test->assertTrue(count($r['warnings']) >= 3); // 全文のインデックス、外部キー、ビュー
228+ $db = $sqlite('my.sqlite');
229+ $rows = $db->query('SELECT * FROM customers ORDER BY id')['rows'];
230+ $test->assertSame(['諏訪子', '神奈子', "O'Brien"], array_column($rows, 'name'));
231+ $test->assertSame("\0\\'\"\n\r\x1a", $rows[0]['photo']);
232+ $test->assertSame("\x00\xff\x10", $rows[1]['photo']);
233+ $test->assertSame("it's; a \"test\"\\ -- not a comment", $rows[0]['note']);
234+ $test->assertSame("行1\n行2\ttab", $rows[1]['note']);
235+ $test->assertSame('/* x */ # y', $rows[2]['note']);
236+ $test->assertSame([1, 0, 1], array_column($rows, 'active'));
237+ $test->assertSame([1234.5, -0.01, 0], array_column($rows, 'balance'));
238+ $test->assertSame(['2026-01-02 03:04:05', null, '2026-11-12 00:00:00'], array_column($rows, 'created_at'));
239+ $test->assertSame('2026-01-02 03:04:05.123', $rows[0]['updated_at']);
240+ $test->assertSame([['id' => 1, 'total' => 99.5, 'ordered' => '2026-01-03'], ['id' => 2, 'total' => 1000.0, 'ordered' => null]],
241+ $db->query('SELECT id, total, ordered FROM orders ORDER BY id')['rows']);
242+ $idx = array_column($db->query("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name")['rows'], 'name');
243+ $test->assertSame(['customers_email_unique', 'customers_idx_name_level', 'orders_customer_id_foreign'], $idx);
244+ // 自動の番号の続き
245+ $test->assertSame(4, $db->query("INSERT INTO customers (name) VALUES ('新')")['insertId']);
246+ });
247+
248+ $test->it('pg_dumpのファイル(COPY)を読めるはず', function($test) use ($tmp, $fixtures, $sqlite): void {
249+ file_put_contents($tmp.'/pg.sql', $fixtures['pgdump']);
250+ $src = new DumpSource($tmp.'/pg.sql');
251+ $test->assertSame('pgsql', $src->dialect);
252+ $r = Convert::run($src, new DbTarget($sqlite('pg.sqlite')));
253+ $test->assertSame(['items' => 3, 'tags' => 3], $r['tables']);
254+ $test->assertTrue(in_array('式・部分のインデックスは変換しません:items_name', $r['warnings'], true));
255+ $db = $sqlite('pg.sqlite');
256+ $rows = $db->query('SELECT * FROM items ORDER BY id')['rows'];
257+ $test->assertSame(['りんご', 'みかん', "it's"], array_column($rows, 'name'));
258+ $test->assertSame("\x00\xff\x0a\x5c", $rows[0]['data']);
259+ $test->assertSame('', $rows[2]['data']);
260+ $test->assertSame('["a","b c",null,"d\"e"]', $rows[0]['labels']); // 配列はJSON
261+ $test->assertSame('[]', $rows[1]['labels']);
262+ $test->assertSame(['2026-01-01 18:04:05.5', '2026-11-12 00:00:00', '2000-02-29 12:00:00'], array_column($rows, 'at')); // UTC
263+ $test->assertSame("行1\n行2\tタブ\\", $rows[0]['note']);
264+ $test->assertSame([1, null, 0], array_column($rows, 'flags'));
265+ $test->assertSame([120.5, null, -0.01], array_column($rows, 'price'));
266+ $test->assertSame(['id', 'name', 'price', 'flags', 'data', 'meta', 'labels', 'at', 'day', 'uid', 'note'], array_column($db->query('PRAGMA table_info(items)')['rows'], 'name'));
267+ $test->assertSame([[1, '果物', 1], [1, '赤', 2], [2, '果物', 3]], array_map('array_values', $db->query('SELECT * FROM tags ORDER BY item_id, n')['rows']));
268+ });
269+
270+ $test->it('sqlite3の.dumpのファイルを読めるはず', function($test) use ($tmp, $fixtures, $sqlite): void {
271+ file_put_contents($tmp.'/sq.sql', $fixtures['sqlitedump']);
272+ $src = new DumpSource($tmp.'/sq.sql');
273+ $test->assertSame('sqlite', $src->dialect);
274+ $r = Convert::run($src, new DbTarget($sqlite('sq.sqlite')));
275+ $test->assertSame(['users' => 3, 'q t' => 1, 'u2' => 2], $r['tables']);
276+ $db = $sqlite('sq.sqlite');
277+ $test->assertSame([[1, "a'b", "\x00\xff", 1.5], [2, "x\ny", null, -2.0e-5], [3, "a\r\nb", null, null]],
278+ array_map('array_values', $db->query('SELECT id, name, b, r FROM users ORDER BY id')['rows']));
279+ $test->assertSame(["a\nb\\c😀", "x\ty"], array_column($db->query('SELECT s FROM u2 ORDER BY rowid')['rows'], 's'));
280+ $test->assertSame(4, $db->query("INSERT INTO users (name) VALUES ('新')")['insertId']);
281+ });
282+
283+ $test->it('ダンプに書いて、読み戻せるはず(全部の方言)', function($test) use ($tmp, $sqlite, $sample, $same): void {
284+ $a = $sample('rt.sqlite');
285+ foreach (['mysql', 'pgsql', 'sqlite'] as $d) {
286+ foreach (['', '.gz'] as $gz) {
287+ if ($gz !== '' && !function_exists('gzopen')) continue;
288+ $file = "{$tmp}/rt-{$d}.sql{$gz}";
289+ $r = Convert::run(new DbSource($a), new DumpTarget($file, $d));
290+ $test->assertEquals(['users' => 3, 'posts' => 1200, 'kv' => 3], $r['tables']);
291+ $head = $gz ? (string)gzdecode((string)file_get_contents($file)) : (string)file_get_contents($file);
292+ $test->assertStringContains("-- LittleBeastのデータベースの変換({$d})", $head);
293+ $src = new DumpSource($file);
294+ $test->assertSame($d, $src->dialect);
295+ $r = Convert::run($src, new DbTarget($sqlite("rt-{$d}{$gz}.sqlite")));
296+ $test->assertEquals(['users' => 3, 'posts' => 1200, 'kv' => 3], $r['tables'], $d);
297+ $b = $sqlite("rt-{$d}{$gz}.sqlite");
298+ if ($d === 'sqlite') {
299+ $same($test, $a, $b);
300+ } else {
301+ // 型が変わる所(BOOLEAN、NUMERIC)以外は同じ
302+ $same($test, $a, $b, ['posts', 'kv']);
303+ $test->assertSame($a->query('SELECT name, email, avatar, prefs, created FROM users ORDER BY id')['rows'] == $b->query('SELECT name, email, avatar, prefs, created FROM users ORDER BY id')['rows'], true, $d);
304+ }
305+ $test->assertSame(4, $b->query("INSERT INTO users (name) VALUES ('新')")['insertId'], $d);
306+ }
307+ }
308+ });
309+
310+ $test->it('SQLiteのダンプは本物のSQLiteで読めるはず', function($test) use ($tmp): void {
311+ if (!class_exists('PDO') || !in_array('sqlite', \PDO::getAvailableDrivers(), true)) {
312+ $test->assertTrue(true);
313+ return;
314+ }
315+ $p = new \PDO('sqlite::memory:');
316+ $p->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
317+ $p->exec((string)file_get_contents($tmp.'/rt-sqlite.sql'));
318+ $test->assertSame(1200, (int)$p->query('SELECT count(*) FROM posts')->fetchColumn());
319+ $test->assertSame('00ff5c270a', bin2hex((string)$p->query('SELECT avatar FROM users WHERE id = 1')->fetchColumn()));
320+ $test->assertSame('ok', $p->query('PRAGMA integrity_check')->fetchColumn());
321+ });
322+
323+ $test->it('壊れたダンプは例外で、書く先のファイルは消すはず', function($test) use ($tmp, $sqlite, $sample, $catch): void {
324+ file_put_contents($tmp.'/bad.sql', "CREATE TABLE t (id INT PRIMARY KEY, s TEXT);\nINSERT INTO t VALUES (1, 'a'), (2, 'b);\n");
325+ $e = $catch(fn() => Convert::run(new DumpSource($tmp.'/bad.sql'), new DumpTarget($tmp.'/bad-out.sql', 'pgsql')));
326+ $test->assertTrue($e instanceof DbException);
327+ $test->assertFalse(is_file($tmp.'/bad-out.sql'));
328+ $e = $catch(fn() => Convert::run(new DbSource($sample('x.sqlite')), new DumpTarget($tmp.'/x.sql', 'oracle')));
329+ $test->assertTrue($e instanceof \InvalidArgumentException);
330+ });
331+});
332+
333+$test->describe('Convert(データベース同士)', function($test) use ($tmp, $sqlite, $sample, $same, $dump, $catch): void {
334+ $test->it('SQLiteからSQLiteに変換出来て、テーブルを選べるはず', function($test) use ($sqlite, $sample, $same, $catch): void {
335+ $a = $sample('a.sqlite');
336+ $n = [];
337+ $r = Convert::run(new DbSource($a), new DbTarget($sqlite('b.sqlite')), ['progress' => function(string $t, int $rows) use (&$n): void { $n[$t] = $rows; }]);
338+ $test->assertEquals(['users' => 3, 'posts' => 1200, 'kv' => 3], $r['tables']);
339+ $test->assertEquals(['users' => 3, 'posts' => 1200, 'kv' => 3], $n);
340+ $test->assertSame([], $r['warnings']);
341+ $b = $sqlite('b.sqlite');
342+ $same($test, $a, $b);
343+ $test->assertSame('ok', $b->query('PRAGMA integrity_check')['rows'][0]['integrity_check']);
344+ // 既に有るテーブルは、dropが無ければ例外
345+ $e = $catch(fn() => Convert::run(new DbSource($a), new DbTarget($b)));
346+ $test->assertTrue($e instanceof DbException);
347+ $test->assertStringContains('既に有ります', $e->getMessage());
348+ $r = Convert::run(new DbSource($a), new DbTarget($b), ['tables' => ['KV'], 'drop' => true]);
349+ $test->assertSame(['kv' => 3], $r['tables']);
350+ $same($test, $a, $b);
351+ // 空のデータベース
352+ $r = Convert::run(new DbSource($sqlite('empty.sqlite')), new DbTarget($sqlite('empty2.sqlite')));
353+ $test->assertSame([], $r['tables']);
354+ });
355+
356+ $test->it('偽物のMySQLサーバーに書いて、読み戻せるはず', function($test) use ($sqlite, $sample, $same): void {
357+ $srv = MockServer::start('mysql-server.php', []);
358+ if ($srv === null) {
359+ $test->assertTrue(true);
360+ return;
361+ }
362+ try {
363+ $a = $sample('my-a.sqlite');
364+ $my = new Db(['driver' => 'mysql', 'host' => '127.0.0.1', 'port' => $srv->port, 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb', 'timeout' => 5]);
365+ $r = Convert::run(new DbSource($a), new DbTarget($my));
366+ $test->assertEquals(['users' => 3, 'posts' => 1200, 'kv' => 3], $r['tables']);
367+ $ddl = $my->query('SHOW CREATE TABLE `users`')['rows'][0]['Create Table'];
368+ $test->assertStringContains('AUTO_INCREMENT', $ddl);
369+ $test->assertStringContains('utf8mb4', $ddl);
370+ $r = Convert::run(new DbSource($my), new DbTarget($sqlite('my-b.sqlite')));
371+ $test->assertEquals(['kv' => 3, 'posts' => 1200, 'users' => 3], $r['tables']);
372+ $same($test, $a, $sqlite('my-b.sqlite'), ['posts', 'kv']);
373+ $test->assertSame(array_column($a->query('SELECT name FROM users ORDER BY id')['rows'], 'name'), array_column($sqlite('my-b.sqlite')->query('SELECT name FROM users ORDER BY id')['rows'], 'name'));
374+ } finally {
375+ $srv->stop();
376+ }
377+ });
378+
379+ $test->it('偽物のPostgreSQLサーバーに書けるはず', function($test) use ($sample): void {
380+ $srv = MockServer::start('pg-server.php', []);
381+ if ($srv === null) {
382+ $test->assertTrue(true);
383+ return;
384+ }
385+ try {
386+ $a = $sample('pg-a.sqlite');
387+ $pg = new Db(['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => $srv->port, 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb', 'timeout' => 5]);
388+ $r = Convert::run(new DbSource($a), new DbTarget($pg));
389+ $test->assertEquals(['users' => 3, 'posts' => 1200, 'kv' => 3], $r['tables']);
390+ $test->assertSame(1200, (int)$pg->query('SELECT count(*) AS c FROM posts')['rows'][0]['c']);
391+ $test->assertSame(['諏訪子', '神奈子'], array_column($pg->query('SELECT name FROM users WHERE id < 3 ORDER BY id')['rows'], 'name'));
392+ } finally {
393+ $srv->stop();
394+ }
395+ });
396+
397+ $test->it('本物のPostgreSQLと往復出来るはず(LB_TEST_PGSQL)', function($test) use ($sqlite, $sample, $same): void {
398+ $url = (string)getenv('LB_TEST_PGSQL');
399+ if ($url === '') {
400+ $test->assertTrue(true);
401+ return;
402+ }
403+ $u = parse_url($url);
404+ $pg = new Db(['driver' => 'pgsql', 'host' => $u['host'] ?? '127.0.0.1', 'port' => $u['port'] ?? 5432, 'username' => urldecode($u['user'] ?? ''),
405+ 'password' => urldecode($u['pass'] ?? ''), 'dbname' => ltrim($u['path'] ?? '', '/'), 'timeout' => 5]);
406+ $a = $sample('real-a.sqlite');
407+ $names = ['lb_test_users', 'lb_test_posts', 'lb_test_kv'];
408+ foreach (['users', 'posts', 'kv'] as $i => $t) $a->query("ALTER TABLE {$t} RENAME TO {$names[$i]}");
409+ try {
410+ Convert::run(new DbSource($a), new DbTarget($pg), ['drop' => true]);
411+ $r = Convert::run(new DbSource($pg), new DbTarget($sqlite('real-b.sqlite')), ['tables' => $names]);
412+ $test->assertEquals(['lb_test_kv' => 3, 'lb_test_posts' => 1200, 'lb_test_users' => 3], $r['tables']);
413+ $same($test, $a, $sqlite('real-b.sqlite'), ['lb_test_posts', 'lb_test_kv']);
414+ $test->assertSame(4, (int)$pg->query("INSERT INTO lb_test_users (name) VALUES ('新') RETURNING id")['rows'][0]['id']);
415+ } finally {
416+ foreach ($names as $t) $pg->query("DROP TABLE IF EXISTS {$t}");
417+ }
418+ });
419+});
420+
421+$test->describe('Convert(JSONのデータ)', function($test) use ($tmp, $sqlite, $sample, $same, $data, $files, $sameFiles, $catch): void {
422+ $test->it('データのディレクトリが無い・空なら、テーブルも無いはず', function($test) use ($tmp, $sqlite): void {
423+ $test->assertSame([], (new JsonSource($tmp.'/nothing'))->tables());
424+ mkdir($tmp.'/emptydata');
425+ $r = Convert::run(new JsonSource($tmp.'/emptydata'), new DbTarget($sqlite('e.sqlite')));
426+ $test->assertSame([], $r['tables']);
427+ $r = Convert::run(new JsonSource($tmp.'/emptydata'), new JsonTarget($tmp.'/emptyout'));
428+ $test->assertTrue(is_dir($tmp.'/emptyout'));
429+ });
430+
431+ $test->it('JSONのデータをテーブルにするはず', function($test) use ($tmp, $sqlite, $data): void {
432+ $data($tmp.'/data');
433+ $src = new JsonSource($tmp.'/data');
434+ $r = Convert::run($src, new DbTarget($sqlite('j.sqlite')));
435+ $test->assertSame(['lb_users' => 2, 'lb_comment_threads' => 2, 'lb_comments' => 2, 'lb_comment_ap' => 1, 'lb_notifications' => 2, 'lb_cron' => 2,
436+ 'lb_followers' => 2, 'lb_ap_notes' => 1, 'lb_files' => 4], $r['tables']);
437+ $test->assertTrue(in_array('SQLiteのファイルは変換しません:site.sqlite', $r['warnings'], true));
438+ $db = $sqlite('j.sqlite');
439+ $test->assertSame([['id' => 1, 'username' => 'alice', 'email' => 'a@example.com', 'role' => 3], ['id' => 2, 'username' => 'bob', 'email' => null, 'role' => 1]],
440+ $db->query('SELECT id, username, email, role FROM lb_users ORDER BY id')['rows']);
441+ $test->assertSame([['id' => '0190a1', 'seq' => 0], ['id' => '0190a2', 'seq' => 1]], $db->query("SELECT id, seq FROM lb_comments WHERE thread = 'blog/hello' ORDER BY seq")['rows']);
442+ $test->assertSame('二', json_decode($db->query("SELECT data FROM lb_comments WHERE id = '0190a2'")['rows'][0]['data'], true)['body']);
443+ // キャッシュ・ロック・SQLiteのファイルは入れない、壊れたJSONは普通のファイル
444+ $test->assertSame(['ap/remote/media/x.bin', 'ap/users/alice/private.pem', 'broken.json', 'cron/state.json'],
445+ array_column($db->query('SELECT path FROM lb_files ORDER BY path')['rows'], 'path'));
446+ });
447+
448+ $test->it('テーブルからJSONのデータに戻せるはず', function($test) use ($tmp, $sqlite, $files, $sameFiles, $catch): void {
449+ $want = array_filter($files($tmp.'/data'), fn($k) => !preg_match('#^(cache|ratelimit)/|\.lock$|^site\.sqlite$#', $k), ARRAY_FILTER_USE_KEY);
450+ $r = Convert::run(new DbSource($sqlite('j.sqlite')), new JsonTarget($tmp.'/out1'));
451+ $test->assertSame(2, $r['tables']['lb_users']);
452+ $sameFiles($test, $want, $files($tmp.'/out1'));
453+ // ユーザーのファイルはそのまま
454+ $test->assertSame(file_get_contents($tmp.'/data/user/1.alice.json'), file_get_contents($tmp.'/out1/user/1.alice.json'));
455+ // 空でないディレクトリには、上書きを許さなければ書かない
456+ $e = $catch(fn() => Convert::run(new DbSource($sqlite('j.sqlite')), new JsonTarget($tmp.'/out1')));
457+ $test->assertTrue($e instanceof DbException);
458+ $test->assertStringContains('空ではありません', $e->getMessage());
459+ Convert::run(new DbSource($sqlite('j.sqlite')), new JsonTarget($tmp.'/out1', true));
460+ $sameFiles($test, $want, $files($tmp.'/out1'));
461+ });
462+
463+ $test->it('JSONのデータをダンプ経由で往復出来るはず', function($test) use ($tmp, $files, $sameFiles): void {
464+ $want = $files($tmp.'/out1');
465+ foreach (['mysql', 'pgsql', 'sqlite'] as $d) {
466+ Convert::run(new JsonSource($tmp.'/out1'), new DumpTarget("{$tmp}/j-{$d}.sql", $d));
467+ Convert::run(new DumpSource("{$tmp}/j-{$d}.sql"), new JsonTarget("{$tmp}/out-{$d}"));
468+ $sameFiles($test, $want, $files("{$tmp}/out-{$d}"));
469+ }
470+ });
471+
472+ $test->it('普通のテーブルはdata/dbに書いて、読み戻せるはず', function($test) use ($tmp, $sqlite, $sample, $same, $files): void {
473+ $a = $sample('g.sqlite');
474+ $a->query('CREATE TABLE "odd/name" (id INTEGER PRIMARY KEY, f REAL, b BLOB)');
475+ $a->query('INSERT INTO "odd/name" (f, b) VALUES (1.0, X\'00ff\'), (NULL, NULL)');
476+ $r = Convert::run(new DbSource($a), new JsonTarget($tmp.'/gen'));
477+ $test->assertEquals(['users' => 3, 'posts' => 1200, 'kv' => 3, 'odd/name' => 2], $r['tables']);
478+ $test->assertSame(['db/kv.json', 'db/odd%2Fname.json', 'db/posts.json', 'db/users.json'], array_keys($files($tmp.'/gen')));
479+ $j = json_decode((string)file_get_contents($tmp.'/gen/db/odd%2Fname.json'), true);
480+ $test->assertSame('odd/name', $j['table']);
481+ $test->assertSame([[1, 1.0, ['$blob' => base64_encode("\x00\xff")]], [2, null, null]], $j['rows']);
482+ $r = Convert::run(new JsonSource($tmp.'/gen'), new DbTarget($sqlite('g2.sqlite')));
483+ $test->assertEquals(['users' => 3, 'posts' => 1200, 'kv' => 3, 'odd/name' => 2], array_intersect_key($r['tables'], ['users' => 1, 'posts' => 1, 'kv' => 1, 'odd/name' => 1]));
484+ $b = $sqlite('g2.sqlite');
485+ $same($test, $a, $b, ['users', 'posts', 'kv', '"odd/name"']);
486+ });
487+
488+ $test->it('不正なパス・名前は書かないはず', function($test) use ($tmp, $sqlite, $catch): void {
489+ foreach (['../x', '/etc/passwd', 'a//b', 'a/./b', "a\0b", 'C:/x', 'a\\b', ''] as $p) $test->assertFalse(Stores::safePath($p), $p);
490+ $test->assertTrue(Stores::safePath('ap/users/alice/private.pem'));
491+ $test->assertFalse(Stores::safeThread('../../etc'));
492+ $test->assertTrue(Stores::safeThread('tutorial/php/basics'));
493+ $db = $sqlite('evil.sqlite');
494+ $db->query('CREATE TABLE lb_files (path VARCHAR(700) NOT NULL PRIMARY KEY, content BLOB NOT NULL)');
495+ $db->query("INSERT INTO lb_files VALUES ('../evil.txt', X'41')");
496+ $e = $catch(fn() => Convert::run(new DbSource($db), new JsonTarget($tmp.'/evil')));
497+ $test->assertTrue($e instanceof DbException);
498+ $test->assertFalse(is_file($tmp.'/evil.txt'));
499+ });
500+});
501+
502+$test->printSummary();