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 / search-replace-repository.php
backup-backup / includes / database Last commit date
export 3 months ago better-backup-v3.php 3 months ago better-backup.php 3 months ago better-restore.php 3 months ago even-better-restore-v3.php 3 months ago even-better-restore-v4.php 3 months ago interface-search-replace-repository.php 3 months ago manager.php 3 months ago search-replace-processor.php 3 months ago search-replace-repository.php 3 months ago search-replace-stack-based.php 3 months ago search-replace-v2.php 3 months ago search-replace.php 3 months ago smart-sort.php 3 months ago
search-replace-repository.php
478 lines
1 <?php
2
3 namespace BMI\Plugin\Database;
4
5 require_once BMI_INCLUDES . '/database/interface-search-replace-repository.php';
6
7 /**
8 * Class SearchReplaceRepository
9 *
10 * Handles database interactions for the search and replace functionality.
11 * Supports both direct MySQLi connection and fallback to global $wpdb.
12 */
13 class SearchReplaceRepository implements SearchReplaceRepositoryInterface
14 {
15 /**
16 * @var \mysqli|null The MySQLi connection instance.
17 */
18 private $mysqli = null;
19
20 /**
21 * @var bool Whether to use the MySQLi connection.
22 */
23 private $useMysqli = false;
24
25 /**
26 * @var string|null The last error message.
27 */
28 private $lastError = null;
29
30 /**
31 * Constructor.
32 *
33 * Initializes the database connection.
34 */
35 public function __construct()
36 {
37 $this->connect();
38 }
39
40 /**
41 * Establishes a connection to the database.
42 *
43 * Tries to connect using MySQLi with credentials from wp-config.php.
44 * Falls back to $wpdb if MySQLi connection fails or extension is missing.
45 */
46 private function connect()
47 {
48 if (!$this->canUseMysqli()) {
49 return;
50 }
51
52 $connectionParams = $this->parseHostString(DB_HOST);
53
54 try {
55 $this->mysqli = new \mysqli(
56 $connectionParams['host'],
57 DB_USER,
58 DB_PASSWORD,
59 DB_NAME,
60 $connectionParams['port'],
61 $connectionParams['socket']
62 );
63
64 if ($this->mysqli->connect_errno) {
65 $this->useMysqli = false;
66 $this->mysqli = null;
67 return;
68 }
69
70 $this->useMysqli = true;
71 $this->setCharset();
72 } catch (\Exception $e) {
73 $this->useMysqli = false;
74 $this->mysqli = null;
75 }
76 }
77
78 /**
79 * Checks if MySQLi extension can be used.
80 *
81 * @return bool True if MySQLi is available and required constants are defined.
82 */
83 private function canUseMysqli()
84 {
85 return extension_loaded('mysqli')
86 && defined('DB_HOST')
87 && defined('DB_USER')
88 && defined('DB_PASSWORD')
89 && defined('DB_NAME');
90 }
91
92 /**
93 * Parses the host string to extract host, port, and socket.
94 *
95 * @param string $hostString The host string from DB_HOST.
96 * @return array{host: string, port: int|null, socket: string|null}
97 */
98 private function parseHostString($hostString)
99 {
100 $host = $hostString;
101 $port = null;
102 $socket = null;
103
104 if (strpos($hostString, ':') !== false) {
105 $parts = explode(':', $hostString, 2);
106 $host = $parts[0];
107 $portOrSocket = $parts[1];
108
109 if (is_numeric($portOrSocket)) {
110 $port = (int) $portOrSocket;
111 } else {
112 $socket = $portOrSocket;
113 }
114 }
115
116 return [
117 'host' => $host,
118 'port' => $port,
119 'socket' => $socket
120 ];
121 }
122
123 /**
124 * Sets the character set for the MySQLi connection.
125 */
126 private function setCharset()
127 {
128 global $wpdb;
129 if (!empty($wpdb->charset) && $this->mysqli !== null) {
130 $this->mysqli->set_charset($wpdb->charset);
131 }
132 }
133
134 /**
135 * {@inheritdoc}
136 */
137 public function escape($string)
138 {
139 if ($this->useMysqli && $this->mysqli !== null) {
140 return $this->mysqli->real_escape_string($string);
141 }
142
143 global $wpdb;
144 return $wpdb->_real_escape($string);
145 }
146
147 /**
148 * {@inheritdoc}
149 */
150 public function getLastError()
151 {
152 return $this->lastError;
153 }
154
155 /**
156 * Retrieves the total row count of a table.
157 *
158 * {@inheritdoc}
159 */
160 public function getTableRowCount($table, $primaryKey, $whereClause = '')
161 {
162 $escapedTable = $this->escapeIdentifier($table);
163
164 // Try with PRIMARY index hint first
165 if ($primaryKey !== false) {
166 $result = $this->tryCountWithIndex($escapedTable, 'PRIMARY', $whereClause);
167 if ($result !== null) {
168 return $result;
169 }
170
171 // Try with primary key column name as index
172 $result = $this->tryCountWithIndex($escapedTable, $primaryKey, $whereClause);
173 if ($result !== null) {
174 return $result;
175 }
176 }
177
178 // Fallback: count without index hint
179 return $this->executeCount($escapedTable, '', $whereClause);
180 }
181
182 /**
183 * Tries to count rows using a specific index hint.
184 *
185 * @param string $table The escaped table name.
186 * @param string $indexName The index name to use.
187 * @param string $whereClause Optional WHERE clause.
188 * @return int|null The count, or null if query failed.
189 */
190 private function tryCountWithIndex($table, $indexName, $whereClause)
191 {
192 $indexHint = ' USE INDEX(`' . $indexName . '`)';
193 $count = $this->executeCount($table, $indexHint, $whereClause);
194
195 if ($this->lastError === null) {
196 return $count;
197 }
198
199 return null;
200 }
201
202 /**
203 * Executes a COUNT query.
204 *
205 * @param string $table The escaped table name.
206 * @param string $indexHint Optional index hint.
207 * @param string $whereClause Optional WHERE clause.
208 * @return int The count result.
209 */
210 private function executeCount($table, $indexHint, $whereClause)
211 {
212 $sql = 'SELECT COUNT(*) AS num FROM ' . $table . $indexHint;
213
214 if (!empty($whereClause)) {
215 $sql .= ' WHERE ' . $whereClause;
216 }
217
218 $result = $this->query($sql);
219
220 if (!empty($result) && isset($result[0]['num'])) {
221 return (int) $result[0]['num'];
222 }
223
224 return 0;
225 }
226
227 /**
228 * Escapes a SQL identifier (table/column name).
229 *
230 * @param string $identifier The identifier to escape.
231 * @return string The escaped identifier.
232 */
233 private function escapeIdentifier($identifier)
234 {
235 return '`' . str_replace('`', '``', $identifier) . '`';
236 }
237
238 /**
239 * {@inheritdoc}
240 */
241 public function getTableColumnsInfo($table)
242 {
243 $fields = $this->query('DESCRIBE `' . str_replace('`', '``', $table) . '`');
244
245 $primaryKey = false;
246 $autoIncrementPrimaryKey = false;
247 $textColumns = [];
248
249 if (!is_array($fields)) {
250 return $this->buildColumnsInfoResult($primaryKey, $autoIncrementPrimaryKey, $textColumns);
251 }
252
253 foreach ($fields as $fieldData) {
254 $columnInfo = $this->parseColumnInfo($fieldData, $table);
255
256 if ($columnInfo['skip']) {
257 continue;
258 }
259
260 if ($columnInfo['is_primary'] && $primaryKey === false) {
261 $primaryKey = $columnInfo['name'];
262 }
263
264 if ($columnInfo['is_auto_increment_primary'] && $autoIncrementPrimaryKey === false) {
265 $autoIncrementPrimaryKey = $columnInfo['name'];
266 }
267
268 if ($columnInfo['is_text']) {
269 $textColumns[] = $columnInfo['name'];
270 }
271 }
272
273 return $this->buildColumnsInfoResult($primaryKey, $autoIncrementPrimaryKey, $textColumns);
274 }
275
276 /**
277 * Parses column information from DESCRIBE result.
278 *
279 * @param array $fieldData The field data from DESCRIBE.
280 * @param string $table The table name.
281 * @return array Parsed column information.
282 */
283 private function parseColumnInfo($fieldData, $table)
284 {
285 $field = $fieldData['Field'];
286 $key = $fieldData['Key'];
287 $extra = $fieldData['Extra'];
288 $type = strtolower($fieldData['Type']);
289
290 // Skip guid field in posts table
291 $skip = (strpos($table, 'posts') !== false && $field === 'guid');
292
293 $isPrimary = ($key === 'PRI');
294 $isAutoIncrementPrimary = ($isPrimary && strpos($extra, 'auto_increment') !== false);
295 $isText = (strpos($type, 'char') !== false || strpos($type, 'text') !== false);
296
297 return [
298 'name' => $field,
299 'skip' => $skip,
300 'is_primary' => $isPrimary,
301 'is_auto_increment_primary' => $isAutoIncrementPrimary,
302 'is_text' => $isText
303 ];
304 }
305
306 /**
307 * Builds the columns info result array.
308 *
309 * @param string|false $primaryKey The primary key.
310 * @param string|false $autoIncrementPrimaryKey The auto-increment primary key.
311 * @param array $textColumns The text columns.
312 * @return array The result array.
313 */
314 private function buildColumnsInfoResult($primaryKey, $autoIncrementPrimaryKey, $textColumns)
315 {
316 return [
317 'primary_key' => $primaryKey,
318 'auto_increment_primary_key' => $autoIncrementPrimaryKey,
319 'text_columns' => $textColumns
320 ];
321 }
322
323 /**
324 * {@inheritdoc}
325 */
326 public function fetchRows($sql)
327 {
328 $result = $this->query($sql);
329 return is_array($result) ? $result : [];
330 }
331
332 /**
333 * {@inheritdoc}
334 */
335 public function executeTransaction($queries)
336 {
337 if (empty($queries)) {
338 return ['updates' => 0, 'errors' => []];
339 }
340
341 if ($this->useMysqli && $this->mysqli !== null) {
342 return $this->executeMysqliTransaction($queries);
343 }
344
345 return $this->executeWpdbTransaction($queries);
346 }
347
348 /**
349 * Executes transaction using MySQLi.
350 *
351 * @param array $queries The queries to execute.
352 * @return array The result with updates count and errors.
353 */
354 private function executeMysqliTransaction($queries)
355 {
356 $errors = [];
357 $updates = 0;
358
359 $this->mysqli->begin_transaction();
360
361 foreach ($queries as $sql) {
362 if ($this->mysqli->query($sql) === true) {
363 $updates++;
364 } else {
365 $errors[] = $this->mysqli->error;
366 }
367 }
368
369 $this->mysqli->commit();
370
371 return ['updates' => $updates, 'errors' => $errors];
372 }
373
374 /**
375 * Executes transaction using $wpdb.
376 *
377 * @param array $queries The queries to execute.
378 * @return array The result with updates count and errors.
379 */
380 private function executeWpdbTransaction($queries)
381 {
382 global $wpdb;
383 $errors = [];
384 $updates = 0;
385
386 $wpdb->query('START TRANSACTION');
387
388 foreach ($queries as $sql) {
389 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared –– $sql is assumed to be safe here
390 $wpdb->query($sql);
391 if ($wpdb->last_error === '') {
392 $updates++;
393 } else {
394 $errors[] = $wpdb->last_error;
395 }
396 }
397
398 $wpdb->query('COMMIT');
399
400 return ['updates' => $updates, 'errors' => $errors];
401 }
402
403 /**
404 * Executes a SQL query using the active connection.
405 *
406 * @param string $sql The SQL query to execute.
407 * @return array|bool The query result (array of rows, true for success, or empty array on failure).
408 */
409 private function query($sql)
410 {
411 $this->lastError = null;
412
413 if ($this->useMysqli && $this->mysqli !== null) {
414 return $this->executeMysqliQuery($sql);
415 }
416
417 return $this->executeWpdbQuery($sql);
418 }
419
420 /**
421 * Executes query using MySQLi.
422 *
423 * @param string $sql The SQL query.
424 * @return array|bool The result.
425 */
426 private function executeMysqliQuery($sql)
427 {
428 $result = $this->mysqli->query($sql);
429
430 if ($result === false) {
431 $this->lastError = $this->mysqli->error;
432 return [];
433 }
434
435 if ($result === true) {
436 return true;
437 }
438
439 $data = [];
440 while ($row = $result->fetch_assoc()) {
441 $data[] = $row;
442 }
443 $result->free();
444
445 return $data;
446 }
447
448 /**
449 * Executes query using $wpdb.
450 *
451 * @param string $sql The SQL query.
452 * @return array|null The result.
453 */
454 private function executeWpdbQuery($sql)
455 {
456 global $wpdb;
457 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared –– $sql is assumed to be safe here
458 $result = $wpdb->get_results($sql, ARRAY_A);
459
460 if ($wpdb->last_error !== '') {
461 $this->lastError = $wpdb->last_error;
462 }
463
464 return $result;
465 }
466
467 /**
468 * Closes the MySQLi connection if open.
469 */
470 public function __destruct()
471 {
472 if ($this->mysqli !== null && $this->useMysqli) {
473 $this->mysqli->close();
474 }
475 }
476
477 }
478