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