PluginProbe ʕ •ᴥ•ʔ
Backup Migration / 2.1.2
Backup Migration v2.1.2
2.1.6 2.1.5.2 trunk 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.6.1 1.4.7 1.4.8 1.4.9 1.4.9.1 2.0.0 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.5.1
backup-backup / includes / database / export / class-database-export-repository.php
backup-backup / includes / database / export Last commit date
class-database-export-engine.php 3 months ago class-database-export-processor.php 3 months ago class-database-export-repository.php 3 months ago interface-database-export-repository.php 3 months ago
class-database-export-repository.php
530 lines
1 <?php
2
3 namespace BMI\Plugin\Database\Export;
4
5 require_once __DIR__ . '/interface-database-export-repository.php';
6
7 /**
8 * Class DatabaseExportRepository
9 *
10 * Handles database interactions for the database export functionality.
11 * Supports both direct MySQLi connection (preferred) and fallback to global $wpdb.
12 *
13 * MySQLi is preferred because:
14 * - Direct connection avoids $wpdb overhead
15 * - real_escape_string is faster than esc_sql for bulk operations
16 * - fetch_array(MYSQLI_NUM) yields numerically-indexed rows with minimal memory
17 * - Unbuffered queries can stream results directly to files
18 */
19 class DatabaseExportRepository implements DatabaseExportRepositoryInterface
20 {
21 /**
22 * @var \mysqli|null The MySQLi connection instance.
23 */
24 private $mysqli = null;
25
26 /**
27 * @var bool Whether to use the MySQLi connection.
28 */
29 private $useMysqli = false;
30
31 /**
32 * @var string|null The last error message.
33 */
34 private $lastError = null;
35
36 /**
37 * Constructor.
38 *
39 * Initializes the database connection, preferring MySQLi.
40 */
41 public function __construct()
42 {
43 $this->connect();
44 }
45
46 /**
47 * Establishes a connection to the database.
48 *
49 * Tries MySQLi first; falls back to $wpdb if unavailable or connection fails.
50 */
51 private function connect()
52 {
53 if (!$this->canUseMysqli()) {
54 return;
55 }
56
57 $connectionParams = $this->parseHostString(DB_HOST);
58
59 try {
60 $this->mysqli = new \mysqli(
61 $connectionParams['host'],
62 DB_USER,
63 DB_PASSWORD,
64 DB_NAME,
65 $connectionParams['port'],
66 $connectionParams['socket']
67 );
68
69 if ($this->mysqli->connect_errno) {
70 $this->useMysqli = false;
71 $this->mysqli = null;
72 return;
73 }
74
75 $this->useMysqli = true;
76 $this->setCharset();
77 } catch (\Exception $e) {
78 $this->useMysqli = false;
79 $this->mysqli = null;
80 }
81 }
82
83 /**
84 * Checks if MySQLi extension can be used.
85 *
86 * @return bool True if MySQLi is available and required constants are defined.
87 */
88 private function canUseMysqli()
89 {
90 return extension_loaded('mysqli')
91 && defined('DB_HOST')
92 && defined('DB_USER')
93 && defined('DB_PASSWORD')
94 && defined('DB_NAME');
95 }
96
97 /**
98 * Parses the host string to extract host, port, and socket.
99 *
100 * Handles formats like: "localhost", "localhost:3306", "localhost:/tmp/mysql.sock"
101 *
102 * @param string $hostString The host string from DB_HOST.
103 * @return array{host: string, port: int|null, socket: string|null}
104 */
105 private function parseHostString($hostString)
106 {
107 $host = $hostString;
108 $port = null;
109 $socket = null;
110
111 if (strpos($hostString, ':') !== false) {
112 $parts = explode(':', $hostString, 2);
113 $host = $parts[0];
114 $portOrSocket = $parts[1];
115
116 if (is_numeric($portOrSocket)) {
117 $port = (int) $portOrSocket;
118 } else {
119 $socket = $portOrSocket;
120 }
121 }
122
123 return [
124 'host' => $host,
125 'port' => $port,
126 'socket' => $socket
127 ];
128 }
129
130 /**
131 * Sets the character set for the MySQLi connection based on WordPress config.
132 */
133 private function setCharset()
134 {
135 global $wpdb;
136 if (!empty($wpdb->charset) && $this->mysqli !== null) {
137 $this->mysqli->set_charset($wpdb->charset);
138 }
139 }
140
141 /**
142 * {@inheritdoc}
143 */
144 public function escape($string)
145 {
146 if ($string === null) {
147 return '';
148 }
149
150 if ($this->useMysqli && $this->mysqli !== null) {
151 return $this->mysqli->real_escape_string($string);
152 }
153
154 return esc_sql($string);
155 }
156
157 /**
158 * {@inheritdoc}
159 */
160 public function escapeIdentifier($identifier)
161 {
162 return '`' . str_replace('`', '``', $identifier) . '`';
163 }
164
165 /**
166 * {@inheritdoc}
167 */
168 public function getLastError()
169 {
170 return $this->lastError;
171 }
172
173 /**
174 * {@inheritdoc}
175 */
176 public function getTableNames()
177 {
178 $result = $this->query('SHOW TABLES');
179
180 if (!is_array($result)) {
181 return [];
182 }
183
184 $tables = [];
185 foreach ($result as $row) {
186 // SHOW TABLES returns rows with a single column
187 $values = array_values($row);
188 if (!empty($values)) {
189 $tables[] = $values[0];
190 }
191 }
192
193 return $tables;
194 }
195
196 /**
197 * {@inheritdoc}
198 */
199 public function getTableInfo($table, $dbName)
200 {
201 $escapedTable = $this->escape($table);
202 $escapedDb = $this->escape($dbName);
203
204 $sql = "SELECT table_name AS `table`, "
205 . "ROUND(((data_length + index_length) / 1024 / 1024), 2) AS `size` "
206 . "FROM information_schema.TABLES "
207 . "WHERE table_schema = '" . $escapedDb . "' AND table_name = '" . $escapedTable . "'";
208
209 $result = $this->query($sql);
210
211 if (empty($result) || !isset($result[0])) {
212 return null;
213 }
214
215 $row = $result[0];
216
217 // Get actual row count with COUNT(*)
218 $countSql = 'SELECT COUNT(*) AS cnt FROM ' . $this->escapeIdentifier($table);
219 $countResult = $this->query($countSql);
220 $rowCount = 0;
221 if (!empty($countResult) && isset($countResult[0]['cnt'])) {
222 $rowCount = (int) $countResult[0]['cnt'];
223 }
224
225 return [
226 'size' => floatval($row['size']),
227 'rows' => $rowCount
228 ];
229 }
230
231 /**
232 * {@inheritdoc}
233 */
234 public function getTableColumnsInfo($table)
235 {
236 $fields = $this->query('DESCRIBE ' . $this->escapeIdentifier($table));
237
238 $primaryKey = false;
239 $autoIncrementPrimaryKey = false;
240 $columns = [];
241
242 if (!is_array($fields)) {
243 return [
244 'primary_key' => $primaryKey,
245 'auto_increment_primary_key' => $autoIncrementPrimaryKey,
246 'columns' => $columns
247 ];
248 }
249
250 foreach ($fields as $fieldData) {
251 $field = $fieldData['Field'];
252 $key = $fieldData['Key'];
253 $extra = $fieldData['Extra'];
254 $type = strtolower($fieldData['Type']);
255
256 $isPrimary = ($key === 'PRI');
257 $isAutoIncrement = ($isPrimary && strpos($extra, 'auto_increment') !== false);
258
259 if ($isPrimary && $primaryKey === false) {
260 $primaryKey = $field;
261 }
262
263 if ($isAutoIncrement && $autoIncrementPrimaryKey === false) {
264 $autoIncrementPrimaryKey = $field;
265 }
266
267 $columns[] = [
268 'name' => $field,
269 'type' => $type,
270 'is_numeric' => $this->isNumericType($type),
271 ];
272 }
273
274 return [
275 'primary_key' => $primaryKey,
276 'auto_increment_primary_key' => $autoIncrementPrimaryKey,
277 'columns' => $columns
278 ];
279 }
280
281 /**
282 * {@inheritdoc}
283 */
284 public function getCreateTableStatement($table)
285 {
286 $result = $this->query('SHOW CREATE TABLE ' . $this->escapeIdentifier($table));
287
288 if (empty($result) || !isset($result[0])) {
289 return null;
290 }
291
292 $row = $result[0];
293
294 // SHOW CREATE TABLE returns 'Create Table' column
295 if (isset($row['Create Table'])) {
296 return $row['Create Table'];
297 }
298
299 // Fallback: try second column (some drivers return different key names)
300 $values = array_values($row);
301 return isset($values[1]) ? $values[1] : null;
302 }
303
304 /**
305 * {@inheritdoc}
306 *
307 * Uses MySQLi unbuffered queries when available to stream results
308 * with minimal memory footprint. Falls back to $wpdb buffered results.
309 */
310 public function fetchRows($sql, $useUnbuffered = true)
311 {
312 $this->lastError = null;
313
314 if ($this->useMysqli && $this->mysqli !== null) {
315 return $this->fetchMysqli($sql, $useUnbuffered);
316 }
317
318 return $this->fetchWpdb($sql);
319 }
320
321 /**
322 * {@inheritdoc}
323 */
324 public function getRowCount($table, $whereClause = '')
325 {
326 $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->escapeIdentifier($table);
327 if (!empty($whereClause)) {
328 $sql .= ' WHERE ' . $whereClause;
329 }
330
331 $result = $this->query($sql);
332
333 if (!empty($result) && isset($result[0]['cnt'])) {
334 return (int) $result[0]['cnt'];
335 }
336
337 return 0;
338 }
339
340 /**
341 * {@inheritdoc}
342 */
343 public function execute($sql)
344 {
345 $this->lastError = null;
346
347 if ($this->useMysqli && $this->mysqli !== null) {
348 $result = $this->mysqli->query($sql);
349 if ($result === false) {
350 $this->lastError = $this->mysqli->error;
351 return false;
352 }
353 return true;
354 }
355
356 global $wpdb;
357 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Callers control the SQL
358 $result = $wpdb->query($sql);
359 if ($wpdb->last_error !== '') {
360 $this->lastError = $wpdb->last_error;
361 return false;
362 }
363
364 return $result !== false;
365 }
366
367 /**
368 * Fetches rows via MySQLi as numerically-indexed arrays using a generator.
369 *
370 * Uses buffered query to be safe with multiple queries, but yields rows
371 * one at a time to avoid keeping everything in memory.
372 *
373 * @param string $sql The SQL query.
374 * @return \Generator Yields rows as numerically-indexed arrays.
375 */
376 private function fetchMysqli($sql, $useUnbuffered = true)
377 {
378 $resultModeFlag = $useUnbuffered ? MYSQLI_USE_RESULT : MYSQLI_STORE_RESULT;
379 $result = $this->mysqli->query($sql, $resultModeFlag);
380
381 if ($result === false) {
382 $this->lastError = $this->mysqli->error;
383 return;
384 }
385
386 if ($result === true) {
387 return;
388 }
389
390 while ($row = $result->fetch_array(MYSQLI_NUM)) {
391 yield $row;
392 }
393
394 // Detect errors that occurred mid-stream (e.g., connection timeout)
395 if ($this->mysqli->errno) {
396 $this->lastError = $this->mysqli->error;
397 }
398
399 $result->free();
400 }
401
402 /**
403 * Fetches rows via $wpdb as numerically-indexed arrays using a generator.
404 *
405 * @param string $sql The SQL query.
406 * @return \Generator Yields rows as numerically-indexed arrays.
407 */
408 private function fetchWpdb($sql)
409 {
410 global $wpdb;
411
412 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is built by callers
413 $results = $wpdb->get_results($sql, ARRAY_N);
414
415 if ($wpdb->last_error !== '') {
416 $this->lastError = $wpdb->last_error;
417 return;
418 }
419
420 if (!is_array($results)) {
421 return;
422 }
423
424 foreach ($results as $row) {
425 yield $row;
426 }
427 }
428
429 /**
430 * Determines if a MySQL column type is numeric.
431 *
432 * @param string $type The lowercase column type string.
433 * @return bool True if the type is numeric.
434 */
435 private function isNumericType($type)
436 {
437 $numericTypes = ['int', 'decimal', 'float', 'double', 'real', 'bit', 'numeric'];
438 foreach ($numericTypes as $numType) {
439 if (strpos($type, $numType) !== false) {
440 return true;
441 }
442 }
443 return false;
444 }
445
446 /**
447 * Executes a SQL query using the active connection.
448 *
449 * @param string $sql The SQL query to execute.
450 * @return array|bool The query result as array of associative arrays, or true/empty array.
451 */
452 private function query($sql)
453 {
454 $this->lastError = null;
455
456 if ($this->useMysqli && $this->mysqli !== null) {
457 return $this->executeMysqliQuery($sql);
458 }
459
460 return $this->executeWpdbQuery($sql);
461 }
462
463 /**
464 * Executes query using MySQLi.
465 *
466 * @param string $sql The SQL query.
467 * @return array|bool The result.
468 */
469 private function executeMysqliQuery($sql)
470 {
471 $result = $this->mysqli->query($sql);
472
473 if ($result === false) {
474 $this->lastError = $this->mysqli->error;
475 return [];
476 }
477
478 if ($result === true) {
479 return true;
480 }
481
482 $data = [];
483 while ($row = $result->fetch_assoc()) {
484 $data[] = $row;
485 }
486 $result->free();
487
488 return $data;
489 }
490
491 /**
492 * Executes query using $wpdb.
493 *
494 * @param string $sql The SQL query.
495 * @return array|null The result.
496 */
497 private function executeWpdbQuery($sql)
498 {
499 global $wpdb;
500 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is built by callers
501 $result = $wpdb->get_results($sql, ARRAY_A);
502
503 if ($wpdb->last_error !== '') {
504 $this->lastError = $wpdb->last_error;
505 }
506
507 return $result;
508 }
509
510 /**
511 * Whether the repository is using MySQLi.
512 *
513 * @return bool
514 */
515 public function isMysqli()
516 {
517 return $this->useMysqli;
518 }
519
520 /**
521 * Closes the MySQLi connection if open.
522 */
523 public function __destruct()
524 {
525 if ($this->mysqli !== null && $this->useMysqli) {
526 $this->mysqli->close();
527 }
528 }
529 }
530