PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 3.0.2
WP STAGING – WordPress Backups, Restore, Migration & Clone v3.0.2
4.11.2 4.11.1 4.11.0 4.10.0 4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Backend / Modules / Jobs / SearchReplace.php
wp-staging / Backend / Modules / Jobs Last commit date
Cleaners 5 years ago Exceptions 5 years ago Cancel.php 3 years ago CancelUpdate.php 4 years ago Cloning.php 3 years ago CloningProcess.php 3 years ago Data.php 3 years ago Database.php 3 years ago Delete.php 3 years ago Directories.php 3 years ago Files.php 3 years ago Finish.php 3 years ago Job.php 3 years ago JobExecutable.php 3 years ago Logs.php 3 years ago PreserveDataFirstStep.php 3 years ago PreserveDataSecondStep.php 3 years ago ProcessLock.php 3 years ago Scan.php 3 years ago SearchReplace.php 3 years ago TotalStepsAreNumberOfTables.php 5 years ago Updating.php 3 years ago Verify.php 3 years ago
SearchReplace.php
637 lines
1 <?php
2
3 namespace WPStaging\Backend\Modules\Jobs;
4
5 use stdClass;
6 use wpdb;
7 use WPStaging\Core\WPStaging;
8 use WPStaging\Core\Utils\Logger;
9 use WPStaging\Core\Utils\Multisite;
10 use WPStaging\Framework\SiteInfo;
11 use WPStaging\Framework\Traits\DatabaseSearchReplaceTrait;
12 use WPStaging\Framework\Traits\DbRowsGeneratorTrait;
13 use WPStaging\Framework\Utils\Strings;
14 use WPStaging\Framework\Utils\Escape;
15
16 /**
17 * Class SearchReplace
18 *
19 * Used for CLONING
20 * @see \WPStaging\Backend\Pro\Modules\Jobs\SearchReplace Used for PUSHING
21 *
22 * @todo Unify those
23 *
24 * @package WPStaging\Backend\Modules\Jobs
25 */
26 class SearchReplace extends CloningProcess
27 {
28 use TotalStepsAreNumberOfTables;
29 use DbRowsGeneratorTrait;
30 use DatabaseSearchReplaceTrait;
31
32 /**
33 * The maximum number of failed attempts after which the Job should just move on.
34 *
35 * @var int
36 */
37 protected $maxFailedAttempts = 10;
38
39 /**
40 * The number of processed items, or `null` if the job did not run yet.
41 *
42 * @var int|null
43 */
44 protected $processed;
45
46 /**
47 * @var int
48 */
49 private $total = 0;
50
51 /**
52 *
53 * @var string
54 */
55 private $sourceHostname;
56
57 /**
58 *
59 * @var string
60 */
61 private $destinationHostname;
62
63 /**
64 *
65 * @var Strings
66 */
67 private $strings;
68
69 /**
70 * The prefix of the new database tables which are used for the live site after updating tables
71 * @var string
72 */
73 public $tmpPrefix;
74
75 /**
76 * Initialize
77 */
78 public function initialize()
79 {
80 $this->initializeDbObjects();
81 $this->total = count($this->options->tables);
82 $this->tmpPrefix = $this->options->prefix;
83 $this->strings = new Strings();
84 $this->sourceHostname = $this->getSourceHostname();
85 $this->destinationHostname = $this->getDestinationHostname();
86 }
87
88 public function start()
89 {
90 // Skip job. Nothing to do
91 if ($this->options->totalSteps === 0) {
92 $this->prepareResponse(true, false);
93 }
94
95 $this->run();
96
97 // Save option, progress
98 $this->saveOptions();
99
100 return (object)$this->response;
101 }
102
103 /**
104 * Execute the Current Step
105 * Returns false when over threshold limits are hit or when the job is done, true otherwise
106 * @return bool
107 */
108 protected function execute()
109 {
110 // Over limits threshold
111 if ($this->isOverThreshold()) {
112 // Prepare response and save current progress
113 $this->prepareResponse(false, false);
114 $this->saveOptions();
115 return false;
116 }
117
118 // No more steps, finished
119 if ($this->options->currentStep > $this->total || !isset($this->options->tables[$this->options->currentStep])) {
120 $this->prepareResponse(true, false);
121 return false;
122 }
123
124 // Table is excluded
125 if (in_array($this->options->tables[$this->options->currentStep], $this->options->excludedTables)) {
126 $this->prepareResponse();
127 return true;
128 }
129
130 // Search & Replace
131 if (!$this->updateTable($this->options->tables[$this->options->currentStep])) {
132 // Prepare Response
133 $this->prepareResponse(false, false);
134
135 // Not finished
136 return true;
137 }
138
139
140 // Prepare Response
141 $this->prepareResponse();
142
143 // Not finished
144 return true;
145 }
146
147 /**
148 * Copy Tables
149 * @param string $tableName
150 * @return bool
151 */
152 private function updateTable($tableName)
153 {
154 $strings = new Strings();
155 $table = $strings->str_replace_first(WPStaging::getTablePrefix(), '', $tableName);
156 $newTableName = $this->tmpPrefix . $table;
157
158 // Save current job
159 $this->setJob($newTableName);
160
161 // Beginning of the job
162 if (!$this->startJob($newTableName, $tableName)) {
163 return true;
164 }
165 // Copy data
166 $this->startReplace($newTableName);
167
168 // Finish the step
169 return $this->finishStep();
170 }
171
172 /**
173 * Get destination hostname without scheme e.g example.com/staging or staging.example.com
174 *
175 * Conditions:
176 * - Main job is 'update'
177 * - WP installed in sub dir
178 * - Target hostname in advanced settings defined (Pro version only)
179 *
180 * @return string
181 * @todo Complex conditions. Might need refactor
182 */
183 private function getDestinationHostname()
184 {
185 // Update process: Neither 'push' nor 'clone'
186 if ($this->options->mainJob === 'updating') {
187 // Defined and created in advanced settings with pro version
188 if (!empty($this->options->cloneHostname)) {
189 return $this->strings->getUrlWithoutScheme($this->options->cloneHostname);
190 }
191 return $this->strings->getUrlWithoutScheme($this->options->destinationHostname);
192 }
193
194 // Clone process: Defined and created in advanced settings with pro version
195 if (!empty($this->options->cloneHostname)) {
196 return $this->strings->getUrlWithoutScheme($this->options->cloneHostname);
197 }
198
199 // Clone process: WP installed in sub directory under root
200 if ($this->isSubDir()) {
201 return $this->strings->getUrlWithoutScheme(trailingslashit($this->options->destinationHostname) . $this->getSubDir() . '/' . $this->options->cloneDirectoryName);
202 }
203
204 if ($this->isMultisiteAndPro()) {
205 $multisiteHostname = (new Multisite())->getHomeDomainWithoutScheme();
206 // Relative path to root of main multisite without leading or trailing slash e.g.: wordpress
207 $multisitePath = defined('PATH_CURRENT_SITE') ? PATH_CURRENT_SITE : '/';
208
209 return rtrim($multisiteHostname, '/\\') . $multisitePath . $this->options->cloneDirectoryName;
210 }
211
212 // Clone process: Default
213 return $this->strings->getUrlWithoutScheme(trailingslashit($this->options->destinationHostname) . $this->options->cloneDirectoryName);
214 }
215
216 /**
217 * Start search replace job
218 * @param string $table
219 */
220 private function startReplace($table)
221 {
222 $rows = $this->options->job->start + $this->settings->querySRLimit;
223
224 if ((int)$this->settings->querySRLimit <= 1) {
225 $this->logDebug(sprintf('%s - $this->settings->querySRLimit is too low. Typeof: %s. JSON Encoded Value: %s', __METHOD__, gettype($this->settings->querySRLimit), wp_json_encode($this->settings->querySRLimit)));
226 }
227
228 if ((int)$rows <= 1) {
229 $this->logDebug(sprintf('%s - $rows is too low.', __METHOD__));
230 }
231
232 $this->log(
233 "DB Search & Replace: Table {$table} {$this->options->job->start} to {$rows} records"
234 );
235
236 // Search & Replace
237 $this->searchReplace($table, []);
238
239 if (defined('WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR') && WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR) {
240 $this->options->job->start += $this->settings->querySRLimit;
241 }
242 }
243
244 /**
245 * Gets the columns in a table.
246 * @access public
247 * @param string $table The table to check.
248 * @return array|false Either the primary key and columns structures, or `false` to indicate the query
249 * failed or the table is not describe-able.
250 */
251 protected function getColumns($table)
252 {
253 $primaryKeys = [];
254 $columns = [];
255 $fields = $this->stagingDb->get_results('DESCRIBE ' . $table);
256
257 if (empty($fields)) {
258 // Either there was an error or the table has no columns.
259 return false;
260 }
261
262 if (is_array($fields)) {
263 foreach ($fields as $column) {
264 $columns[] = $column->Field;
265 if ($column->Key === 'PRI') {
266 $primaryKeys[] = $column->Field;
267 }
268 }
269 }
270
271 return [$primaryKeys, $columns];
272 }
273
274 /**
275 *
276 * @param string $table The table to run the replacement on.
277 * @param array $args An associative array containing arguments for this run.
278 * @return bool Whether the search-replace operation was successful or not.
279 */
280 private function searchReplace($table, $args)
281 {
282 $table = esc_sql($table);
283
284 $args['search_for'] = $this->generateHostnamePatterns($this->sourceHostname);
285 $args['search_for'][] = ABSPATH;
286
287 $args['replace_with'] = $this->generateHostnamePatterns($this->destinationHostname);
288 $args['replace_with'][] = $this->options->destinationDir;
289
290 $this->debugLog("DB Search & Replace: Search: {$args['search_for'][0]}", Logger::TYPE_INFO);
291 $this->debugLog("DB Search & Replace: Replace: {$args['replace_with'][0]}", Logger::TYPE_INFO);
292
293 $args['replace_guids'] = 'off';
294 $args['dry_run'] = 'off';
295 $args['case_insensitive'] = false;
296 $args['skip_transients'] = 'on';
297
298 // Allow filtering of search & replace parameters
299 $args = apply_filters('wpstg_clone_searchreplace_params', $args);
300
301 // Get columns and primary keys
302 $primaryKeyAndColumns = $this->getColumns($table);
303
304 if (false === $primaryKeyAndColumns) {
305 // Stop here: for some reason the table cannot be described or there was an error.
306 ++$this->options->job->failedAttempts;
307 return false;
308 }
309
310 list($primaryKeys, $columns) = $primaryKeyAndColumns;
311
312 if ($this->options->job->current !== $table) {
313 $this->logDebug(sprintf('We are using the LIMITS of a table different than the table we are parsing now. Table being parsed: %s. Table that we are using "start" from: %s. Start: %s', $table, $this->options->job->current, $this->options->job->start));
314 }
315
316 $currentRow = 0;
317 $offset = $this->options->job->start;
318 $limit = $this->settings->querySRLimit;
319
320 /// DEBUG
321 /* $this->logDebug(
322 sprintf(
323 'SearchReplace-beforeRowsGenerator: max-memory-limit=%s; script-memory-limit=%s; memory-usage=%s; execution-time-limit=%s; running-time=%s; is-threshold=%s',
324 $this->getMaxMemoryLimit(),
325 $this->getScriptMemoryLimit(),
326 $this->getMemoryUsage(),
327 $this->findExecutionTimeLimit(),
328 $this->getRunningTime(),
329 ($this->isThreshold() ? 'yes' : 'no')
330 )
331 );*/
332 /// DEBUG
333
334 if (defined('WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR') && WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR) {
335 $data = $this->stagingDb->get_results("SELECT * FROM $table LIMIT $offset, $limit", ARRAY_A);
336 } else {
337 $this->lastFetchedPrimaryKeyValue = property_exists($this->options->job, 'lastProcessedId') ? $this->options->job->lastProcessedId : false;
338 $data = $this->rowsGenerator($table, $offset, $limit, $this->stagingDb);
339 }
340
341 // Filter certain rows (of other plugins)
342 $filter = $this->excludedStrings();
343
344 $filter = apply_filters('wpstg_clone_searchreplace_excl_rows', $filter);
345
346 $processed = 0;
347
348 /// DEBUG
349 /*
350 $this->logDebug(
351 sprintf(
352 'SearchReplace-beforeRowProcessing: max-memory-limit=%s; script-memory-limit=%s; memory-usage=%s; execution-time-limit=%s; running-time=%s; is-threshold=%s',
353 $this->getMaxMemoryLimit(),
354 $this->getScriptMemoryLimit(),
355 $this->getMemoryUsage(),
356 $this->findExecutionTimeLimit(),
357 $this->getRunningTime(),
358 ($this->isThreshold() ? 'yes' : 'no')
359 )
360 );
361 */
362 /// DEBUG
363
364 // Go through the table rows
365 foreach ($data as $row) {
366 $processed++;
367 $currentRow++;
368 $updateSql = [];
369 $whereSql = [];
370 $doUpdate = false;
371
372 if ($this->lastFetchedPrimaryKeyValue !== false) {
373 $this->lastFetchedPrimaryKeyValue = $row[$this->numericPrimaryKey];
374 }
375
376 // Skip rows
377 if (isset($row['option_name']) && in_array($row['option_name'], $filter)) {
378 continue;
379 }
380
381 // Skip transients (There can be thousands of them. Save memory and increase performance)
382 if (
383 isset($row['option_name']) && $args['skip_transients'] === 'on' && strpos($row['option_name'], '_transient')
384 !== false
385 ) {
386 continue;
387 }
388 // Skip rows with more than 5MB to save memory. These rows contain log data or something similiar but never site relevant data
389 if (isset($row['option_value']) && strlen($row['option_value']) >= 5000000) {
390 continue;
391 }
392
393 // Go through the columns
394 foreach ($columns as $column) {
395 $dataRow = $row[$column];
396
397 // Skip column larger than 5MB
398 $size = strlen($dataRow);
399 if ($size >= 5000000) {
400 continue;
401 }
402
403 // Skip primary key column
404 if (in_array($column, $primaryKeys)) {
405 $whereSql[] = $column . ' = "' . WPStaging::make(Escape::class)->mysqlRealEscapeString($dataRow) . '"';
406 continue;
407 }
408
409 // Skip GUIDs by default.
410 if ($args['replace_guids'] !== 'on' && $column === 'guid') {
411 continue;
412 }
413
414 $excludes = apply_filters('wpstg_clone_searchreplace_excl', []);
415 $searchReplace = new \WPStaging\Framework\Database\SearchReplace($args['search_for'], $args['replace_with'], $args['case_insensitive'], $excludes);
416 /** @var SiteInfo */
417 $siteInfo = WPStaging::make(SiteInfo::class);
418 $searchReplace->setWpBakeryActive($siteInfo->isWpBakeryActive());
419 $dataRow = $searchReplace->replaceExtended($dataRow);
420
421 // Something was changed
422 if ($row[$column] !== $dataRow) {
423 $updateSql[] = $column . ' = "' . WPStaging::make(Escape::class)->mysqlRealEscapeString($dataRow) . '"';
424 $doUpdate = true;
425 }
426 }
427
428 // Determine what to do with updates.
429 if ($args['dry_run'] === 'on') {
430 // Don't do anything if a dry run
431 } elseif ($doUpdate && !empty($whereSql)) {
432 // If there are changes to make, run the query.
433 $sql = 'UPDATE ' . $table . ' SET ' . implode(', ', $updateSql) . ' WHERE ' . implode(' AND ', array_filter($whereSql));
434 $result = $this->stagingDb->query($sql);
435
436 if ($result === false) {
437 $partialQuery = substr($sql, 0, 100);
438 $this->log(
439 "Error updating row {$currentRow} SQL: {$partialQuery}",
440 Logger::TYPE_ERROR
441 );
442 }
443 }
444 } // end row loop
445
446 /// DEBUG
447 /* $this->logDebug(
448 sprintf(
449 'SearchReplace-afterRowsProcessing: processed=%s; max-memory-limit=%s; script-memory-limit=%s; memory-usage=%s; execution-time-limit=%s; running-time=%s; is-threshold=%s',
450 $processed,
451 $this->getMaxMemoryLimit(),
452 $this->getScriptMemoryLimit(),
453 $this->getMemoryUsage(),
454 $this->findExecutionTimeLimit(),
455 $this->getRunningTime(),
456 ($this->isThreshold() ? 'yes' : 'no')
457 )
458 );*/
459 /// DEBUG
460
461 unset($row,$updateSql,$whereSql,$sql,$currentRow);
462
463 if (
464 !defined('WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR') ||
465 (defined('WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR') && !WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR)
466 ) {
467 $this->updateJobStart($processed, $this->stagingDb, $table);
468 }
469
470 // DB Flush
471 $this->stagingDb->flush();
472 return true;
473 }
474
475 /**
476 * Set the job
477 * @param string $table
478 */
479 private function setJob($table)
480 {
481 if (!empty($this->options->job->current)) {
482 return;
483 }
484
485 $this->options->job->current = $table;
486 $this->options->job->start = 0;
487 }
488
489 /**
490 * Start Job
491 * @param string $newTableName
492 * @param string $oldTableName
493 * @return bool
494 */
495 private function startJob($newTableName, $oldTableName)
496 {
497 if ($this->isExcludedTable($newTableName)) {
498 return false;
499 }
500
501 // Table does not exist
502 $result = $this->productionDb->query("SHOW TABLES LIKE '{$oldTableName}'");
503 if (!$result || $result === 0) {
504 return false;
505 }
506
507 if (!isset($this->options->job->failedAttempts)) {
508 $this->options->job->failedAttempts = 0;
509 }
510
511 if ($this->options->job->start !== 0) {
512 // The job was attempted too many times and should be skipped now.
513 return !($this->options->job->failedAttempts > $this->maxFailedAttempts);
514 }
515
516 $this->options->job->total = (int)$this->productionDb->get_var("SELECT COUNT(1) FROM {$oldTableName}");
517 $this->options->job->failedAttempts = 0;
518
519 if ($this->options->job->total === 0) {
520 $this->finishStep();
521 return false;
522 }
523
524 return true;
525 }
526
527 /**
528 * Is table excluded from search replace processing?
529 * @param string $table
530 * @return boolean
531 */
532 private function isExcludedTable($table)
533 {
534
535 $tables = $this->excludedTableService->getExcludedTablesForSearchReplace($this->isNetworkClone());
536
537 $excludedAllTables = [];
538 foreach ($tables as $key => $value) {
539 $excludedAllTables[] = $this->options->prefix . ltrim($value, '_');
540 }
541
542 if (in_array($table, $excludedAllTables)) {
543 $this->log("DB Search & Replace: Table {$table} excluded by WP STAGING", Logger::TYPE_INFO);
544 return true;
545 }
546
547 return false;
548 }
549
550 /**
551 * Finish the step
552 */
553 protected function finishStep()
554 {
555 // This job is not finished yet
556 if (!$this->noResultRows && ($this->options->job->total > $this->options->job->start)) {
557 return false;
558 }
559
560 // Add it to cloned tables listing
561 $this->options->clonedTables[] = $this->options->tables[$this->options->currentStep];
562
563 // Reset job
564 $this->options->job = new stdClass();
565
566 return true;
567 }
568
569 /**
570 * Updates the (next) job start to reflect the number of actually processed rows.
571 *
572 * If nothing was processed, then the job start will be ticked by 1.
573 *
574 * @param int $processed The number of actually processed rows in this run.
575 * @param wpdb $db The wpdb instance being used to process.
576 * @param string $table The table being processed.
577 *
578 * @return void The method does not return any value.
579 */
580 protected function updateJobStart($processed, wpdb $db, $table)
581 {
582 $this->processed = absint($processed);
583
584 // If it is a numeric primary key table execution,
585 // Save the last processed primary key value for the next request
586 if ($this->executeNumericPrimaryKeyQuery && $this->lastFetchedPrimaryKeyValue !== false) {
587 $this->options->job->lastProcessedId = $this->lastFetchedPrimaryKeyValue;
588 $this->options->job->start += $this->processed;
589 return;
590 }
591
592 // We make sure to increment the offset at least in 1 to avoid infinite loops.
593 $minimumProcessed = 1;
594
595 /*
596 * There are some scenarios where we couldn't process any rows in this request.
597 * The exact causes of this is still under investigation, but to mitigate this
598 * effect, we will smartly set the offset for the next job based on some context.
599 */
600 if ($this->processed === 0) {
601 $this->logDebug('SEARCH_REPLACE: Processed is zero');
602
603 $totalRowsInTable = $db->get_var("SELECT COUNT(*) FROM $table");
604
605 if (is_numeric($totalRowsInTable)) {
606 $this->logDebug("SEARCH_REPLACE: Rows count is numeric: $totalRowsInTable");
607 // Skip 1% of the current table on each iteration, with a minimum of 1 and a maximum of the query limit.
608 $minimumProcessed = min(max((int)$totalRowsInTable / 100, 1), $this->settings->querySRLimit);
609 } else {
610 $this->logDebug(sprintf("SEARCH_REPLACE: Rows count is not numeric. Type: %s. Json encoded value: %s", gettype($totalRowsInTable), wp_json_encode($totalRowsInTable)));
611 // Unexpected result from query. Set the offset to the limit.
612 $minimumProcessed = $this->settings->querySRLimit;
613 }
614
615 $this->logDebug("SEARCH_REPLACE: Minimum processed is: $minimumProcessed");
616 }
617
618 $this->options->job->start += max($processed, $minimumProcessed);
619 }
620
621 /**
622 * Returns the number of rows processed by the job.
623 *
624 * @return int|null Either the number of rows processed by the Job, or `null` if the Job did
625 * not run yet.
626 */
627 public function getProcessed()
628 {
629 return $this->processed;
630 }
631
632 protected function logDebug($message)
633 {
634 \WPStaging\functions\debug_log($message, 'debug');
635 }
636 }
637