master
ブランチ master
LittleBeast / src / Std / Lib / Db / Convert / DbSource.php
11.1 KB、246行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, 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
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib\Db\Convert;
39
40use Std\Lib\Db;
41use Std\Lib\Db\Convert;
42use 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 */
49final 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}