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