Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
44.68% |
105 / 235 |
|
23.08% |
3 / 13 |
CRAP | |
0.00% |
0 / 1 |
MergeHistory | |
44.87% |
105 / 234 |
|
23.08% |
3 / 13 |
308.07 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
getRevisionCount | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getMergedRevisionCount | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
authorizeInternal | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
probablyCanMerge | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
authorizeMerge | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
isValidMerge | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
8.02 | |||
merge | |
80.36% |
45 / 56 |
|
0.00% |
0 / 1 |
6.27 | |||
updateSourcePage | |
0.00% |
0 / 64 |
|
0.00% |
0 / 1 |
42 | |||
getMaxTimestamp | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getTimestampLimit | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getTimeWhere | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
initTimestampLimits | |
66.67% |
32 / 48 |
|
0.00% |
0 / 1 |
7.33 |
1 | <?php |
2 | |
3 | /** |
4 | * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com> |
5 | * |
6 | * This program is free software; you can redistribute it and/or modify |
7 | * it under the terms of the GNU General Public License as published by |
8 | * the Free Software Foundation; either version 2 of the License, or |
9 | * (at your option) any later version. |
10 | * |
11 | * This program is distributed in the hope that it will be useful, |
12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
14 | * GNU General Public License for more details. |
15 | * |
16 | * You should have received a copy of the GNU General Public License along |
17 | * with this program; if not, write to the Free Software Foundation, Inc., |
18 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
19 | * http://www.gnu.org/copyleft/gpl.html |
20 | * |
21 | * @file |
22 | */ |
23 | |
24 | namespace MediaWiki\Page; |
25 | |
26 | use Content; |
27 | use InvalidArgumentException; |
28 | use ManualLogEntry; |
29 | use MediaWiki; |
30 | use MediaWiki\CommentStore\CommentStoreComment; |
31 | use MediaWiki\Content\IContentHandlerFactory; |
32 | use MediaWiki\EditPage\SpamChecker; |
33 | use MediaWiki\HookContainer\HookContainer; |
34 | use MediaWiki\HookContainer\HookRunner; |
35 | use MediaWiki\Linker\LinkTargetLookup; |
36 | use MediaWiki\MainConfigNames; |
37 | use MediaWiki\MediaWikiServices; |
38 | use MediaWiki\Message\Message; |
39 | use MediaWiki\Permissions\Authority; |
40 | use MediaWiki\Permissions\PermissionStatus; |
41 | use MediaWiki\Revision\MutableRevisionRecord; |
42 | use MediaWiki\Revision\RevisionStore; |
43 | use MediaWiki\Revision\SlotRecord; |
44 | use MediaWiki\Status\Status; |
45 | use MediaWiki\Title\TitleFactory; |
46 | use MediaWiki\Title\TitleFormatter; |
47 | use MediaWiki\Title\TitleValue; |
48 | use MediaWiki\User\UserIdentity; |
49 | use MediaWiki\Utils\MWTimestamp; |
50 | use WatchedItemStoreInterface; |
51 | use Wikimedia\Rdbms\IConnectionProvider; |
52 | use Wikimedia\Rdbms\IDatabase; |
53 | use Wikimedia\Timestamp\TimestampException; |
54 | |
55 | /** |
56 | * Handles the backend logic of merging the histories of two |
57 | * pages. |
58 | * |
59 | * @since 1.27 |
60 | */ |
61 | class MergeHistory { |
62 | |
63 | /** Maximum number of revisions that can be merged at once */ |
64 | public const REVISION_LIMIT = 5000; |
65 | |
66 | /** @var PageIdentity Page from which history will be merged */ |
67 | protected $source; |
68 | |
69 | /** @var PageIdentity Page to which history will be merged */ |
70 | protected $dest; |
71 | |
72 | /** @var IDatabase Database that we are using */ |
73 | protected $dbw; |
74 | |
75 | /** @var ?string Timestamp up to which history from the source will be merged */ |
76 | private $timestamp; |
77 | |
78 | /** |
79 | * @var MWTimestamp|false Maximum timestamp that we can use (oldest timestamp of dest). |
80 | * Use ::getMaxTimestamp to lazily initialize. |
81 | */ |
82 | protected $maxTimestamp = false; |
83 | |
84 | /** |
85 | * @var string|false|null SQL WHERE condition that selects source revisions |
86 | * to insert into destination. Use ::getTimeWhere to lazy-initialize. |
87 | */ |
88 | protected $timeWhere = false; |
89 | |
90 | /** |
91 | * @var MWTimestamp|false|null Timestamp upto which history from the source will be merged. |
92 | * Use getTimestampLimit to lazily initialize. |
93 | */ |
94 | protected $timestampLimit = false; |
95 | |
96 | /** |
97 | * @var string|null |
98 | */ |
99 | private $revidLimit = null; |
100 | |
101 | /** @var int Number of revisions merged (for Special:MergeHistory success message) */ |
102 | protected $revisionsMerged; |
103 | |
104 | /** @var IContentHandlerFactory */ |
105 | private $contentHandlerFactory; |
106 | |
107 | /** @var RevisionStore */ |
108 | private $revisionStore; |
109 | |
110 | /** @var WatchedItemStoreInterface */ |
111 | private $watchedItemStore; |
112 | |
113 | /** @var SpamChecker */ |
114 | private $spamChecker; |
115 | |
116 | /** @var HookRunner */ |
117 | private $hookRunner; |
118 | |
119 | /** @var WikiPageFactory */ |
120 | private $wikiPageFactory; |
121 | |
122 | /** @var TitleFormatter */ |
123 | private $titleFormatter; |
124 | |
125 | /** @var TitleFactory */ |
126 | private $titleFactory; |
127 | private LinkTargetLookup $linkTargetLookup; |
128 | |
129 | /** |
130 | * @param PageIdentity $source Page from which history will be merged |
131 | * @param PageIdentity $dest Page to which history will be merged |
132 | * @param ?string $timestamp Timestamp up to which history from the source will be merged |
133 | * @param IConnectionProvider $dbProvider |
134 | * @param IContentHandlerFactory $contentHandlerFactory |
135 | * @param RevisionStore $revisionStore |
136 | * @param WatchedItemStoreInterface $watchedItemStore |
137 | * @param SpamChecker $spamChecker |
138 | * @param HookContainer $hookContainer |
139 | * @param WikiPageFactory $wikiPageFactory |
140 | * @param TitleFormatter $titleFormatter |
141 | * @param TitleFactory $titleFactory |
142 | * @param LinkTargetLookup $linkTargetLookup |
143 | */ |
144 | public function __construct( |
145 | PageIdentity $source, |
146 | PageIdentity $dest, |
147 | ?string $timestamp, |
148 | IConnectionProvider $dbProvider, |
149 | IContentHandlerFactory $contentHandlerFactory, |
150 | RevisionStore $revisionStore, |
151 | WatchedItemStoreInterface $watchedItemStore, |
152 | SpamChecker $spamChecker, |
153 | HookContainer $hookContainer, |
154 | WikiPageFactory $wikiPageFactory, |
155 | TitleFormatter $titleFormatter, |
156 | TitleFactory $titleFactory, |
157 | LinkTargetLookup $linkTargetLookup |
158 | ) { |
159 | // Save the parameters |
160 | $this->source = $source; |
161 | $this->dest = $dest; |
162 | $this->timestamp = $timestamp; |
163 | |
164 | // Get the database |
165 | $this->dbw = $dbProvider->getPrimaryDatabase(); |
166 | |
167 | $this->contentHandlerFactory = $contentHandlerFactory; |
168 | $this->revisionStore = $revisionStore; |
169 | $this->watchedItemStore = $watchedItemStore; |
170 | $this->spamChecker = $spamChecker; |
171 | $this->hookRunner = new HookRunner( $hookContainer ); |
172 | $this->wikiPageFactory = $wikiPageFactory; |
173 | $this->titleFormatter = $titleFormatter; |
174 | $this->titleFactory = $titleFactory; |
175 | $this->linkTargetLookup = $linkTargetLookup; |
176 | } |
177 | |
178 | /** |
179 | * Get the number of revisions that will be moved |
180 | * @return int |
181 | */ |
182 | public function getRevisionCount() { |
183 | $count = $this->dbw->newSelectQueryBuilder() |
184 | ->select( '1' ) |
185 | ->from( 'revision' ) |
186 | ->where( [ 'rev_page' => $this->source->getId(), $this->getTimeWhere() ] ) |
187 | ->limit( self::REVISION_LIMIT + 1 ) |
188 | ->caller( __METHOD__ )->fetchRowCount(); |
189 | |
190 | return $count; |
191 | } |
192 | |
193 | /** |
194 | * Get the number of revisions that were moved |
195 | * Used in the SpecialMergeHistory success message |
196 | * @return int |
197 | */ |
198 | public function getMergedRevisionCount() { |
199 | return $this->revisionsMerged; |
200 | } |
201 | |
202 | /** |
203 | * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status ) |
204 | * @param Authority $performer |
205 | * @param string $reason |
206 | * @return PermissionStatus |
207 | */ |
208 | private function authorizeInternal( |
209 | callable $authorizer, |
210 | Authority $performer, |
211 | string $reason |
212 | ) { |
213 | $status = PermissionStatus::newEmpty(); |
214 | |
215 | $authorizer( 'edit', $this->source, $status ); |
216 | $authorizer( 'edit', $this->dest, $status ); |
217 | |
218 | // Anti-spam |
219 | if ( $this->spamChecker->checkSummary( $reason ) !== false ) { |
220 | // This is kind of lame, won't display nice |
221 | $status->fatal( 'spamprotectiontext' ); |
222 | } |
223 | |
224 | // Check mergehistory permission |
225 | if ( !$performer->isAllowed( 'mergehistory' ) ) { |
226 | // User doesn't have the right to merge histories |
227 | $status->fatal( 'mergehistory-fail-permission' ); |
228 | } |
229 | return $status; |
230 | } |
231 | |
232 | /** |
233 | * Check whether $performer can execute the merge. |
234 | * |
235 | * @note this method does not guarantee full permissions check, so it should |
236 | * only be used to to decide whether to show a merge form. To authorize the merge |
237 | * action use {@link self::authorizeMerge} instead. |
238 | * |
239 | * @param Authority $performer |
240 | * @param string|null $reason |
241 | * @return PermissionStatus |
242 | */ |
243 | public function probablyCanMerge( Authority $performer, string $reason = null ): PermissionStatus { |
244 | return $this->authorizeInternal( |
245 | static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) { |
246 | return $performer->probablyCan( $action, $target, $status ); |
247 | }, |
248 | $performer, |
249 | $reason |
250 | ); |
251 | } |
252 | |
253 | /** |
254 | * Authorize the merge by $performer. |
255 | * |
256 | * @note this method should be used right before the actual merge is performed. |
257 | * To check whether a current performer has the potential to merge the history, |
258 | * use {@link self::probablyCanMerge} instead. |
259 | * |
260 | * @param Authority $performer |
261 | * @param string|null $reason |
262 | * @return PermissionStatus |
263 | */ |
264 | public function authorizeMerge( Authority $performer, string $reason = null ): PermissionStatus { |
265 | return $this->authorizeInternal( |
266 | static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) { |
267 | return $performer->authorizeWrite( $action, $target, $status ); |
268 | }, |
269 | $performer, |
270 | $reason |
271 | ); |
272 | } |
273 | |
274 | /** |
275 | * Does various checks that the merge is |
276 | * valid. Only things based on the two pages |
277 | * should be checked here. |
278 | * |
279 | * @return Status |
280 | */ |
281 | public function isValidMerge() { |
282 | $status = new Status(); |
283 | |
284 | // If either article ID is 0, then revisions cannot be reliably selected |
285 | if ( $this->source->getId() === 0 ) { |
286 | $status->fatal( 'mergehistory-fail-invalid-source' ); |
287 | } |
288 | if ( $this->dest->getId() === 0 ) { |
289 | $status->fatal( 'mergehistory-fail-invalid-dest' ); |
290 | } |
291 | |
292 | // Make sure page aren't the same |
293 | if ( $this->source->isSamePageAs( $this->dest ) ) { |
294 | $status->fatal( 'mergehistory-fail-self-merge' ); |
295 | } |
296 | |
297 | // Make sure the timestamp is valid |
298 | if ( !$this->getTimestampLimit() ) { |
299 | $status->fatal( 'mergehistory-fail-bad-timestamp' ); |
300 | } |
301 | |
302 | // $this->timestampLimit must be older than $this->maxTimestamp |
303 | if ( $this->getTimestampLimit() > $this->getMaxTimestamp() ) { |
304 | $status->fatal( 'mergehistory-fail-timestamps-overlap' ); |
305 | } |
306 | |
307 | // Check that there are not too many revisions to move |
308 | if ( $this->getTimestampLimit() && $this->getRevisionCount() > self::REVISION_LIMIT ) { |
309 | $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) ); |
310 | } |
311 | |
312 | return $status; |
313 | } |
314 | |
315 | /** |
316 | * Actually attempt the history move |
317 | * |
318 | * @todo if all versions of page A are moved to B and then a user |
319 | * tries to do a reverse-merge via the "unmerge" log link, then page |
320 | * A will still be a redirect (as it was after the original merge), |
321 | * though it will have the old revisions back from before (as expected). |
322 | * The user may have to "undo" the redirect manually to finish the "unmerge". |
323 | * Maybe this should delete redirects at the source page of merges? |
324 | * |
325 | * @param Authority $performer |
326 | * @param string $reason |
327 | * @return Status status of the history merge |
328 | */ |
329 | public function merge( Authority $performer, $reason = '' ) { |
330 | $status = new Status(); |
331 | |
332 | // Check validity and permissions required for merge |
333 | $validCheck = $this->isValidMerge(); // Check this first to check for null pages |
334 | if ( !$validCheck->isOK() ) { |
335 | return $validCheck; |
336 | } |
337 | $permCheck = $this->authorizeMerge( $performer, $reason ); |
338 | if ( !$permCheck->isOK() ) { |
339 | return Status::wrap( $permCheck ); |
340 | } |
341 | |
342 | $this->dbw->startAtomic( __METHOD__ ); |
343 | |
344 | $this->dbw->newUpdateQueryBuilder() |
345 | ->update( 'revision' ) |
346 | ->set( [ 'rev_page' => $this->dest->getId() ] ) |
347 | ->where( [ 'rev_page' => $this->source->getId(), $this->getTimeWhere() ] ) |
348 | ->caller( __METHOD__ )->execute(); |
349 | |
350 | // Check if this did anything |
351 | $this->revisionsMerged = $this->dbw->affectedRows(); |
352 | if ( $this->revisionsMerged < 1 ) { |
353 | $this->dbw->endAtomic( __METHOD__ ); |
354 | return $status->fatal( 'mergehistory-fail-no-change' ); |
355 | } |
356 | |
357 | $haveRevisions = $this->dbw->newSelectQueryBuilder() |
358 | ->from( 'revision' ) |
359 | ->where( [ 'rev_page' => $this->source->getId() ] ) |
360 | ->forUpdate() |
361 | ->caller( __METHOD__ ) |
362 | ->fetchRowCount(); |
363 | |
364 | $legacySource = $this->titleFactory->newFromPageIdentity( $this->source ); |
365 | $legacyDest = $this->titleFactory->newFromPageIdentity( $this->dest ); |
366 | |
367 | // Update source page, histories and invalidate caches |
368 | if ( !$haveRevisions ) { |
369 | if ( $reason ) { |
370 | $reason = wfMessage( |
371 | 'mergehistory-comment', |
372 | $this->titleFormatter->getPrefixedText( $this->source ), |
373 | $this->titleFormatter->getPrefixedText( $this->dest ), |
374 | $reason |
375 | )->inContentLanguage()->text(); |
376 | } else { |
377 | $reason = wfMessage( |
378 | 'mergehistory-autocomment', |
379 | $this->titleFormatter->getPrefixedText( $this->source ), |
380 | $this->titleFormatter->getPrefixedText( $this->dest ) |
381 | )->inContentLanguage()->text(); |
382 | } |
383 | |
384 | $this->updateSourcePage( $status, $performer->getUser(), $reason ); |
385 | |
386 | } else { |
387 | $legacySource->invalidateCache(); |
388 | } |
389 | $legacyDest->invalidateCache(); |
390 | |
391 | // Duplicate watchers of the old article to the new article |
392 | $this->watchedItemStore->duplicateAllAssociatedEntries( $this->source, $this->dest ); |
393 | |
394 | // Update our logs |
395 | $logEntry = new ManualLogEntry( 'merge', 'merge' ); |
396 | $logEntry->setPerformer( $performer->getUser() ); |
397 | $logEntry->setComment( $reason ); |
398 | $logEntry->setTarget( $this->source ); |
399 | $logEntry->setParameters( [ |
400 | '4::dest' => $this->titleFormatter->getPrefixedText( $this->dest ), |
401 | '5::mergepoint' => $this->getTimestampLimit()->getTimestamp( TS_MW ), |
402 | '6::mergerevid' => $this->revidLimit |
403 | ] ); |
404 | $logId = $logEntry->insert(); |
405 | $logEntry->publish( $logId ); |
406 | |
407 | $this->hookRunner->onArticleMergeComplete( $legacySource, $legacyDest ); |
408 | |
409 | $this->dbw->endAtomic( __METHOD__ ); |
410 | |
411 | return $status; |
412 | } |
413 | |
414 | /** |
415 | * Do various cleanup work and updates to the source page. This method |
416 | * will only be called if no revision is remaining on the page. |
417 | * |
418 | * At the end, there would be either a redirect page or a deleted page, |
419 | * depending on whether the content model of the page supports redirects or not. |
420 | * |
421 | * @param Status $status |
422 | * @param UserIdentity $user |
423 | * @param string $reason |
424 | * |
425 | * @return Status |
426 | */ |
427 | private function updateSourcePage( $status, $user, $reason ) { |
428 | $deleteSource = false; |
429 | $legacySourceTitle = $this->titleFactory->newFromPageIdentity( $this->source ); |
430 | $legacyDestTitle = $this->titleFactory->newFromPageIdentity( $this->dest ); |
431 | $sourceModel = $legacySourceTitle->getContentModel(); |
432 | $contentHandler = $this->contentHandlerFactory->getContentHandler( $sourceModel ); |
433 | |
434 | if ( !$contentHandler->supportsRedirects() ) { |
435 | $deleteSource = true; |
436 | $newContent = $contentHandler->makeEmptyContent(); |
437 | } else { |
438 | $msg = wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain(); |
439 | $newContent = $contentHandler->makeRedirectContent( $legacyDestTitle, $msg ); |
440 | } |
441 | |
442 | if ( !$newContent instanceof Content ) { |
443 | // Handler supports redirect but cannot create new redirect content? |
444 | // Not possible to proceed without Content. |
445 | |
446 | // @todo. Remove this once there's no evidence it's happening or if it's |
447 | // determined all violating handlers have been fixed. |
448 | // This is mostly kept because previous code was also blindly checking |
449 | // existing of the Content for both content models that supports redirects |
450 | // and those that that don't, so it's hard to know what it was masking. |
451 | $logger = MediaWiki\Logger\LoggerFactory::getInstance( 'ContentHandler' ); |
452 | $logger->warning( |
453 | 'ContentHandler for {model} says it supports redirects but failed ' |
454 | . 'to return Content object from ContentHandler::makeRedirectContent().' |
455 | . ' {value} returned instead.', |
456 | [ |
457 | 'value' => gettype( $newContent ), |
458 | 'model' => $sourceModel |
459 | ] |
460 | ); |
461 | |
462 | throw new InvalidArgumentException( |
463 | "ContentHandler for '$sourceModel' supports redirects" . |
464 | ' but cannot create redirect content during History merge.' |
465 | ); |
466 | } |
467 | |
468 | // T263340/T93469: Create revision record to also serve as the page revision. |
469 | // This revision will be used to create page content. If the source page's |
470 | // content model supports redirects, then it will be the redirect content. |
471 | // If the content model does not supports redirect, this content will aid |
472 | // proper deletion of the page below. |
473 | $comment = CommentStoreComment::newUnsavedComment( $reason ); |
474 | $revRecord = new MutableRevisionRecord( $this->source ); |
475 | $revRecord->setContent( SlotRecord::MAIN, $newContent ) |
476 | ->setPageId( $this->source->getId() ) |
477 | ->setComment( $comment ) |
478 | ->setUser( $user ) |
479 | ->setTimestamp( wfTimestampNow() ); |
480 | |
481 | $insertedRevRecord = $this->revisionStore->insertRevisionOn( $revRecord, $this->dbw ); |
482 | |
483 | $newPage = $this->wikiPageFactory->newFromTitle( $this->source ); |
484 | $newPage->updateRevisionOn( $this->dbw, $insertedRevRecord ); |
485 | |
486 | if ( !$deleteSource ) { |
487 | // TODO: This doesn't belong here, it should be part of PageLinksTable. |
488 | // We have created a redirect page so let's |
489 | // record the link from the page to the new title. |
490 | // It should have no other outgoing links... |
491 | $this->dbw->newDeleteQueryBuilder() |
492 | ->deleteFrom( 'pagelinks' ) |
493 | ->where( [ 'pl_from' => $this->source->getId() ] ) |
494 | ->caller( __METHOD__ )->execute(); |
495 | $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get( |
496 | MainConfigNames::PageLinksSchemaMigrationStage |
497 | ); |
498 | $row = [ |
499 | 'pl_from' => $this->source->getId(), |
500 | 'pl_from_namespace' => $this->source->getNamespace(), |
501 | ]; |
502 | if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) { |
503 | $row['pl_namespace'] = $this->dest->getNamespace(); |
504 | $row['pl_title'] = $this->dest->getDBkey(); |
505 | } |
506 | if ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) { |
507 | $row['pl_target_id'] = $this->linkTargetLookup->acquireLinkTargetId( |
508 | new TitleValue( $this->dest->getNamespace(), $this->dest->getDBkey() ), |
509 | $this->dbw |
510 | ); |
511 | } |
512 | $this->dbw->newInsertQueryBuilder() |
513 | ->insertInto( 'pagelinks' ) |
514 | ->row( $row ) |
515 | ->caller( __METHOD__ )->execute(); |
516 | |
517 | } else { |
518 | // T263340/T93469: Delete the source page to prevent errors because its |
519 | // revisions are now tied to a different title and its content model |
520 | // does not support redirects, so we cannot leave a new revision on it. |
521 | // This deletion does not depend on userright but may still fails. If it |
522 | // fails, it will be communicated in the status response. |
523 | $reason = wfMessage( 'mergehistory-source-deleted-reason' )->inContentLanguage()->plain(); |
524 | $deletionStatus = $newPage->doDeleteArticleReal( $reason, $user ); |
525 | // Notify callers that the source page has been deleted. |
526 | $status->value = 'source-deleted'; |
527 | $status->merge( $deletionStatus ); |
528 | } |
529 | |
530 | return $status; |
531 | } |
532 | |
533 | /** |
534 | * Get the maximum timestamp that we can use (oldest timestamp of dest) |
535 | * |
536 | * @return MWTimestamp |
537 | */ |
538 | private function getMaxTimestamp(): MWTimestamp { |
539 | if ( $this->maxTimestamp === false ) { |
540 | $this->initTimestampLimits(); |
541 | } |
542 | return $this->maxTimestamp; |
543 | } |
544 | |
545 | /** |
546 | * Get the timestamp upto which history from the source will be merged, |
547 | * or null if something went wrong |
548 | * |
549 | * @return ?MWTimestamp |
550 | */ |
551 | private function getTimestampLimit(): ?MWTimestamp { |
552 | if ( $this->timestampLimit === false ) { |
553 | $this->initTimestampLimits(); |
554 | } |
555 | return $this->timestampLimit; |
556 | } |
557 | |
558 | /** |
559 | * Get the SQL WHERE condition that selects source revisions to insert into destination, |
560 | * or null if something went wrong |
561 | * |
562 | * @return ?string |
563 | */ |
564 | private function getTimeWhere(): ?string { |
565 | if ( $this->timeWhere === false ) { |
566 | $this->initTimestampLimits(); |
567 | } |
568 | return $this->timeWhere; |
569 | } |
570 | |
571 | /** |
572 | * Lazily initializes timestamp (and possibly revid) limits and conditions. |
573 | */ |
574 | private function initTimestampLimits() { |
575 | // Max timestamp should be min of destination page |
576 | $firstDestTimestamp = $this->dbw->newSelectQueryBuilder() |
577 | ->select( 'MIN(rev_timestamp)' ) |
578 | ->from( 'revision' ) |
579 | ->where( [ 'rev_page' => $this->dest->getId() ] ) |
580 | ->caller( __METHOD__ )->fetchField(); |
581 | $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp ); |
582 | $this->revidLimit = null; |
583 | // Get the timestamp pivot condition |
584 | try { |
585 | if ( $this->timestamp ) { |
586 | $parts = explode( '|', $this->timestamp ); |
587 | if ( count( $parts ) == 2 ) { |
588 | $timestamp = $parts[0]; |
589 | $this->revidLimit = $parts[1]; |
590 | } else { |
591 | $timestamp = $this->timestamp; |
592 | } |
593 | // If we have a requested timestamp, use the |
594 | // latest revision up to that point as the insertion point |
595 | $mwTimestamp = new MWTimestamp( $timestamp ); |
596 | |
597 | $lastWorkingTimestamp = $this->dbw->newSelectQueryBuilder() |
598 | ->select( 'MAX(rev_timestamp)' ) |
599 | ->from( 'revision' ) |
600 | ->where( [ |
601 | $this->dbw->expr( 'rev_timestamp', '<=', $this->dbw->timestamp( $mwTimestamp ) ), |
602 | 'rev_page' => $this->source->getId() |
603 | ] ) |
604 | ->caller( __METHOD__ )->fetchField(); |
605 | $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp ); |
606 | |
607 | $timeInsert = $mwLastWorkingTimestamp; |
608 | $this->timestampLimit = $mwLastWorkingTimestamp; |
609 | } else { |
610 | // If we don't, merge entire source page history into the |
611 | // beginning of destination page history |
612 | |
613 | // Get the latest timestamp of the source |
614 | $row = $this->dbw->newSelectQueryBuilder() |
615 | ->select( [ 'rev_timestamp', 'rev_id' ] ) |
616 | ->from( 'page' ) |
617 | ->join( 'revision', null, 'page_latest = rev_id' ) |
618 | ->where( [ 'page_id' => $this->source->getId() ] ) |
619 | ->caller( __METHOD__ )->fetchRow(); |
620 | $timeInsert = $this->maxTimestamp; |
621 | if ( $row ) { |
622 | $lasttimestamp = new MWTimestamp( $row->rev_timestamp ); |
623 | $this->timestampLimit = $lasttimestamp; |
624 | $this->revidLimit = $row->rev_id; |
625 | } else { |
626 | $this->timestampLimit = null; |
627 | } |
628 | } |
629 | $dbLimit = $this->dbw->timestamp( $timeInsert ); |
630 | if ( $this->revidLimit ) { |
631 | $this->timeWhere = $this->dbw->buildComparison( '<=', |
632 | [ 'rev_timestamp' => $dbLimit, 'rev_id' => $this->revidLimit ] |
633 | ); |
634 | } else { |
635 | $this->timeWhere = $this->dbw->buildComparison( '<=', |
636 | [ 'rev_timestamp' => $dbLimit ] |
637 | ); |
638 | } |
639 | } catch ( TimestampException $ex ) { |
640 | // The timestamp we got is screwed up and merge cannot continue |
641 | // This should be detected by $this->isValidMerge() |
642 | $this->timestampLimit = null; |
643 | $this->timeWhere = null; |
644 | } |
645 | } |
646 | } |
647 | |
648 | /** @deprecated class alias since 1.40 */ |
649 | class_alias( MergeHistory::class, 'MergeHistory' ); |