PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 3.3.1
WP STAGING – WordPress Backup, Restore, Migration & Clone v3.3.1
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 2 years ago Job.php 2 years ago JobExecutable.php 2 years ago Logs.php 3 years ago PreserveDataFirstStep.php 3 years ago PreserveDataSecondStep.php 3 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
610 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 (false === $primaryKeyAndColumns) {
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 (
356 isset($row['option_name']) && $args['skip_transients'] === 'on' && strpos($row['option_name'], '_transient')
357 !== false
358 ) {
359 continue;
360 }
361
362 // Skip rows with more than 5MB to save memory. These rows contain log data or something similiar but never site relevant data
363 if (isset($row['option_value']) && strlen($row['option_value']) >= 5000000) {
364 continue;
365 }
366
367 // Go through the columns
368 foreach ($columns as $column) {
369 $dataRow = $row[$column];
370
371 // Skip column larger than 5MB
372 $size = strlen($dataRow);
373 if ($size >= 5000000) {
374 continue;
375 }
376
377 // Skip primary key column
378 if (in_array($column, $primaryKeys)) {
379 $whereSql[] = $column . ' = "' . WPStaging::make(Escape::class)->mysqlRealEscapeString($dataRow) . '"';
380 continue;
381 }
382
383 // Skip GUIDs by default.
384 if ($args['replace_guids'] !== 'on' && $column === 'guid') {
385 continue;
386 }
387
388 $excludes = apply_filters('wpstg_clone_searchreplace_excl', []);
389 $searchReplace = new \WPStaging\Framework\Database\SearchReplace($args['search_for'], $args['replace_with'], $args['case_insensitive'], $excludes);
390 /** @var SiteInfo */
391 $siteInfo = WPStaging::make(SiteInfo::class);
392 $searchReplace->setWpBakeryActive($siteInfo->isWpBakeryActive());
393 $dataRow = $searchReplace->replaceExtended($dataRow);
394
395 // Something was changed
396 if ($row[$column] !== $dataRow) {
397 $updateSql[] = $column . ' = "' . WPStaging::make(Escape::class)->mysqlRealEscapeString($dataRow) . '"';
398 $doUpdate = true;
399 }
400 }
401
402 // Determine what to do with updates.
403 if ($args['dry_run'] === 'on') {
404 // Don't do anything if a dry run
405 } elseif ($doUpdate && !empty($whereSql)) {
406 // If there are changes to make, run the query.
407 $sql = 'UPDATE ' . $table . ' SET ' . implode(', ', $updateSql) . ' WHERE ' . implode(' AND ', array_filter($whereSql));
408 $result = $this->stagingDb->query($sql);
409
410 if ($result === false) {
411 $partialQuery = substr($sql, 0, 100);
412 $this->log(
413 "Error updating row {$currentRow} SQL: {$partialQuery}",
414 Logger::TYPE_ERROR
415 );
416 }
417 }
418 } // end row loop
419
420 unset($row,$updateSql,$whereSql,$sql,$currentRow);
421
422 if (!$this->isSearchReplaceGeneratorDisabled()) {
423 $this->updateJobStart($processed, $this->stagingDb, $table);
424 }
425
426 // DB Flush
427 $this->stagingDb->flush();
428 return true;
429 }
430
431 /**
432 * Set the job
433 * @param string $table
434 */
435 private function setJob($table)
436 {
437 if (!empty($this->options->job->current)) {
438 return;
439 }
440
441 // Create job object if not exists
442 if (!is_object($this->options->job)) {
443 $this->options->job = new stdClass();
444 }
445
446 $this->options->job->current = $table;
447 $this->options->job->start = 0;
448 }
449
450 /**
451 * Start Job
452 * @param string $newTableName
453 * @param string $oldTableName
454 * @return bool
455 */
456 private function startJob($newTableName, $oldTableName)
457 {
458 if ($this->isExcludedTable($newTableName)) {
459 return false;
460 }
461
462 // Table does not exist
463 $result = $this->productionDb->query("SHOW TABLES LIKE '{$oldTableName}'");
464 if (!$result || $result === 0) {
465 return false;
466 }
467
468 if (!isset($this->options->job->failedAttempts)) {
469 $this->options->job->failedAttempts = 0;
470 }
471
472 if ($this->options->job->start !== 0) {
473 // The job was attempted too many times and should be skipped now.
474 return !($this->options->job->failedAttempts > $this->maxFailedAttempts);
475 }
476
477 $this->options->job->total = (int)$this->productionDb->get_var("SELECT COUNT(1) FROM {$oldTableName}");
478 $this->options->job->failedAttempts = 0;
479
480 if ($this->options->job->total === 0) {
481 $this->finishStep();
482 return false;
483 }
484
485 return true;
486 }
487
488 /**
489 * Is table excluded from search replace processing?
490 * @param string $table
491 * @return boolean
492 */
493 private function isExcludedTable($table)
494 {
495
496 $tables = $this->excludedTableService->getExcludedTablesForSearchReplace($this->isNetworkClone());
497
498 $excludedAllTables = [];
499 foreach ($tables as $key => $value) {
500 $excludedAllTables[] = $this->options->prefix . ltrim($value, '_');
501 }
502
503 if (in_array($table, $excludedAllTables)) {
504 $this->log("DB Search & Replace: Table {$table} excluded by WP STAGING", Logger::TYPE_INFO);
505 return true;
506 }
507
508 return false;
509 }
510
511 /**
512 * Finish the step
513 */
514 protected function finishStep()
515 {
516 // This job is not finished yet
517 if (!$this->noResultRows && ($this->options->job->total > $this->options->job->start)) {
518 return false;
519 }
520
521 // Add it to cloned tables listing
522 $this->options->clonedTables[] = $this->options->tables[$this->options->currentStep];
523
524 // Reset job
525 $this->options->job = new stdClass();
526
527 return true;
528 }
529
530 /**
531 * Updates the (next) job start to reflect the number of actually processed rows.
532 *
533 * If nothing was processed, then the job start will be ticked by 1.
534 *
535 * @param int $processed The number of actually processed rows in this run.
536 * @param wpdb $db The wpdb instance being used to process.
537 * @param string $table The table being processed.
538 *
539 * @return void The method does not return any value.
540 */
541 protected function updateJobStart($processed, wpdb $db, $table)
542 {
543 $this->processed = absint($processed);
544
545 // If it is a numeric primary key table execution,
546 // Save the last processed primary key value for the next request
547 if ($this->executeNumericPrimaryKeyQuery && $this->lastFetchedPrimaryKeyValue !== false) {
548 $this->options->job->lastProcessedId = $this->lastFetchedPrimaryKeyValue;
549 $this->options->job->start += $this->processed;
550 return;
551 }
552
553 // We make sure to increment the offset at least in 1 to avoid infinite loops.
554 $minimumProcessed = 1;
555
556 /*
557 * There are some scenarios where we couldn't process any rows in this request.
558 * The exact causes of this is still under investigation, but to mitigate this
559 * effect, we will smartly set the offset for the next job based on some context.
560 */
561 if ($this->processed === 0) {
562 $this->logDebug('SEARCH_REPLACE: Processed is zero');
563
564 $totalRowsInTable = $db->get_var("SELECT COUNT(*) FROM $table");
565
566 if (is_numeric($totalRowsInTable)) {
567 $this->logDebug("SEARCH_REPLACE: Rows count is numeric: $totalRowsInTable");
568 // Skip 1% of the current table on each iteration, with a minimum of 1 and a maximum of the query limit.
569 $minimumProcessed = min(max((int)$totalRowsInTable / 100, 1), $this->settings->querySRLimit);
570 } else {
571 $this->logDebug(sprintf("SEARCH_REPLACE: Rows count is not numeric. Type: %s. Json encoded value: %s", gettype($totalRowsInTable), wp_json_encode($totalRowsInTable)));
572 // Unexpected result from query. Set the offset to the limit.
573 $minimumProcessed = $this->settings->querySRLimit;
574 }
575
576 $this->logDebug("SEARCH_REPLACE: Minimum processed is: $minimumProcessed");
577 }
578
579 $this->options->job->start += max($processed, $minimumProcessed);
580 }
581
582 /**
583 * Returns the number of rows processed by the job.
584 *
585 * @return int|null Either the number of rows processed by the Job, or `null` if the Job did
586 * not run yet.
587 */
588 public function getProcessed()
589 {
590 return $this->processed;
591 }
592
593 protected function logDebug($message)
594 {
595 \WPStaging\functions\debug_log($message, 'debug');
596 }
597
598 /**
599 * @return bool
600 */
601 protected function isSearchReplaceGeneratorDisabled(): bool
602 {
603 if (!defined('WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR')) {
604 return false;
605 }
606
607 return constant('WPSTG_DISABLE_SEARCH_REPLACE_GENERATOR');
608 }
609 }
610