Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
49.11% |
692 / 1409 |
|
30.56% |
22 / 72 |
CRAP | |
0.00% |
0 / 1 |
RevisionStore | |
49.11% |
692 / 1409 |
|
30.56% |
22 / 72 |
12973.33 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
2 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isReadOnly | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getWikiId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDBConnectionRefForQueryFlags | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getDBConnection | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTitle | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getPage | |
19.35% |
6 / 31 |
|
0.00% |
0 / 1 |
87.53 | |||
wrapPage | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
failOnNull | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
failOnEmpty | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
insertRevisionOn | |
98.57% |
69 / 70 |
|
0.00% |
0 / 1 |
5 | |||
updateSlotsOn | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
20 | |||
updateSlotsInternal | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
insertRevisionInternal | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
20 | |||
insertSlotOn | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
insertIpChangesRow | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
insertRevisionRowOn | |
0.00% |
0 / 62 |
|
0.00% |
0 / 1 |
72 | |||
getBaseRevisionRow | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
storeContentBlob | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
2 | |||
insertSlotRowOn | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
insertContentRowOn | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
checkContent | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
newNullRevision | |
83.33% |
30 / 36 |
|
0.00% |
0 / 1 |
3.04 | |||
getRcIdIfUnpatrolled | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
getRecentChange | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
2.00 | |||
loadSlotContent | |
0.00% |
0 / 44 |
|
0.00% |
0 / 1 |
90 | |||
getRevisionById | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRevisionByTitle | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
4.07 | |||
getRevisionByPageId | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
2.09 | |||
getRevisionByTimestamp | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
loadSlotRecords | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
20 | |||
loadSlotRecordsFromDb | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
12 | |||
constructSlotRecords | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
90 | |||
newRevisionSlots | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
newRevisionFromArchiveRow | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
newRevisionFromRow | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
newRevisionFromArchiveRowAndSlots | |
87.50% |
28 / 32 |
|
0.00% |
0 / 1 |
10.20 | |||
newRevisionFromRowAndSlots | |
33.78% |
25 / 74 |
|
0.00% |
0 / 1 |
53.81 | |||
ensureRevisionRowMatchesPage | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
56 | |||
newRevisionsFromBatch | |
86.47% |
115 / 133 |
|
0.00% |
0 / 1 |
33.38 | |||
getSlotRowsForBatch | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
420 | |||
getContentBlobsForBatch | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
6.07 | |||
newRevisionFromConds | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
loadRevisionFromConds | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
checkDatabaseDomain | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
fetchRevisionRowFromConds | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
getQueryInfo | |
94.23% |
49 / 52 |
|
0.00% |
0 / 1 |
4.00 | |||
newSelectQueryBuilder | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
newArchiveSelectQueryBuilder | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSlotsQueryInfo | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
4 | |||
isRevisionRow | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
getArchiveQueryInfo | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
1 | |||
getRevisionSizes | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
getRelativeRevision | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
110 | |||
getPreviousRevision | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getNextRevision | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPreviousRevisionId | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
12 | |||
getTimestampFromId | |
76.92% |
10 / 13 |
|
0.00% |
0 / 1 |
6.44 | |||
countRevisionsByPageId | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
2.01 | |||
countRevisionsByTitle | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
userWasLastToEdit | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
4.00 | |||
getKnownCurrentRevision | |
66.67% |
28 / 42 |
|
0.00% |
0 / 1 |
12.00 | |||
getFirstRevision | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
getRevisionRowCacheKey | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
assertRevisionParameter | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
getRevisionLimitConditions | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
56 | |||
getRevisionIdsBetween | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
10 | |||
getAuthorsBetween | |
89.47% |
34 / 38 |
|
0.00% |
0 / 1 |
7.06 | |||
countAuthorsBetween | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
countRevisionsBetween | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
5 | |||
findIdenticalRevision | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * Attribution notice: when this file was created, much of its content was taken |
19 | * from the Revision.php file as present in release 1.30. Refer to the history |
20 | * of that file for original authorship (that file was removed entirely in 1.37, |
21 | * but its history can still be found in prior versions of MediaWiki). |
22 | * |
23 | * @file |
24 | */ |
25 | |
26 | namespace MediaWiki\Revision; |
27 | |
28 | use BagOStuff; |
29 | use Content; |
30 | use DBAccessObjectUtils; |
31 | use FallbackContent; |
32 | use IDBAccessObject; |
33 | use InvalidArgumentException; |
34 | use LogicException; |
35 | use MediaWiki\CommentStore\CommentStore; |
36 | use MediaWiki\CommentStore\CommentStoreComment; |
37 | use MediaWiki\Content\IContentHandlerFactory; |
38 | use MediaWiki\DAO\WikiAwareEntity; |
39 | use MediaWiki\HookContainer\HookContainer; |
40 | use MediaWiki\HookContainer\HookRunner; |
41 | use MediaWiki\Linker\LinkTarget; |
42 | use MediaWiki\Page\LegacyArticleIdAccess; |
43 | use MediaWiki\Page\PageIdentity; |
44 | use MediaWiki\Page\PageIdentityValue; |
45 | use MediaWiki\Page\PageStore; |
46 | use MediaWiki\Permissions\Authority; |
47 | use MediaWiki\Storage\BadBlobException; |
48 | use MediaWiki\Storage\BlobAccessException; |
49 | use MediaWiki\Storage\BlobStore; |
50 | use MediaWiki\Storage\NameTableAccessException; |
51 | use MediaWiki\Storage\NameTableStore; |
52 | use MediaWiki\Storage\RevisionSlotsUpdate; |
53 | use MediaWiki\Storage\SqlBlobStore; |
54 | use MediaWiki\Title\Title; |
55 | use MediaWiki\Title\TitleFactory; |
56 | use MediaWiki\User\ActorStore; |
57 | use MediaWiki\User\UserIdentity; |
58 | use MediaWiki\Utils\MWTimestamp; |
59 | use MWException; |
60 | use MWUnknownContentModelException; |
61 | use Psr\Log\LoggerAwareInterface; |
62 | use Psr\Log\LoggerInterface; |
63 | use Psr\Log\NullLogger; |
64 | use RecentChange; |
65 | use RuntimeException; |
66 | use StatusValue; |
67 | use stdClass; |
68 | use Traversable; |
69 | use WANObjectCache; |
70 | use Wikimedia\Assert\Assert; |
71 | use Wikimedia\IPUtils; |
72 | use Wikimedia\Rdbms\Database; |
73 | use Wikimedia\Rdbms\IDatabase; |
74 | use Wikimedia\Rdbms\ILoadBalancer; |
75 | use Wikimedia\Rdbms\IReadableDatabase; |
76 | use Wikimedia\Rdbms\IResultWrapper; |
77 | use Wikimedia\Rdbms\Platform\ISQLPlatform; |
78 | use Wikimedia\Rdbms\SelectQueryBuilder; |
79 | |
80 | /** |
81 | * Service for looking up page revisions. |
82 | * |
83 | * @since 1.31 |
84 | * @since 1.32 Renamed from MediaWiki\Storage\RevisionStore |
85 | * |
86 | * @note This was written to act as a drop-in replacement for the corresponding |
87 | * static methods in the old Revision class (which was later removed in 1.37). |
88 | */ |
89 | class RevisionStore implements RevisionFactory, RevisionLookup, LoggerAwareInterface { |
90 | |
91 | use LegacyArticleIdAccess; |
92 | |
93 | public const ROW_CACHE_KEY = 'revision-row-1.29'; |
94 | |
95 | public const ORDER_OLDEST_TO_NEWEST = 'ASC'; |
96 | public const ORDER_NEWEST_TO_OLDEST = 'DESC'; |
97 | |
98 | // Constants for get(...)Between methods |
99 | public const INCLUDE_OLD = 'include_old'; |
100 | public const INCLUDE_NEW = 'include_new'; |
101 | public const INCLUDE_BOTH = 'include_both'; |
102 | |
103 | /** |
104 | * @var SqlBlobStore |
105 | */ |
106 | private $blobStore; |
107 | |
108 | /** |
109 | * @var false|string |
110 | */ |
111 | private $wikiId; |
112 | |
113 | /** |
114 | * @var ILoadBalancer |
115 | */ |
116 | private $loadBalancer; |
117 | |
118 | /** |
119 | * @var WANObjectCache |
120 | */ |
121 | private $cache; |
122 | |
123 | /** |
124 | * @var BagOStuff |
125 | */ |
126 | private $localCache; |
127 | |
128 | /** |
129 | * @var CommentStore |
130 | */ |
131 | private $commentStore; |
132 | |
133 | /** @var ActorStore */ |
134 | private $actorStore; |
135 | |
136 | /** |
137 | * @var LoggerInterface |
138 | */ |
139 | private $logger; |
140 | |
141 | /** |
142 | * @var NameTableStore |
143 | */ |
144 | private $contentModelStore; |
145 | |
146 | /** |
147 | * @var NameTableStore |
148 | */ |
149 | private $slotRoleStore; |
150 | |
151 | /** @var SlotRoleRegistry */ |
152 | private $slotRoleRegistry; |
153 | |
154 | /** @var IContentHandlerFactory */ |
155 | private $contentHandlerFactory; |
156 | |
157 | /** @var HookRunner */ |
158 | private $hookRunner; |
159 | |
160 | /** @var PageStore */ |
161 | private $pageStore; |
162 | |
163 | /** @var TitleFactory */ |
164 | private $titleFactory; |
165 | |
166 | /** |
167 | * @param ILoadBalancer $loadBalancer |
168 | * @param SqlBlobStore $blobStore |
169 | * @param WANObjectCache $cache A cache for caching revision rows. This can be the local |
170 | * wiki's default instance even if $wikiId refers to a different wiki, since |
171 | * makeGlobalKey() is used to constructed a key that allows cached revision rows from |
172 | * the same database to be re-used between wikis. For example, enwiki and frwiki will |
173 | * use the same cache keys for revision rows from the wikidatawiki database, regardless |
174 | * of the cache's default key space. |
175 | * @param BagOStuff $localCache Another layer of cache, best to use APCu here. |
176 | * @param CommentStore $commentStore |
177 | * @param NameTableStore $contentModelStore |
178 | * @param NameTableStore $slotRoleStore |
179 | * @param SlotRoleRegistry $slotRoleRegistry |
180 | * @param ActorStore $actorStore |
181 | * @param IContentHandlerFactory $contentHandlerFactory |
182 | * @param PageStore $pageStore |
183 | * @param TitleFactory $titleFactory |
184 | * @param HookContainer $hookContainer |
185 | * @param false|string $wikiId Relevant wiki id or WikiAwareEntity::LOCAL for the current one |
186 | * |
187 | * @todo $blobStore should be allowed to be any BlobStore! |
188 | * |
189 | */ |
190 | public function __construct( |
191 | ILoadBalancer $loadBalancer, |
192 | SqlBlobStore $blobStore, |
193 | WANObjectCache $cache, |
194 | BagOStuff $localCache, |
195 | CommentStore $commentStore, |
196 | NameTableStore $contentModelStore, |
197 | NameTableStore $slotRoleStore, |
198 | SlotRoleRegistry $slotRoleRegistry, |
199 | ActorStore $actorStore, |
200 | IContentHandlerFactory $contentHandlerFactory, |
201 | PageStore $pageStore, |
202 | TitleFactory $titleFactory, |
203 | HookContainer $hookContainer, |
204 | $wikiId = WikiAwareEntity::LOCAL |
205 | ) { |
206 | Assert::parameterType( [ 'string', 'false' ], $wikiId, '$wikiId' ); |
207 | |
208 | $this->loadBalancer = $loadBalancer; |
209 | $this->blobStore = $blobStore; |
210 | $this->cache = $cache; |
211 | $this->localCache = $localCache; |
212 | $this->commentStore = $commentStore; |
213 | $this->contentModelStore = $contentModelStore; |
214 | $this->slotRoleStore = $slotRoleStore; |
215 | $this->slotRoleRegistry = $slotRoleRegistry; |
216 | $this->actorStore = $actorStore; |
217 | $this->wikiId = $wikiId; |
218 | $this->logger = new NullLogger(); |
219 | $this->contentHandlerFactory = $contentHandlerFactory; |
220 | $this->pageStore = $pageStore; |
221 | $this->titleFactory = $titleFactory; |
222 | $this->hookRunner = new HookRunner( $hookContainer ); |
223 | } |
224 | |
225 | public function setLogger( LoggerInterface $logger ) { |
226 | $this->logger = $logger; |
227 | } |
228 | |
229 | /** |
230 | * @return bool Whether the store is read-only |
231 | */ |
232 | public function isReadOnly() { |
233 | return $this->blobStore->isReadOnly(); |
234 | } |
235 | |
236 | /** |
237 | * Get the ID of the wiki this revision belongs to. |
238 | * |
239 | * @return string|false The wiki's logical name, of false to indicate the local wiki. |
240 | */ |
241 | public function getWikiId() { |
242 | return $this->wikiId; |
243 | } |
244 | |
245 | /** |
246 | * @param int $queryFlags a bit field composed of READ_XXX flags |
247 | * |
248 | * @return IDatabase |
249 | */ |
250 | private function getDBConnectionRefForQueryFlags( $queryFlags ) { |
251 | if ( ( $queryFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
252 | return $this->getDBConnection( DB_PRIMARY ); |
253 | } else { |
254 | return $this->getDBConnection( DB_REPLICA ); |
255 | } |
256 | } |
257 | |
258 | /** |
259 | * @param int $mode DB_PRIMARY or DB_REPLICA |
260 | * @param string|array $groups |
261 | * @return IDatabase |
262 | */ |
263 | private function getDBConnection( $mode, $groups = [] ) { |
264 | return $this->loadBalancer->getConnection( $mode, $groups, $this->wikiId ); |
265 | } |
266 | |
267 | /** |
268 | * Determines the page Title based on the available information. |
269 | * |
270 | * MCR migration note: this corresponded to Revision::getTitle |
271 | * |
272 | * @deprecated since 1.36, Use RevisionRecord::getPage() instead. |
273 | * @note The resulting Title object will be misleading if the RevisionStore is not |
274 | * for the local wiki. |
275 | * |
276 | * @param int|null $pageId |
277 | * @param int|null $revId |
278 | * @param int $queryFlags |
279 | * |
280 | * @return Title |
281 | * @throws RevisionAccessException |
282 | */ |
283 | public function getTitle( $pageId, $revId, $queryFlags = IDBAccessObject::READ_NORMAL ) { |
284 | // TODO: Hard-deprecate this once getPage() returns a PageRecord. T195069 |
285 | if ( $this->wikiId !== WikiAwareEntity::LOCAL ) { |
286 | wfDeprecatedMsg( 'Using a Title object to refer to a page on another site.', '1.36' ); |
287 | } |
288 | |
289 | $page = $this->getPage( $pageId, $revId, $queryFlags ); |
290 | return $this->titleFactory->newFromPageIdentity( $page ); |
291 | } |
292 | |
293 | /** |
294 | * Determines the page based on the available information. |
295 | * |
296 | * @param int|null $pageId |
297 | * @param int|null $revId |
298 | * @param int $queryFlags |
299 | * |
300 | * @return PageIdentity |
301 | * @throws RevisionAccessException |
302 | */ |
303 | private function getPage( ?int $pageId, ?int $revId, int $queryFlags = IDBAccessObject::READ_NORMAL ) { |
304 | if ( !$pageId && !$revId ) { |
305 | throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' ); |
306 | } |
307 | |
308 | // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title |
309 | // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method |
310 | if ( DBAccessObjectUtils::hasFlags( $queryFlags, IDBAccessObject::READ_LATEST_IMMUTABLE ) ) { |
311 | $queryFlags = IDBAccessObject::READ_NORMAL; |
312 | } |
313 | |
314 | // Loading by ID is best |
315 | if ( $pageId !== null && $pageId > 0 ) { |
316 | $page = $this->pageStore->getPageById( $pageId, $queryFlags ); |
317 | if ( $page ) { |
318 | return $this->wrapPage( $page ); |
319 | } |
320 | } |
321 | |
322 | // rev_id is defined as NOT NULL, but this revision may not yet have been inserted. |
323 | if ( $revId !== null && $revId > 0 ) { |
324 | $pageQuery = $this->pageStore->newSelectQueryBuilder( $queryFlags ) |
325 | ->join( 'revision', null, 'page_id=rev_page' ) |
326 | ->conds( [ 'rev_id' => $revId ] ) |
327 | ->caller( __METHOD__ ); |
328 | |
329 | $page = $pageQuery->fetchPageRecord(); |
330 | if ( $page ) { |
331 | return $this->wrapPage( $page ); |
332 | } |
333 | } |
334 | |
335 | // If we still don't have a title, fallback to primary DB if that wasn't already happening. |
336 | if ( $queryFlags === IDBAccessObject::READ_NORMAL ) { |
337 | $title = $this->getPage( $pageId, $revId, IDBAccessObject::READ_LATEST ); |
338 | if ( $title ) { |
339 | $this->logger->info( |
340 | __METHOD__ . ' fell back to READ_LATEST and got a Title.', |
341 | [ 'exception' => new RuntimeException() ] |
342 | ); |
343 | return $title; |
344 | } |
345 | } |
346 | |
347 | throw new RevisionAccessException( |
348 | 'Could not determine title for page ID {page_id} and revision ID {rev_id}', |
349 | [ |
350 | 'page_id' => $pageId, |
351 | 'rev_id' => $revId, |
352 | ] |
353 | ); |
354 | } |
355 | |
356 | /** |
357 | * @param PageIdentity $page |
358 | * |
359 | * @return PageIdentity |
360 | */ |
361 | private function wrapPage( PageIdentity $page ): PageIdentity { |
362 | if ( $this->wikiId === WikiAwareEntity::LOCAL ) { |
363 | // NOTE: since there is still a lot of code that needs a full Title, |
364 | // and uses Title::castFromPageIdentity() to get one, it's beneficial |
365 | // to create a Title right away if we can, so we don't have to convert |
366 | // over and over later on. |
367 | // When there is less need to convert to Title, this special case can |
368 | // be removed. |
369 | return $this->titleFactory->newFromPageIdentity( $page ); |
370 | } else { |
371 | return $page; |
372 | } |
373 | } |
374 | |
375 | /** |
376 | * @param mixed $value |
377 | * @param string $name |
378 | * |
379 | * @throws IncompleteRevisionException if $value is null |
380 | * @return mixed $value, if $value is not null |
381 | */ |
382 | private function failOnNull( $value, $name ) { |
383 | if ( $value === null ) { |
384 | throw new IncompleteRevisionException( |
385 | "$name must not be " . var_export( $value, true ) . "!" |
386 | ); |
387 | } |
388 | |
389 | return $value; |
390 | } |
391 | |
392 | /** |
393 | * @param mixed $value |
394 | * @param string $name |
395 | * |
396 | * @throws IncompleteRevisionException if $value is empty |
397 | * @return mixed $value, if $value is not null |
398 | */ |
399 | private function failOnEmpty( $value, $name ) { |
400 | if ( $value === null || $value === 0 || $value === '' ) { |
401 | throw new IncompleteRevisionException( |
402 | "$name must not be " . var_export( $value, true ) . "!" |
403 | ); |
404 | } |
405 | |
406 | return $value; |
407 | } |
408 | |
409 | /** |
410 | * Insert a new revision into the database, returning the new revision record |
411 | * on success and dies horribly on failure. |
412 | * |
413 | * MCR migration note: this replaced Revision::insertOn |
414 | * |
415 | * @param RevisionRecord $rev |
416 | * @param IDatabase $dbw (primary connection) |
417 | * |
418 | * @return RevisionRecord the new revision record. |
419 | */ |
420 | public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) { |
421 | // TODO: pass in a DBTransactionContext instead of a database connection. |
422 | $this->checkDatabaseDomain( $dbw ); |
423 | |
424 | $slotRoles = $rev->getSlotRoles(); |
425 | |
426 | // Make sure the main slot is always provided throughout migration |
427 | if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) { |
428 | throw new IncompleteRevisionException( |
429 | 'main slot must be provided' |
430 | ); |
431 | } |
432 | |
433 | // Checks |
434 | $this->failOnNull( $rev->getSize(), 'size field' ); |
435 | $this->failOnEmpty( $rev->getSha1(), 'sha1 field' ); |
436 | $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' ); |
437 | $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' ); |
438 | $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' ); |
439 | $this->failOnNull( $user->getId(), 'user field' ); |
440 | $this->failOnEmpty( $user->getName(), 'user_text field' ); |
441 | |
442 | if ( !$rev->isReadyForInsertion() ) { |
443 | // This is here for future-proofing. At the time this check being added, it |
444 | // was redundant to the individual checks above. |
445 | throw new IncompleteRevisionException( 'Revision is incomplete' ); |
446 | } |
447 | |
448 | if ( $slotRoles == [ SlotRecord::MAIN ] ) { |
449 | // T239717: If the main slot is the only slot, make sure the revision's nominal size |
450 | // and hash match the main slot's nominal size and hash. |
451 | $mainSlot = $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ); |
452 | Assert::precondition( |
453 | $mainSlot->getSize() === $rev->getSize(), |
454 | 'The revisions\'s size must match the main slot\'s size (see T239717)' |
455 | ); |
456 | Assert::precondition( |
457 | $mainSlot->getSha1() === $rev->getSha1(), |
458 | 'The revisions\'s SHA1 hash must match the main slot\'s SHA1 hash (see T239717)' |
459 | ); |
460 | } |
461 | |
462 | $pageId = $this->failOnEmpty( $rev->getPageId( $this->wikiId ), 'rev_page field' ); // check this early |
463 | |
464 | $parentId = $rev->getParentId() ?? $this->getPreviousRevisionId( $dbw, $rev ); |
465 | |
466 | /** @var RevisionRecord $rev */ |
467 | $rev = $dbw->doAtomicSection( |
468 | __METHOD__, |
469 | function ( IDatabase $dbw, $fname ) use ( |
470 | $rev, |
471 | $user, |
472 | $comment, |
473 | $pageId, |
474 | $parentId |
475 | ) { |
476 | return $this->insertRevisionInternal( |
477 | $rev, |
478 | $dbw, |
479 | $user, |
480 | $comment, |
481 | $rev->getPage(), |
482 | $pageId, |
483 | $parentId |
484 | ); |
485 | } |
486 | ); |
487 | |
488 | Assert::postcondition( $rev->getId( $this->wikiId ) > 0, 'revision must have an ID' ); |
489 | Assert::postcondition( $rev->getPageId( $this->wikiId ) > 0, 'revision must have a page ID' ); |
490 | Assert::postcondition( |
491 | $rev->getComment( RevisionRecord::RAW ) !== null, |
492 | 'revision must have a comment' |
493 | ); |
494 | Assert::postcondition( |
495 | $rev->getUser( RevisionRecord::RAW ) !== null, |
496 | 'revision must have a user' |
497 | ); |
498 | |
499 | // Trigger exception if the main slot is missing. |
500 | // Technically, this could go away after MCR migration: while |
501 | // calling code may require a main slot to exist, RevisionStore |
502 | // really should not know or care about that requirement. |
503 | $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ); |
504 | |
505 | foreach ( $slotRoles as $role ) { |
506 | $slot = $rev->getSlot( $role, RevisionRecord::RAW ); |
507 | Assert::postcondition( |
508 | $slot->getContent() !== null, |
509 | $role . ' slot must have content' |
510 | ); |
511 | Assert::postcondition( |
512 | $slot->hasRevision(), |
513 | $role . ' slot must have a revision associated' |
514 | ); |
515 | } |
516 | |
517 | $this->hookRunner->onRevisionRecordInserted( $rev ); |
518 | |
519 | return $rev; |
520 | } |
521 | |
522 | /** |
523 | * Update derived slots in an existing revision into the database, returning the modified |
524 | * slots on success. |
525 | * |
526 | * @param RevisionRecord $revision After this method returns, the $revision object will be |
527 | * obsolete in that it does not have the new slots. |
528 | * @param RevisionSlotsUpdate $revisionSlotsUpdate |
529 | * @param IDatabase $dbw (primary connection) |
530 | * |
531 | * @return SlotRecord[] the new slot records. |
532 | * @internal |
533 | */ |
534 | public function updateSlotsOn( |
535 | RevisionRecord $revision, |
536 | RevisionSlotsUpdate $revisionSlotsUpdate, |
537 | IDatabase $dbw |
538 | ): array { |
539 | $this->checkDatabaseDomain( $dbw ); |
540 | |
541 | // Make sure all modified and removed slots are derived slots |
542 | foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) { |
543 | Assert::precondition( |
544 | $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(), |
545 | 'Trying to modify a slot that is not derived' |
546 | ); |
547 | } |
548 | foreach ( $revisionSlotsUpdate->getRemovedRoles() as $role ) { |
549 | $isDerived = $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(); |
550 | Assert::precondition( |
551 | $isDerived, |
552 | 'Trying to remove a slot that is not derived' |
553 | ); |
554 | throw new LogicException( 'Removing derived slots is not yet implemented. See T277394.' ); |
555 | } |
556 | |
557 | /** @var SlotRecord[] $slotRecords */ |
558 | $slotRecords = $dbw->doAtomicSection( |
559 | __METHOD__, |
560 | function ( IDatabase $dbw, $fname ) use ( |
561 | $revision, |
562 | $revisionSlotsUpdate |
563 | ) { |
564 | return $this->updateSlotsInternal( |
565 | $revision, |
566 | $revisionSlotsUpdate, |
567 | $dbw |
568 | ); |
569 | } |
570 | ); |
571 | |
572 | foreach ( $slotRecords as $role => $slot ) { |
573 | Assert::postcondition( |
574 | $slot->getContent() !== null, |
575 | $role . ' slot must have content' |
576 | ); |
577 | Assert::postcondition( |
578 | $slot->hasRevision(), |
579 | $role . ' slot must have a revision associated' |
580 | ); |
581 | } |
582 | |
583 | return $slotRecords; |
584 | } |
585 | |
586 | /** |
587 | * @param RevisionRecord $revision |
588 | * @param RevisionSlotsUpdate $revisionSlotsUpdate |
589 | * @param IDatabase $dbw |
590 | * @return SlotRecord[] |
591 | */ |
592 | private function updateSlotsInternal( |
593 | RevisionRecord $revision, |
594 | RevisionSlotsUpdate $revisionSlotsUpdate, |
595 | IDatabase $dbw |
596 | ): array { |
597 | $page = $revision->getPage(); |
598 | $revId = $revision->getId( $this->wikiId ); |
599 | $blobHints = [ |
600 | BlobStore::PAGE_HINT => $page->getId( $this->wikiId ), |
601 | BlobStore::REVISION_HINT => $revId, |
602 | BlobStore::PARENT_HINT => $revision->getParentId( $this->wikiId ), |
603 | ]; |
604 | |
605 | $newSlots = []; |
606 | foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) { |
607 | $slot = $revisionSlotsUpdate->getModifiedSlot( $role ); |
608 | $newSlots[$role] = $this->insertSlotOn( $dbw, $revId, $slot, $page, $blobHints ); |
609 | } |
610 | |
611 | return $newSlots; |
612 | } |
613 | |
614 | private function insertRevisionInternal( |
615 | RevisionRecord $rev, |
616 | IDatabase $dbw, |
617 | UserIdentity $user, |
618 | CommentStoreComment $comment, |
619 | PageIdentity $page, |
620 | $pageId, |
621 | $parentId |
622 | ) { |
623 | $slotRoles = $rev->getSlotRoles(); |
624 | |
625 | $revisionRow = $this->insertRevisionRowOn( |
626 | $dbw, |
627 | $rev, |
628 | $parentId |
629 | ); |
630 | |
631 | $revisionId = $revisionRow['rev_id']; |
632 | |
633 | $blobHints = [ |
634 | BlobStore::PAGE_HINT => $pageId, |
635 | BlobStore::REVISION_HINT => $revisionId, |
636 | BlobStore::PARENT_HINT => $parentId, |
637 | ]; |
638 | |
639 | $newSlots = []; |
640 | foreach ( $slotRoles as $role ) { |
641 | $slot = $rev->getSlot( $role, RevisionRecord::RAW ); |
642 | |
643 | // If the SlotRecord already has a revision ID set, this means it already exists |
644 | // in the database, and should already belong to the current revision. |
645 | // However, a slot may already have a revision, but no content ID, if the slot |
646 | // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD |
647 | // mode, and the respective archive row was not yet migrated to the new schema. |
648 | // In that case, a new slot row (and content row) must be inserted even during |
649 | // undeletion. |
650 | if ( $slot->hasRevision() && $slot->hasContentId() ) { |
651 | // TODO: properly abort transaction if the assertion fails! |
652 | Assert::parameter( |
653 | $slot->getRevision() === $revisionId, |
654 | 'slot role ' . $slot->getRole(), |
655 | 'Existing slot should belong to revision ' |
656 | . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!' |
657 | ); |
658 | |
659 | // Slot exists, nothing to do, move along. |
660 | // This happens when restoring archived revisions. |
661 | |
662 | $newSlots[$role] = $slot; |
663 | } else { |
664 | $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $page, $blobHints ); |
665 | } |
666 | } |
667 | |
668 | $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId ); |
669 | |
670 | $rev = new RevisionStoreRecord( |
671 | $page, |
672 | $user, |
673 | $comment, |
674 | (object)$revisionRow, |
675 | new RevisionSlots( $newSlots ), |
676 | $this->wikiId |
677 | ); |
678 | |
679 | return $rev; |
680 | } |
681 | |
682 | /** |
683 | * @param IDatabase $dbw |
684 | * @param int $revisionId |
685 | * @param SlotRecord $protoSlot |
686 | * @param PageIdentity $page |
687 | * @param array $blobHints See the BlobStore::XXX_HINT constants |
688 | * @return SlotRecord |
689 | */ |
690 | private function insertSlotOn( |
691 | IDatabase $dbw, |
692 | $revisionId, |
693 | SlotRecord $protoSlot, |
694 | PageIdentity $page, |
695 | array $blobHints = [] |
696 | ) { |
697 | if ( $protoSlot->hasAddress() ) { |
698 | $blobAddress = $protoSlot->getAddress(); |
699 | } else { |
700 | $blobAddress = $this->storeContentBlob( $protoSlot, $page, $blobHints ); |
701 | } |
702 | |
703 | if ( $protoSlot->hasContentId() ) { |
704 | $contentId = $protoSlot->getContentId(); |
705 | } else { |
706 | $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress ); |
707 | } |
708 | |
709 | $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId ); |
710 | |
711 | return SlotRecord::newSaved( |
712 | $revisionId, |
713 | $contentId, |
714 | $blobAddress, |
715 | $protoSlot |
716 | ); |
717 | } |
718 | |
719 | /** |
720 | * Insert IP revision into ip_changes for use when querying for a range. |
721 | * @param IDatabase $dbw |
722 | * @param UserIdentity $user |
723 | * @param RevisionRecord $rev |
724 | * @param int $revisionId |
725 | */ |
726 | private function insertIpChangesRow( |
727 | IDatabase $dbw, |
728 | UserIdentity $user, |
729 | RevisionRecord $rev, |
730 | $revisionId |
731 | ) { |
732 | if ( !$user->isRegistered() && IPUtils::isValid( $user->getName() ) ) { |
733 | $dbw->newInsertQueryBuilder() |
734 | ->insertInto( 'ip_changes' ) |
735 | ->row( [ |
736 | 'ipc_rev_id' => $revisionId, |
737 | 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ), |
738 | 'ipc_hex' => IPUtils::toHex( $user->getName() ), |
739 | ] ) |
740 | ->caller( __METHOD__ )->execute(); |
741 | |
742 | } |
743 | } |
744 | |
745 | /** |
746 | * @param IDatabase $dbw |
747 | * @param RevisionRecord $rev |
748 | * @param int $parentId |
749 | * |
750 | * @return array a revision table row |
751 | * |
752 | * @throws MWException |
753 | * @throws MWUnknownContentModelException |
754 | */ |
755 | private function insertRevisionRowOn( |
756 | IDatabase $dbw, |
757 | RevisionRecord $rev, |
758 | $parentId |
759 | ) { |
760 | $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $parentId ); |
761 | |
762 | $revisionRow += $this->commentStore->insert( |
763 | $dbw, |
764 | 'rev_comment', |
765 | $rev->getComment( RevisionRecord::RAW ) |
766 | ); |
767 | |
768 | $dbw->newInsertQueryBuilder() |
769 | ->insertInto( 'revision' ) |
770 | ->row( $revisionRow ) |
771 | ->caller( __METHOD__ )->execute(); |
772 | |
773 | if ( !isset( $revisionRow['rev_id'] ) ) { |
774 | // only if auto-increment was used |
775 | $revisionRow['rev_id'] = intval( $dbw->insertId() ); |
776 | |
777 | if ( $dbw->getType() === 'mysql' ) { |
778 | // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the |
779 | // auto-increment value to disk, so on server restart it might reuse IDs from deleted |
780 | // revisions. We can fix that with an insert with an explicit rev_id value, if necessary. |
781 | |
782 | $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) ); |
783 | $table = 'archive'; |
784 | $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) ); |
785 | if ( $maxRevId2 >= $maxRevId ) { |
786 | $maxRevId = $maxRevId2; |
787 | $table = 'slots'; |
788 | } |
789 | |
790 | if ( $maxRevId >= $revisionRow['rev_id'] ) { |
791 | $this->logger->debug( |
792 | '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.' |
793 | . ' Trying to fix it.', |
794 | [ |
795 | 'revid' => $revisionRow['rev_id'], |
796 | 'table' => $table, |
797 | 'maxrevid' => $maxRevId, |
798 | ] |
799 | ); |
800 | |
801 | if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) { |
802 | throw new MWException( 'Failed to get database lock for T202032' ); |
803 | } |
804 | $fname = __METHOD__; |
805 | $dbw->onTransactionResolution( |
806 | static function ( $trigger, IDatabase $dbw ) use ( $fname ) { |
807 | $dbw->unlock( 'fix-for-T202032', $fname ); |
808 | }, |
809 | __METHOD__ |
810 | ); |
811 | |
812 | $dbw->newDeleteQueryBuilder() |
813 | ->deleteFrom( 'revision' ) |
814 | ->where( [ 'rev_id' => $revisionRow['rev_id'] ] ) |
815 | ->caller( __METHOD__ )->execute(); |
816 | |
817 | // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction |
818 | // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing |
819 | // inserts too, though, at least on MariaDB 10.1.29. |
820 | // |
821 | // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent |
822 | // transactions in this code path thanks to the row lock from the original ->insert() above. |
823 | // |
824 | // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning |
825 | // that's for non-MySQL DBs. |
826 | $row1 = $dbw->query( |
827 | $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE', |
828 | __METHOD__ |
829 | )->fetchObject(); |
830 | |
831 | $row2 = $dbw->query( |
832 | $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ ) |
833 | . ' FOR UPDATE', |
834 | __METHOD__ |
835 | )->fetchObject(); |
836 | |
837 | $maxRevId = max( |
838 | $maxRevId, |
839 | $row1 ? intval( $row1->v ) : 0, |
840 | $row2 ? intval( $row2->v ) : 0 |
841 | ); |
842 | |
843 | // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent |
844 | // transactions will throw a duplicate key error here. It doesn't seem worth trying |
845 | // to avoid that. |
846 | $revisionRow['rev_id'] = $maxRevId + 1; |
847 | $dbw->newInsertQueryBuilder() |
848 | ->insertInto( 'revision' ) |
849 | ->row( $revisionRow ) |
850 | ->caller( __METHOD__ )->execute(); |
851 | } |
852 | } |
853 | } |
854 | |
855 | return $revisionRow; |
856 | } |
857 | |
858 | /** |
859 | * @param IDatabase $dbw |
860 | * @param RevisionRecord $rev |
861 | * @param int $parentId |
862 | * |
863 | * @return array a revision table row |
864 | */ |
865 | private function getBaseRevisionRow( |
866 | IDatabase $dbw, |
867 | RevisionRecord $rev, |
868 | $parentId |
869 | ) { |
870 | // Record the edit in revisions |
871 | $revisionRow = [ |
872 | 'rev_page' => $rev->getPageId( $this->wikiId ), |
873 | 'rev_parent_id' => $parentId, |
874 | 'rev_actor' => $this->actorStore->acquireActorId( |
875 | $rev->getUser( RevisionRecord::RAW ), |
876 | $dbw |
877 | ), |
878 | 'rev_minor_edit' => $rev->isMinor() ? 1 : 0, |
879 | 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ), |
880 | 'rev_deleted' => $rev->getVisibility(), |
881 | 'rev_len' => $rev->getSize(), |
882 | 'rev_sha1' => $rev->getSha1(), |
883 | ]; |
884 | |
885 | if ( $rev->getId( $this->wikiId ) !== null ) { |
886 | // Needed to restore revisions with their original ID |
887 | $revisionRow['rev_id'] = $rev->getId( $this->wikiId ); |
888 | } |
889 | |
890 | return $revisionRow; |
891 | } |
892 | |
893 | /** |
894 | * @param SlotRecord $slot |
895 | * @param PageIdentity $page |
896 | * @param array $blobHints See the BlobStore::XXX_HINT constants |
897 | * |
898 | * @throws MWException |
899 | * @return string the blob address |
900 | */ |
901 | private function storeContentBlob( |
902 | SlotRecord $slot, |
903 | PageIdentity $page, |
904 | array $blobHints = [] |
905 | ) { |
906 | $content = $slot->getContent(); |
907 | $format = $content->getDefaultFormat(); |
908 | $model = $content->getModel(); |
909 | |
910 | $this->checkContent( $content, $page, $slot->getRole() ); |
911 | |
912 | return $this->blobStore->storeBlob( |
913 | $content->serialize( $format ), |
914 | // These hints "leak" some information from the higher abstraction layer to |
915 | // low level storage to allow for optimization. |
916 | array_merge( |
917 | $blobHints, |
918 | [ |
919 | BlobStore::DESIGNATION_HINT => 'page-content', |
920 | BlobStore::ROLE_HINT => $slot->getRole(), |
921 | BlobStore::SHA1_HINT => $slot->getSha1(), |
922 | BlobStore::MODEL_HINT => $model, |
923 | BlobStore::FORMAT_HINT => $format, |
924 | ] |
925 | ) |
926 | ); |
927 | } |
928 | |
929 | /** |
930 | * @param SlotRecord $slot |
931 | * @param IDatabase $dbw |
932 | * @param int $revisionId |
933 | * @param int $contentId |
934 | */ |
935 | private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) { |
936 | $dbw->newInsertQueryBuilder() |
937 | ->insertInto( 'slots' ) |
938 | ->row( [ |
939 | 'slot_revision_id' => $revisionId, |
940 | 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ), |
941 | 'slot_content_id' => $contentId, |
942 | // If the slot has a specific origin use that ID, otherwise use the ID of the revision |
943 | // that we just inserted. |
944 | 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId, |
945 | ] ) |
946 | ->caller( __METHOD__ )->execute(); |
947 | } |
948 | |
949 | /** |
950 | * @param SlotRecord $slot |
951 | * @param IDatabase $dbw |
952 | * @param string $blobAddress |
953 | * @return int content row ID |
954 | */ |
955 | private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) { |
956 | $dbw->newInsertQueryBuilder() |
957 | ->insertInto( 'content' ) |
958 | ->row( [ |
959 | 'content_size' => $slot->getSize(), |
960 | 'content_sha1' => $slot->getSha1(), |
961 | 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ), |
962 | 'content_address' => $blobAddress, |
963 | ] ) |
964 | ->caller( __METHOD__ )->execute(); |
965 | return intval( $dbw->insertId() ); |
966 | } |
967 | |
968 | /** |
969 | * MCR migration note: this corresponded to Revision::checkContentModel |
970 | * |
971 | * @param Content $content |
972 | * @param PageIdentity $page |
973 | * @param string $role |
974 | * |
975 | * @throws MWException |
976 | * @throws MWUnknownContentModelException |
977 | */ |
978 | private function checkContent( Content $content, PageIdentity $page, string $role ) { |
979 | // Note: may return null for revisions that have not yet been inserted |
980 | |
981 | $model = $content->getModel(); |
982 | $format = $content->getDefaultFormat(); |
983 | $handler = $content->getContentHandler(); |
984 | |
985 | if ( !$handler->isSupportedFormat( $format ) ) { |
986 | throw new MWException( |
987 | "Can't use format $format with content model $model on $page role $role" |
988 | ); |
989 | } |
990 | |
991 | if ( !$content->isValid() ) { |
992 | throw new MWException( |
993 | "New content for $page role $role is not valid! Content model is $model" |
994 | ); |
995 | } |
996 | } |
997 | |
998 | /** |
999 | * Create a new null-revision for insertion into a page's |
1000 | * history. This will not re-save the text, but simply refer |
1001 | * to the text from the previous version. |
1002 | * |
1003 | * Such revisions can for instance identify page rename |
1004 | * operations and other such meta-modifications. |
1005 | * |
1006 | * @note This method grabs a FOR UPDATE lock on the relevant row of the page table, |
1007 | * to prevent a new revision from being inserted before the null revision has been written |
1008 | * to the database. |
1009 | * |
1010 | * MCR migration note: this replaced Revision::newNullRevision |
1011 | * |
1012 | * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that |
1013 | * (or go away). |
1014 | * |
1015 | * @param IDatabase $dbw used for obtaining the lock on the page table row |
1016 | * @param PageIdentity $page the page to read from |
1017 | * @param CommentStoreComment $comment RevisionRecord's summary |
1018 | * @param bool $minor Whether the revision should be considered as minor |
1019 | * @param UserIdentity $user The user to attribute the revision to |
1020 | * |
1021 | * @return RevisionRecord|null RevisionRecord or null on error |
1022 | */ |
1023 | public function newNullRevision( |
1024 | IDatabase $dbw, |
1025 | PageIdentity $page, |
1026 | CommentStoreComment $comment, |
1027 | $minor, |
1028 | UserIdentity $user |
1029 | ) { |
1030 | $this->checkDatabaseDomain( $dbw ); |
1031 | |
1032 | $pageId = $this->getArticleId( $page ); |
1033 | |
1034 | // T51581: Lock the page table row to ensure no other process |
1035 | // is adding a revision to the page at the same time. |
1036 | // Avoid locking extra tables, compare T191892. |
1037 | $pageLatest = $dbw->newSelectQueryBuilder() |
1038 | ->select( 'page_latest' ) |
1039 | ->forUpdate() |
1040 | ->from( 'page' ) |
1041 | ->where( [ 'page_id' => $pageId ] ) |
1042 | ->caller( __METHOD__ )->fetchField(); |
1043 | |
1044 | if ( !$pageLatest ) { |
1045 | $msg = 'T235589: Failed to select table row during null revision creation' . |
1046 | " Page id '$pageId' does not exist."; |
1047 | $this->logger->error( |
1048 | $msg, |
1049 | [ 'exception' => new RuntimeException( $msg ) ] |
1050 | ); |
1051 | |
1052 | return null; |
1053 | } |
1054 | |
1055 | // Fetch the actual revision row from primary DB, without locking all extra tables. |
1056 | $oldRevision = $this->loadRevisionFromConds( |
1057 | $dbw, |
1058 | [ 'rev_id' => intval( $pageLatest ) ], |
1059 | IDBAccessObject::READ_LATEST, |
1060 | $page |
1061 | ); |
1062 | |
1063 | if ( !$oldRevision ) { |
1064 | $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId."; |
1065 | $this->logger->error( |
1066 | $msg, |
1067 | [ 'exception' => new RuntimeException( $msg ) ] |
1068 | ); |
1069 | return null; |
1070 | } |
1071 | |
1072 | // Construct the new revision |
1073 | $timestamp = MWTimestamp::now( TS_MW ); |
1074 | $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision ); |
1075 | |
1076 | $newRevision->setComment( $comment ); |
1077 | $newRevision->setUser( $user ); |
1078 | $newRevision->setTimestamp( $timestamp ); |
1079 | $newRevision->setMinorEdit( $minor ); |
1080 | |
1081 | return $newRevision; |
1082 | } |
1083 | |
1084 | /** |
1085 | * MCR migration note: this replaced Revision::isUnpatrolled |
1086 | * |
1087 | * @todo This is overly specific, so move or kill this method. |
1088 | * |
1089 | * @param RevisionRecord $rev |
1090 | * |
1091 | * @return int Rcid of the unpatrolled row, zero if there isn't one |
1092 | */ |
1093 | public function getRcIdIfUnpatrolled( RevisionRecord $rev ) { |
1094 | $rc = $this->getRecentChange( $rev ); |
1095 | if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) { |
1096 | return $rc->getAttribute( 'rc_id' ); |
1097 | } else { |
1098 | return 0; |
1099 | } |
1100 | } |
1101 | |
1102 | /** |
1103 | * Get the RC object belonging to the current revision, if there's one |
1104 | * |
1105 | * MCR migration note: this replaced Revision::getRecentChange |
1106 | * |
1107 | * @todo move this somewhere else? |
1108 | * |
1109 | * @param RevisionRecord $rev |
1110 | * @param int $flags (optional) $flags include: |
1111 | * IDBAccessObject::READ_LATEST: Select the data from the primary DB |
1112 | * |
1113 | * @return null|RecentChange |
1114 | */ |
1115 | public function getRecentChange( RevisionRecord $rev, $flags = 0 ) { |
1116 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
1117 | $dbType = DB_PRIMARY; |
1118 | } else { |
1119 | $dbType = DB_REPLICA; |
1120 | } |
1121 | |
1122 | $rc = RecentChange::newFromConds( |
1123 | [ |
1124 | 'rc_this_oldid' => $rev->getId( $this->wikiId ), |
1125 | // rc_this_oldid does not have to be unique, |
1126 | // in particular, it is shared with categorization |
1127 | // changes. Prefer the original change because callers |
1128 | // often expect a change for patrolling. |
1129 | 'rc_type' => [ RC_EDIT, RC_NEW, RC_LOG ], |
1130 | ], |
1131 | __METHOD__, |
1132 | $dbType |
1133 | ); |
1134 | |
1135 | // XXX: cache this locally? Glue it to the RevisionRecord? |
1136 | return $rc; |
1137 | } |
1138 | |
1139 | /** |
1140 | * Loads a Content object based on a slot row. |
1141 | * |
1142 | * This method does not call $slot->getContent(), and may be used as a callback |
1143 | * called by $slot->getContent(). |
1144 | * |
1145 | * MCR migration note: this roughly corresponded to Revision::getContentInternal |
1146 | * |
1147 | * @param SlotRecord $slot The SlotRecord to load content for |
1148 | * @param string|null $blobData The content blob, in the form indicated by $blobFlags |
1149 | * @param string|null $blobFlags Flags indicating how $blobData needs to be processed. |
1150 | * Use null if no processing should happen. That is in contrast to the empty string, |
1151 | * which causes the blob to be decoded according to the configured legacy encoding. |
1152 | * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded |
1153 | * @param int $queryFlags |
1154 | * |
1155 | * @throws RevisionAccessException |
1156 | * @return Content |
1157 | */ |
1158 | private function loadSlotContent( |
1159 | SlotRecord $slot, |
1160 | ?string $blobData = null, |
1161 | ?string $blobFlags = null, |
1162 | ?string $blobFormat = null, |
1163 | int $queryFlags = 0 |
1164 | ) { |
1165 | if ( $blobData !== null ) { |
1166 | $blobAddress = $slot->hasAddress() ? $slot->getAddress() : null; |
1167 | |
1168 | if ( $blobFlags === null ) { |
1169 | // No blob flags, so use the blob verbatim. |
1170 | $data = $blobData; |
1171 | } else { |
1172 | try { |
1173 | $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $blobAddress ); |
1174 | } catch ( BadBlobException $e ) { |
1175 | throw new BadRevisionException( $e->getMessage(), [], 0, $e ); |
1176 | } |
1177 | |
1178 | if ( $data === false ) { |
1179 | throw new RevisionAccessException( |
1180 | 'Failed to expand blob data using flags {flags} (key: {cache_key})', |
1181 | [ |
1182 | 'flags' => $blobFlags, |
1183 | 'cache_key' => $blobAddress, |
1184 | ] |
1185 | ); |
1186 | } |
1187 | } |
1188 | |
1189 | } else { |
1190 | $address = $slot->getAddress(); |
1191 | try { |
1192 | $data = $this->blobStore->getBlob( $address, $queryFlags ); |
1193 | } catch ( BadBlobException $e ) { |
1194 | throw new BadRevisionException( $e->getMessage(), [], 0, $e ); |
1195 | } catch ( BlobAccessException $e ) { |
1196 | throw new RevisionAccessException( |
1197 | 'Failed to load data blob from {address} for revision {revision}. ' |
1198 | . 'If this problem persist, use the findBadBlobs maintenance script ' |
1199 | . 'to investigate the issue and mark bad blobs.', |
1200 | [ 'address' => $e->getMessage(), 'revision' => $slot->getRevision() ], |
1201 | 0, |
1202 | $e |
1203 | ); |
1204 | } |
1205 | } |
1206 | |
1207 | $model = $slot->getModel(); |
1208 | |
1209 | // If the content model is not known, don't fail here (T220594, T220793, T228921) |
1210 | if ( !$this->contentHandlerFactory->isDefinedModel( $model ) ) { |
1211 | $this->logger->warning( |
1212 | "Undefined content model '$model', falling back to FallbackContent", |
1213 | [ |
1214 | 'content_address' => $slot->getAddress(), |
1215 | 'rev_id' => $slot->getRevision(), |
1216 | 'role_name' => $slot->getRole(), |
1217 | 'model_name' => $model, |
1218 | 'exception' => new RuntimeException() |
1219 | ] |
1220 | ); |
1221 | |
1222 | return new FallbackContent( $data, $model ); |
1223 | } |
1224 | |
1225 | return $this->contentHandlerFactory |
1226 | ->getContentHandler( $model ) |
1227 | ->unserializeContent( $data, $blobFormat ); |
1228 | } |
1229 | |
1230 | /** |
1231 | * Load a page revision from a given revision ID number. |
1232 | * Returns null if no such revision can be found. |
1233 | * |
1234 | * MCR migration note: this replaced Revision::newFromId |
1235 | * |
1236 | * $flags include: |
1237 | * IDBAccessObject::READ_LATEST: Select the data from the primary DB |
1238 | * IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB |
1239 | * |
1240 | * @param int $id |
1241 | * @param int $flags (optional) |
1242 | * @param PageIdentity|null $page The page the revision belongs to. |
1243 | * Providing the page may improve performance. |
1244 | * |
1245 | * @return RevisionRecord|null |
1246 | */ |
1247 | public function getRevisionById( $id, $flags = 0, PageIdentity $page = null ) { |
1248 | return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags, $page ); |
1249 | } |
1250 | |
1251 | /** |
1252 | * Load either the current, or a specified, revision |
1253 | * that's attached to a given link target. If not attached |
1254 | * to that link target, will return null. |
1255 | * |
1256 | * MCR migration note: this replaced Revision::newFromTitle |
1257 | * |
1258 | * $flags include: |
1259 | * IDBAccessObject::READ_LATEST: Select the data from the primary DB |
1260 | * IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB |
1261 | * |
1262 | * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36 |
1263 | * @param int $revId (optional) |
1264 | * @param int $flags Bitfield (optional) |
1265 | * @return RevisionRecord|null |
1266 | */ |
1267 | public function getRevisionByTitle( $page, $revId = 0, $flags = 0 ) { |
1268 | $conds = [ |
1269 | 'page_namespace' => $page->getNamespace(), |
1270 | 'page_title' => $page->getDBkey() |
1271 | ]; |
1272 | |
1273 | if ( $page instanceof LinkTarget ) { |
1274 | // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756) |
1275 | $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null; |
1276 | } |
1277 | |
1278 | if ( $revId ) { |
1279 | // Use the specified revision ID. |
1280 | // Note that we use newRevisionFromConds here because we want to retry |
1281 | // and fall back to primary DB if the page is not found on a replica. |
1282 | // Since the caller supplied a revision ID, we are pretty sure the revision is |
1283 | // supposed to exist, so we should try hard to find it. |
1284 | $conds['rev_id'] = $revId; |
1285 | return $this->newRevisionFromConds( $conds, $flags, $page ); |
1286 | } else { |
1287 | // Use a join to get the latest revision. |
1288 | // Note that we don't use newRevisionFromConds here because we don't want to retry |
1289 | // and fall back to primary DB. The assumption is that we only want to force the fallback |
1290 | // if we are quite sure the revision exists because the caller supplied a revision ID. |
1291 | // If the page isn't found at all on a replica, it probably simply does not exist. |
1292 | $db = $this->getDBConnectionRefForQueryFlags( $flags ); |
1293 | $conds[] = 'rev_id=page_latest'; |
1294 | return $this->loadRevisionFromConds( $db, $conds, $flags, $page ); |
1295 | } |
1296 | } |
1297 | |
1298 | /** |
1299 | * Load either the current, or a specified, revision |
1300 | * that's attached to a given page ID. |
1301 | * Returns null if no such revision can be found. |
1302 | * |
1303 | * MCR migration note: this replaced Revision::newFromPageId |
1304 | * |
1305 | * $flags include: |
1306 | * IDBAccessObject::READ_LATEST: Select the data from the primary DB (since 1.20) |
1307 | * IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB |
1308 | * |
1309 | * @param int $pageId |
1310 | * @param int $revId (optional) |
1311 | * @param int $flags Bitfield (optional) |
1312 | * @return RevisionRecord|null |
1313 | */ |
1314 | public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) { |
1315 | $conds = [ 'page_id' => $pageId ]; |
1316 | if ( $revId ) { |
1317 | // Use the specified revision ID. |
1318 | // Note that we use newRevisionFromConds here because we want to retry |
1319 | // and fall back to primary DB if the page is not found on a replica. |
1320 | // Since the caller supplied a revision ID, we are pretty sure the revision is |
1321 | // supposed to exist, so we should try hard to find it. |
1322 | $conds['rev_id'] = $revId; |
1323 | return $this->newRevisionFromConds( $conds, $flags ); |
1324 | } else { |
1325 | // Use a join to get the latest revision. |
1326 | // Note that we don't use newRevisionFromConds here because we don't want to retry |
1327 | // and fall back to primary DB. The assumption is that we only want to force the fallback |
1328 | // if we are quite sure the revision exists because the caller supplied a revision ID. |
1329 | // If the page isn't found at all on a replica, it probably simply does not exist. |
1330 | $db = $this->getDBConnectionRefForQueryFlags( $flags ); |
1331 | |
1332 | $conds[] = 'rev_id=page_latest'; |
1333 | |
1334 | return $this->loadRevisionFromConds( $db, $conds, $flags ); |
1335 | } |
1336 | } |
1337 | |
1338 | /** |
1339 | * Load the revision for the given title with the given timestamp. |
1340 | * WARNING: Timestamps may in some circumstances not be unique, |
1341 | * so this isn't the best key to use. |
1342 | * |
1343 | * MCR migration note: this replaced Revision::loadFromTimestamp |
1344 | * |
1345 | * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36 |
1346 | * @param string $timestamp |
1347 | * @param int $flags Bitfield (optional) include: |
1348 | * IDBAccessObject::READ_LATEST: Select the data from the primary DB |
1349 | * IDBAccessObject::READ_LOCKING: Select & lock the data from the primary DB |
1350 | * Default: IDBAccessObject::READ_NORMAL |
1351 | * @return RevisionRecord|null |
1352 | */ |
1353 | public function getRevisionByTimestamp( |
1354 | $page, |
1355 | string $timestamp, |
1356 | int $flags = IDBAccessObject::READ_NORMAL |
1357 | ): ?RevisionRecord { |
1358 | if ( $page instanceof LinkTarget ) { |
1359 | // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756) |
1360 | $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null; |
1361 | } |
1362 | $db = $this->getDBConnectionRefForQueryFlags( $flags ); |
1363 | return $this->newRevisionFromConds( |
1364 | [ |
1365 | 'rev_timestamp' => $db->timestamp( $timestamp ), |
1366 | 'page_namespace' => $page->getNamespace(), |
1367 | 'page_title' => $page->getDBkey() |
1368 | ], |
1369 | $flags, |
1370 | $page |
1371 | ); |
1372 | } |
1373 | |
1374 | /** |
1375 | * @param int $revId The revision to load slots for. |
1376 | * @param int $queryFlags |
1377 | * @param PageIdentity $page |
1378 | * |
1379 | * @return SlotRecord[] |
1380 | */ |
1381 | private function loadSlotRecords( $revId, $queryFlags, PageIdentity $page ) { |
1382 | // TODO: Find a way to add NS_MODULE from Scribunto here |
1383 | if ( $page->getNamespace() !== NS_TEMPLATE ) { |
1384 | $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page ); |
1385 | return $this->constructSlotRecords( $revId, $res, $queryFlags, $page ); |
1386 | } |
1387 | |
1388 | // TODO: These caches should not be needed. See T297147#7563670 |
1389 | $res = $this->localCache->getWithSetCallback( |
1390 | $this->localCache->makeKey( |
1391 | 'revision-slots', |
1392 | $page->getWikiId(), |
1393 | $page->getId( $page->getWikiId() ), |
1394 | $revId |
1395 | ), |
1396 | $this->localCache::TTL_HOUR, |
1397 | function () use ( $revId, $queryFlags, $page ) { |
1398 | return $this->cache->getWithSetCallback( |
1399 | $this->cache->makeKey( |
1400 | 'revision-slots', |
1401 | $page->getWikiId(), |
1402 | $page->getId( $page->getWikiId() ), |
1403 | $revId |
1404 | ), |
1405 | WANObjectCache::TTL_DAY, |
1406 | function () use ( $revId, $queryFlags, $page ) { |
1407 | $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page ); |
1408 | if ( !$res ) { |
1409 | // Avoid caching |
1410 | return false; |
1411 | } |
1412 | return $res; |
1413 | } |
1414 | ); |
1415 | } |
1416 | ); |
1417 | if ( !$res ) { |
1418 | $res = []; |
1419 | } |
1420 | |
1421 | return $this->constructSlotRecords( $revId, $res, $queryFlags, $page ); |
1422 | } |
1423 | |
1424 | private function loadSlotRecordsFromDb( $revId, $queryFlags, PageIdentity $page ): array { |
1425 | $revQuery = $this->getSlotsQueryInfo( [ 'content' ] ); |
1426 | |
1427 | [ $dbMode, $dbOptions ] = DBAccessObjectUtils::getDBOptions( $queryFlags ); |
1428 | $db = $this->getDBConnection( $dbMode ); |
1429 | |
1430 | $res = $db->select( |
1431 | $revQuery['tables'], |
1432 | $revQuery['fields'], |
1433 | [ |
1434 | 'slot_revision_id' => $revId, |
1435 | ], |
1436 | __METHOD__, |
1437 | $dbOptions, |
1438 | $revQuery['joins'] |
1439 | ); |
1440 | |
1441 | if ( !$res->numRows() && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) { |
1442 | // If we found no slots, try looking on the primary database (T212428, T252156) |
1443 | $this->logger->info( |
1444 | __METHOD__ . ' falling back to READ_LATEST.', |
1445 | [ |
1446 | 'revid' => $revId, |
1447 | 'exception' => new RuntimeException(), |
1448 | ] |
1449 | ); |
1450 | return $this->loadSlotRecordsFromDb( |
1451 | $revId, |
1452 | $queryFlags | IDBAccessObject::READ_LATEST, |
1453 | $page |
1454 | ); |
1455 | } |
1456 | return iterator_to_array( $res ); |
1457 | } |
1458 | |
1459 | /** |
1460 | * Factory method for SlotRecords based on known slot rows. |
1461 | * |
1462 | * @param int $revId The revision to load slots for. |
1463 | * @param \stdClass[]|IResultWrapper $slotRows |
1464 | * @param int $queryFlags |
1465 | * @param PageIdentity $page |
1466 | * @param array|null $slotContents a map from blobAddress to slot |
1467 | * content blob or Content object. |
1468 | * |
1469 | * @return SlotRecord[] |
1470 | */ |
1471 | private function constructSlotRecords( |
1472 | $revId, |
1473 | $slotRows, |
1474 | $queryFlags, |
1475 | PageIdentity $page, |
1476 | $slotContents = null |
1477 | ) { |
1478 | $slots = []; |
1479 | |
1480 | foreach ( $slotRows as $row ) { |
1481 | // Resolve role names and model names from in-memory cache, if they were not joined in. |
1482 | if ( !isset( $row->role_name ) ) { |
1483 | $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id ); |
1484 | } |
1485 | |
1486 | if ( !isset( $row->model_name ) ) { |
1487 | if ( isset( $row->content_model ) ) { |
1488 | $row->model_name = $this->contentModelStore->getName( (int)$row->content_model ); |
1489 | } else { |
1490 | // We may get here if $row->model_name is set but null, perhaps because it |
1491 | // came from rev_content_model, which is NULL for the default model. |
1492 | $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name ); |
1493 | $row->model_name = $slotRoleHandler->getDefaultModel( $page ); |
1494 | } |
1495 | } |
1496 | |
1497 | // We may have a fake blob_data field from getSlotRowsForBatch(), use it! |
1498 | if ( isset( $row->blob_data ) ) { |
1499 | $slotContents[$row->content_address] = $row->blob_data; |
1500 | } |
1501 | |
1502 | $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) { |
1503 | $blob = null; |
1504 | if ( isset( $slotContents[$slot->getAddress()] ) ) { |
1505 | $blob = $slotContents[$slot->getAddress()]; |
1506 | if ( $blob instanceof Content ) { |
1507 | return $blob; |
1508 | } |
1509 | } |
1510 | return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags ); |
1511 | }; |
1512 | |
1513 | $slots[$row->role_name] = new SlotRecord( $row, $contentCallback ); |
1514 | } |
1515 | |
1516 | if ( !isset( $slots[SlotRecord::MAIN] ) ) { |
1517 | $this->logger->error( |
1518 | __METHOD__ . ': Main slot of revision not found in database. See T212428.', |
1519 | [ |
1520 | 'revid' => $revId, |
1521 | 'queryFlags' => $queryFlags, |
1522 | 'exception' => new RuntimeException(), |
1523 | ] |
1524 | ); |
1525 | |
1526 | throw new RevisionAccessException( |
1527 | 'Main slot of revision not found in database. See T212428.' |
1528 | ); |
1529 | } |
1530 | |
1531 | return $slots; |
1532 | } |
1533 | |
1534 | /** |
1535 | * Factory method for RevisionSlots based on a revision ID. |
1536 | * |
1537 | * @note If other code has a need to construct RevisionSlots objects, this should be made |
1538 | * public, since RevisionSlots instances should not be constructed directly. |
1539 | * |
1540 | * @param int $revId |
1541 | * @param \stdClass[]|null $slotRows |
1542 | * @param int $queryFlags |
1543 | * @param PageIdentity $page |
1544 | * |
1545 | * @return RevisionSlots |
1546 | */ |
1547 | private function newRevisionSlots( |
1548 | $revId, |
1549 | $slotRows, |
1550 | $queryFlags, |
1551 | PageIdentity $page |
1552 | ) { |
1553 | if ( $slotRows ) { |
1554 | $slots = new RevisionSlots( |
1555 | $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $page ) |
1556 | ); |
1557 | } else { |
1558 | $slots = new RevisionSlots( function () use( $revId, $queryFlags, $page ) { |
1559 | return $this->loadSlotRecords( $revId, $queryFlags, $page ); |
1560 | } ); |
1561 | } |
1562 | |
1563 | return $slots; |
1564 | } |
1565 | |
1566 | /** |
1567 | * Make a fake RevisionRecord object from an archive table row. This is queried |
1568 | * for permissions or even inserted (as in Special:Undelete) |
1569 | * |
1570 | * The user ID and user name may optionally be supplied using the aliases |
1571 | * ar_user and ar_user_text (the names of fields which existed before |
1572 | * MW 1.34). |
1573 | * |
1574 | * MCR migration note: this replaced Revision::newFromArchiveRow |
1575 | * |
1576 | * @param \stdClass $row |
1577 | * @param int $queryFlags |
1578 | * @param PageIdentity|null $page |
1579 | * @param array $overrides associative array with fields of $row to override. This may be |
1580 | * used e.g. to force the parent revision ID or page ID. Keys in the array are fields |
1581 | * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to |
1582 | * override ar_parent_id. |
1583 | * |
1584 | * @return RevisionRecord |
1585 | */ |
1586 | public function newRevisionFromArchiveRow( |
1587 | $row, |
1588 | $queryFlags = 0, |
1589 | PageIdentity $page = null, |
1590 | array $overrides = [] |
1591 | ) { |
1592 | return $this->newRevisionFromArchiveRowAndSlots( $row, null, $queryFlags, $page, $overrides ); |
1593 | } |
1594 | |
1595 | /** |
1596 | * @see RevisionFactory::newRevisionFromRow |
1597 | * |
1598 | * MCR migration note: this replaced Revision::newFromRow |
1599 | * |
1600 | * @param \stdClass $row A database row generated from a query based on RevisionSelectQueryBuilder |
1601 | * @param int $queryFlags |
1602 | * @param PageIdentity|null $page Preloaded page object |
1603 | * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale |
1604 | * data is returned from getters, by querying the database as needed |
1605 | * @return RevisionRecord |
1606 | */ |
1607 | public function newRevisionFromRow( |
1608 | $row, |
1609 | $queryFlags = 0, |
1610 | PageIdentity $page = null, |
1611 | $fromCache = false |
1612 | ) { |
1613 | return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $page, $fromCache ); |
1614 | } |
1615 | |
1616 | /** |
1617 | * @see newRevisionFromArchiveRow() |
1618 | * @since 1.35 |
1619 | * |
1620 | * @param stdClass $row |
1621 | * @param null|stdClass[]|RevisionSlots $slots |
1622 | * - Database rows generated from a query based on getSlotsQueryInfo |
1623 | * with the 'content' flag set. Or |
1624 | * - RevisionSlots instance |
1625 | * @param int $queryFlags |
1626 | * @param PageIdentity|null $page |
1627 | * @param array $overrides associative array with fields of $row to override. This may be |
1628 | * used e.g. to force the parent revision ID or page ID. Keys in the array are fields |
1629 | * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to |
1630 | * override ar_parent_id. |
1631 | * |
1632 | * @return RevisionRecord |
1633 | */ |
1634 | public function newRevisionFromArchiveRowAndSlots( |
1635 | stdClass $row, |
1636 | $slots, |
1637 | int $queryFlags = 0, |
1638 | ?PageIdentity $page = null, |
1639 | array $overrides = [] |
1640 | ) { |
1641 | if ( !$page && isset( $overrides['title'] ) ) { |
1642 | if ( !( $overrides['title'] instanceof PageIdentity ) ) { |
1643 | throw new InvalidArgumentException( 'title field override must contain a PageIdentity object.' ); |
1644 | } |
1645 | |
1646 | $page = $overrides['title']; |
1647 | } |
1648 | |
1649 | if ( !isset( $page ) ) { |
1650 | if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) { |
1651 | $page = Title::makeTitle( $row->ar_namespace, $row->ar_title ); |
1652 | } else { |
1653 | throw new InvalidArgumentException( |
1654 | 'A Title or ar_namespace and ar_title must be given' |
1655 | ); |
1656 | } |
1657 | } |
1658 | |
1659 | foreach ( $overrides as $key => $value ) { |
1660 | $field = "ar_$key"; |
1661 | $row->$field = $value; |
1662 | } |
1663 | |
1664 | try { |
1665 | $user = $this->actorStore->newActorFromRowFields( |
1666 | $row->ar_user ?? null, |
1667 | $row->ar_user_text ?? null, |
1668 | $row->ar_actor ?? null |
1669 | ); |
1670 | } catch ( InvalidArgumentException $ex ) { |
1671 | $this->logger->warning( 'Could not load user for archive revision {rev_id}', [ |
1672 | 'ar_rev_id' => $row->ar_rev_id, |
1673 | 'ar_actor' => $row->ar_actor ?? 'null', |
1674 | 'ar_user_text' => $row->ar_user_text ?? 'null', |
1675 | 'ar_user' => $row->ar_user ?? 'null', |
1676 | 'exception' => $ex |
1677 | ] ); |
1678 | $user = $this->actorStore->getUnknownActor(); |
1679 | } |
1680 | |
1681 | $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); |
1682 | // Legacy because $row may have come from self::selectFields() |
1683 | $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true ); |
1684 | |
1685 | if ( !( $slots instanceof RevisionSlots ) ) { |
1686 | $slots = $this->newRevisionSlots( (int)$row->ar_rev_id, $slots, $queryFlags, $page ); |
1687 | } |
1688 | return new RevisionArchiveRecord( $page, $user, $comment, $row, $slots, $this->wikiId ); |
1689 | } |
1690 | |
1691 | /** |
1692 | * @see newFromRevisionRow() |
1693 | * |
1694 | * @param stdClass $row A database row generated from a query based on RevisionSelectQueryBuilder |
1695 | * @param null|stdClass[]|RevisionSlots $slots |
1696 | * - Database rows generated from a query based on getSlotsQueryInfo |
1697 | * with the 'content' flag set. Or |
1698 | * - RevisionSlots instance |
1699 | * @param int $queryFlags |
1700 | * @param PageIdentity|null $page |
1701 | * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale |
1702 | * data is returned from getters, by querying the database as needed |
1703 | * |
1704 | * @return RevisionRecord |
1705 | * @throws RevisionAccessException |
1706 | * @see RevisionFactory::newRevisionFromRow |
1707 | */ |
1708 | public function newRevisionFromRowAndSlots( |
1709 | stdClass $row, |
1710 | $slots, |
1711 | int $queryFlags = 0, |
1712 | ?PageIdentity $page = null, |
1713 | bool $fromCache = false |
1714 | ) { |
1715 | if ( !$page ) { |
1716 | if ( isset( $row->page_id ) |
1717 | && isset( $row->page_namespace ) |
1718 | && isset( $row->page_title ) |
1719 | ) { |
1720 | $page = new PageIdentityValue( |
1721 | (int)$row->page_id, |
1722 | (int)$row->page_namespace, |
1723 | $row->page_title, |
1724 | $this->wikiId |
1725 | ); |
1726 | |
1727 | $page = $this->wrapPage( $page ); |
1728 | } else { |
1729 | $pageId = (int)( $row->rev_page ?? 0 ); |
1730 | $revId = (int)( $row->rev_id ?? 0 ); |
1731 | |
1732 | $page = $this->getPage( $pageId, $revId, $queryFlags ); |
1733 | } |
1734 | } else { |
1735 | $page = $this->ensureRevisionRowMatchesPage( $row, $page ); |
1736 | } |
1737 | |
1738 | if ( !$page ) { |
1739 | // This should already have been caught about, but apparently |
1740 | // it not always is, see T286877. |
1741 | throw new RevisionAccessException( |
1742 | "Failed to determine page associated with revision {$row->rev_id}" |
1743 | ); |
1744 | } |
1745 | |
1746 | try { |
1747 | $user = $this->actorStore->newActorFromRowFields( |
1748 | $row->rev_user ?? null, |
1749 | $row->rev_user_text ?? null, |
1750 | $row->rev_actor ?? null |
1751 | ); |
1752 | } catch ( InvalidArgumentException $ex ) { |
1753 | $this->logger->warning( 'Could not load user for revision {rev_id}', [ |
1754 | 'rev_id' => $row->rev_id, |
1755 | 'rev_actor' => $row->rev_actor ?? 'null', |
1756 | 'rev_user_text' => $row->rev_user_text ?? 'null', |
1757 | 'rev_user' => $row->rev_user ?? 'null', |
1758 | 'exception' => $ex |
1759 | ] ); |
1760 | $user = $this->actorStore->getUnknownActor(); |
1761 | } |
1762 | |
1763 | $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); |
1764 | // Legacy because $row may have come from self::selectFields() |
1765 | $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true ); |
1766 | |
1767 | if ( !( $slots instanceof RevisionSlots ) ) { |
1768 | $slots = $this->newRevisionSlots( (int)$row->rev_id, $slots, $queryFlags, $page ); |
1769 | } |
1770 | |
1771 | // If this is a cached row, instantiate a cache-aware RevisionRecord to avoid stale data. |
1772 | if ( $fromCache ) { |
1773 | $rev = new RevisionStoreCacheRecord( |
1774 | function ( $revId ) use ( $queryFlags ) { |
1775 | $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); |
1776 | $row = $this->fetchRevisionRowFromConds( |
1777 | $db, |
1778 | [ 'rev_id' => intval( $revId ) ] |
1779 | ); |
1780 | if ( !$row && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) { |
1781 | // If we found no slots, try looking on the primary database (T259738) |
1782 | $this->logger->info( |
1783 | 'RevisionStoreCacheRecord refresh callback falling back to READ_LATEST.', |
1784 | [ |
1785 | 'revid' => $revId, |
1786 | 'exception' => new RuntimeException(), |
1787 | ] |
1788 | ); |
1789 | $dbw = $this->getDBConnectionRefForQueryFlags( IDBAccessObject::READ_LATEST ); |
1790 | $row = $this->fetchRevisionRowFromConds( |
1791 | $dbw, |
1792 | [ 'rev_id' => intval( $revId ) ] |
1793 | ); |
1794 | } |
1795 | if ( !$row ) { |
1796 | return [ null, null ]; |
1797 | } |
1798 | return [ |
1799 | $row->rev_deleted, |
1800 | $this->actorStore->newActorFromRowFields( |
1801 | $row->rev_user ?? null, |
1802 | $row->rev_user_text ?? null, |
1803 | $row->rev_actor ?? null |
1804 | ) |
1805 | ]; |
1806 | }, |
1807 | $page, $user, $comment, $row, $slots, $this->wikiId |
1808 | ); |
1809 | } else { |
1810 | $rev = new RevisionStoreRecord( |
1811 | $page, $user, $comment, $row, $slots, $this->wikiId ); |
1812 | } |
1813 | return $rev; |
1814 | } |
1815 | |
1816 | /** |
1817 | * Check that the given row matches the given Title object. |
1818 | * When a mismatch is detected, this tries to re-load the title from primary DB, |
1819 | * to avoid spurious errors during page moves. |
1820 | * |
1821 | * @param \stdClass $row |
1822 | * @param PageIdentity $page |
1823 | * @param array $context |
1824 | * |
1825 | * @return Pageidentity |
1826 | */ |
1827 | private function ensureRevisionRowMatchesPage( $row, PageIdentity $page, $context = [] ) { |
1828 | $revId = (int)( $row->rev_id ?? 0 ); |
1829 | $revPageId = (int)( $row->rev_page ?? 0 ); // XXX: also check $row->page_id? |
1830 | $expectedPageId = $page->getId( $this->wikiId ); |
1831 | // Avoid fatal error when the Title's ID changed, T246720 |
1832 | if ( $revPageId && $expectedPageId && $revPageId !== $expectedPageId ) { |
1833 | // NOTE: PageStore::getPageByReference may use the page ID, which we don't want here. |
1834 | $pageRec = $this->pageStore->getPageByName( |
1835 | $page->getNamespace(), |
1836 | $page->getDBkey(), |
1837 | IDBAccessObject::READ_LATEST |
1838 | ); |
1839 | $masterPageId = $pageRec->getId( $this->wikiId ); |
1840 | $masterLatest = $pageRec->getLatest( $this->wikiId ); |
1841 | if ( $revPageId === $masterPageId ) { |
1842 | if ( $page instanceof Title ) { |
1843 | // If we were using a Title object, keep using it, but update the page ID. |
1844 | // This way, we don't unexpectedly mix Titles with immutable value objects. |
1845 | $page->resetArticleID( $masterPageId ); |
1846 | |
1847 | } else { |
1848 | $page = $pageRec; |
1849 | } |
1850 | |
1851 | $this->logger->info( |
1852 | "Encountered stale Title object", |
1853 | [ |
1854 | 'page_id_stale' => $expectedPageId, |
1855 | 'page_id_reloaded' => $masterPageId, |
1856 | 'page_latest' => $masterLatest, |
1857 | 'rev_id' => $revId, |
1858 | 'exception' => new RuntimeException(), |
1859 | ] + $context |
1860 | ); |
1861 | } else { |
1862 | $expectedTitle = (string)$page; |
1863 | if ( $page instanceof Title ) { |
1864 | // If we started with a Title, keep using a Title. |
1865 | $page = $this->titleFactory->newFromID( $revPageId ); |
1866 | } else { |
1867 | $page = $pageRec; |
1868 | } |
1869 | |
1870 | // This could happen if a caller to e.g. getRevisionById supplied a Title that is |
1871 | // plain wrong. In this case, we should ideally throw an IllegalArgumentException. |
1872 | // However, it is more likely that we encountered a race condition during a page |
1873 | // move (T268910, T279832) or database corruption (T263340). That situation |
1874 | // should not be ignored, but we can allow the request to continue in a reasonable |
1875 | // manner without breaking things for the user. |
1876 | $this->logger->error( |
1877 | "Encountered mismatching Title object (see T259022, T268910, T279832, T263340)", |
1878 | [ |
1879 | 'expected_page_id' => $masterPageId, |
1880 | 'expected_page_title' => $expectedTitle, |
1881 | 'rev_page' => $revPageId, |
1882 | 'rev_page_title' => (string)$page, |
1883 | 'page_latest' => $masterLatest, |
1884 | 'rev_id' => $revId, |
1885 | 'exception' => new RuntimeException(), |
1886 | ] + $context |
1887 | ); |
1888 | } |
1889 | } |
1890 | |
1891 | // @phan-suppress-next-line PhanTypeMismatchReturnNullable getPageByName/newFromID should not return null |
1892 | return $page; |
1893 | } |
1894 | |
1895 | /** |
1896 | * Construct a RevisionRecord instance for each row in $rows, |
1897 | * and return them as an associative array indexed by revision ID. |
1898 | * Use RevisionSelectQueryBuilder or getArchiveQueryInfo() to construct the |
1899 | * query that produces the rows. |
1900 | * |
1901 | * @param IResultWrapper|\stdClass[] $rows the rows to construct revision records from |
1902 | * @param array $options Supports the following options: |
1903 | * 'slots' - whether metadata about revision slots should be |
1904 | * loaded immediately. Supports falsy or truthy value as well |
1905 | * as an explicit list of slot role names. The main slot will |
1906 | * always be loaded. |
1907 | * 'content' - whether the actual content of the slots should be |
1908 | * preloaded. |
1909 | * 'archive' - whether the rows where generated using getArchiveQueryInfo(), |
1910 | * rather than getQueryInfo. |
1911 | * @param int $queryFlags |
1912 | * @param PageIdentity|null $page The page to which all the revision rows belong, if there |
1913 | * is such a page and the caller has it handy, so we don't have to look it up again. |
1914 | * If this parameter is given and any of the rows has a rev_page_id that is different |
1915 | * from Article Id associated with the page, an InvalidArgumentException is thrown. |
1916 | * |
1917 | * @return StatusValue a status with a RevisionRecord[] of successfully fetched revisions |
1918 | * and an array of errors for the revisions failed to fetch. |
1919 | */ |
1920 | public function newRevisionsFromBatch( |
1921 | $rows, |
1922 | array $options = [], |
1923 | $queryFlags = 0, |
1924 | PageIdentity $page = null |
1925 | ) { |
1926 | $result = new StatusValue(); |
1927 | $archiveMode = $options['archive'] ?? false; |
1928 | |
1929 | if ( $archiveMode ) { |
1930 | $revIdField = 'ar_rev_id'; |
1931 | } else { |
1932 | $revIdField = 'rev_id'; |
1933 | } |
1934 | |
1935 | $rowsByRevId = []; |
1936 | $pageIdsToFetchTitles = []; |
1937 | $titlesByPageKey = []; |
1938 | foreach ( $rows as $row ) { |
1939 | if ( isset( $rowsByRevId[$row->$revIdField] ) ) { |
1940 | $result->warning( |
1941 | 'internalerror_info', |
1942 | "Duplicate rows in newRevisionsFromBatch, $revIdField {$row->$revIdField}" |
1943 | ); |
1944 | } |
1945 | |
1946 | // Attach a page key to the row, so we can find and reuse Title objects easily. |
1947 | $row->_page_key = |
1948 | $archiveMode ? $row->ar_namespace . ':' . $row->ar_title : $row->rev_page; |
1949 | |
1950 | if ( $page ) { |
1951 | if ( !$archiveMode && $row->rev_page != $this->getArticleId( $page ) ) { |
1952 | throw new InvalidArgumentException( |
1953 | "Revision {$row->$revIdField} doesn't belong to page " |
1954 | . $this->getArticleId( $page ) |
1955 | ); |
1956 | } |
1957 | |
1958 | if ( $archiveMode |
1959 | && ( $row->ar_namespace != $page->getNamespace() |
1960 | || $row->ar_title !== $page->getDBkey() ) |
1961 | ) { |
1962 | throw new InvalidArgumentException( |
1963 | "Revision {$row->$revIdField} doesn't belong to page " |
1964 | . $page |
1965 | ); |
1966 | } |
1967 | } elseif ( !isset( $titlesByPageKey[ $row->_page_key ] ) ) { |
1968 | if ( isset( $row->page_namespace ) && isset( $row->page_title ) |
1969 | // This should always be true, but just in case we don't have a page_id |
1970 | // set or it doesn't match rev_page, let's fetch the title again. |
1971 | && isset( $row->page_id ) && isset( $row->rev_page ) |
1972 | && $row->rev_page === $row->page_id |
1973 | ) { |
1974 | $titlesByPageKey[ $row->_page_key ] = Title::newFromRow( $row ); |
1975 | } elseif ( $archiveMode ) { |
1976 | // Can't look up deleted pages by ID, but we have namespace and title |
1977 | $titlesByPageKey[ $row->_page_key ] = |
1978 | Title::makeTitle( $row->ar_namespace, $row->ar_title ); |
1979 | } else { |
1980 | $pageIdsToFetchTitles[] = $row->rev_page; |
1981 | } |
1982 | } |
1983 | $rowsByRevId[$row->$revIdField] = $row; |
1984 | } |
1985 | |
1986 | if ( !$rowsByRevId ) { |
1987 | $result->setResult( true, [] ); |
1988 | return $result; |
1989 | } |
1990 | |
1991 | // If the page is not supplied, batch-fetch Title objects. |
1992 | if ( $page ) { |
1993 | // same logic as for $row->_page_key above |
1994 | $pageKey = $archiveMode |
1995 | ? $page->getNamespace() . ':' . $page->getDBkey() |
1996 | : $this->getArticleId( $page ); |
1997 | |
1998 | $titlesByPageKey[$pageKey] = $page; |
1999 | } elseif ( $pageIdsToFetchTitles ) { |
2000 | // Note: when we fetch titles by ID, the page key is also the ID. |
2001 | // We should never get here if $archiveMode is true. |
2002 | Assert::invariant( !$archiveMode, 'Titles are not loaded by ID in archive mode.' ); |
2003 | |
2004 | $pageIdsToFetchTitles = array_unique( $pageIdsToFetchTitles ); |
2005 | $pageRecords = $this->pageStore |
2006 | ->newSelectQueryBuilder() |
2007 | ->wherePageIds( $pageIdsToFetchTitles ) |
2008 | ->caller( __METHOD__ ) |
2009 | ->fetchPageRecordArray(); |
2010 | // Cannot array_merge because it re-indexes entries |
2011 | $titlesByPageKey = $pageRecords + $titlesByPageKey; |
2012 | } |
2013 | |
2014 | // which method to use for creating RevisionRecords |
2015 | $newRevisionRecord = $archiveMode |
2016 | ? [ $this, 'newRevisionFromArchiveRowAndSlots' ] |
2017 | : [ $this, 'newRevisionFromRowAndSlots' ]; |
2018 | |
2019 | if ( !isset( $options['slots'] ) ) { |
2020 | $result->setResult( |
2021 | true, |
2022 | array_map( |
2023 | static function ( $row ) |
2024 | use ( $queryFlags, $titlesByPageKey, $result, $newRevisionRecord, $revIdField ) { |
2025 | try { |
2026 | if ( !isset( $titlesByPageKey[$row->_page_key] ) ) { |
2027 | $result->warning( |
2028 | 'internalerror_info', |
2029 | "Couldn't find title for rev {$row->$revIdField} " |
2030 | . "(page key {$row->_page_key})" |
2031 | ); |
2032 | return null; |
2033 | } |
2034 | return $newRevisionRecord( $row, null, $queryFlags, |
2035 | $titlesByPageKey[ $row->_page_key ] ); |
2036 | } catch ( MWException $e ) { |
2037 | $result->warning( 'internalerror_info', $e->getMessage() ); |
2038 | return null; |
2039 | } |
2040 | }, |
2041 | $rowsByRevId |
2042 | ) |
2043 | ); |
2044 | return $result; |
2045 | } |
2046 | |
2047 | $slotRowOptions = [ |
2048 | 'slots' => $options['slots'] ?? true, |
2049 | 'blobs' => $options['content'] ?? false, |
2050 | ]; |
2051 | |
2052 | if ( is_array( $slotRowOptions['slots'] ) |
2053 | && !in_array( SlotRecord::MAIN, $slotRowOptions['slots'] ) |
2054 | ) { |
2055 | // Make sure the main slot is always loaded, RevisionRecord requires this. |
2056 | $slotRowOptions['slots'][] = SlotRecord::MAIN; |
2057 | } |
2058 | |
2059 | $slotRowsStatus = $this->getSlotRowsForBatch( $rowsByRevId, $slotRowOptions, $queryFlags ); |
2060 | |
2061 | $result->merge( $slotRowsStatus ); |
2062 | $slotRowsByRevId = $slotRowsStatus->getValue(); |
2063 | |
2064 | $result->setResult( |
2065 | true, |
2066 | array_map( |
2067 | function ( $row ) |
2068 | use ( $slotRowsByRevId, $queryFlags, $titlesByPageKey, $result, |
2069 | $revIdField, $newRevisionRecord |
2070 | ) { |
2071 | if ( !isset( $slotRowsByRevId[$row->$revIdField] ) ) { |
2072 | $result->warning( |
2073 | 'internalerror_info', |
2074 | "Couldn't find slots for rev {$row->$revIdField}" |
2075 | ); |
2076 | return null; |
2077 | } |
2078 | if ( !isset( $titlesByPageKey[$row->_page_key] ) ) { |
2079 | $result->warning( |
2080 | 'internalerror_info', |
2081 | "Couldn't find title for rev {$row->$revIdField} " |
2082 | . "(page key {$row->_page_key})" |
2083 | ); |
2084 | return null; |
2085 | } |
2086 | try { |
2087 | return $newRevisionRecord( |
2088 | $row, |
2089 | new RevisionSlots( |
2090 | $this->constructSlotRecords( |
2091 | $row->$revIdField, |
2092 | $slotRowsByRevId[$row->$revIdField], |
2093 | $queryFlags, |
2094 | $titlesByPageKey[$row->_page_key] |
2095 | ) |
2096 | ), |
2097 | $queryFlags, |
2098 | $titlesByPageKey[$row->_page_key] |
2099 | ); |
2100 | } catch ( MWException $e ) { |
2101 | $result->warning( 'internalerror_info', $e->getMessage() ); |
2102 | return null; |
2103 | } |
2104 | }, |
2105 | $rowsByRevId |
2106 | ) |
2107 | ); |
2108 | return $result; |
2109 | } |
2110 | |
2111 | /** |
2112 | * Gets the slot rows associated with a batch of revisions. |
2113 | * The serialized content of each slot can be included by setting the 'blobs' option. |
2114 | * Callers are responsible for unserializing and interpreting the content blobs |
2115 | * based on the model_name and role_name fields. |
2116 | * |
2117 | * @param Traversable|array $rowsOrIds list of revision ids, or revision or archive rows |
2118 | * from a db query. |
2119 | * @param array $options Supports the following options: |
2120 | * 'slots' - a list of slot role names to fetch. If omitted or true or null, |
2121 | * all slots are fetched |
2122 | * 'blobs' - whether the serialized content of each slot should be loaded. |
2123 | * If true, the serialized content will be present in the slot row |
2124 | * in the blob_data field. |
2125 | * @param int $queryFlags |
2126 | * |
2127 | * @return StatusValue a status containing, if isOK() returns true, a two-level nested |
2128 | * associative array, mapping from revision ID to an associative array that maps from |
2129 | * role name to a database row object. The database row object will contain the fields |
2130 | * defined by getSlotQueryInfo() with the 'content' flag set, plus the blob_data field |
2131 | * if the 'blobs' is set in $options. The model_name and role_name fields will also be |
2132 | * set. |
2133 | */ |
2134 | private function getSlotRowsForBatch( |
2135 | $rowsOrIds, |
2136 | array $options = [], |
2137 | $queryFlags = 0 |
2138 | ) { |
2139 | $result = new StatusValue(); |
2140 | |
2141 | $revIds = []; |
2142 | foreach ( $rowsOrIds as $row ) { |
2143 | if ( is_object( $row ) ) { |
2144 | $revIds[] = isset( $row->ar_rev_id ) ? (int)$row->ar_rev_id : (int)$row->rev_id; |
2145 | } else { |
2146 | $revIds[] = (int)$row; |
2147 | } |
2148 | } |
2149 | |
2150 | // Nothing to do. |
2151 | // Note that $rowsOrIds may not be "empty" even if $revIds is, e.g. if it's a ResultWrapper. |
2152 | if ( !$revIds ) { |
2153 | $result->setResult( true, [] ); |
2154 | return $result; |
2155 | } |
2156 | |
2157 | // We need to set the `content` flag to join in content meta-data |
2158 | $slotQueryInfo = $this->getSlotsQueryInfo( [ 'content' ] ); |
2159 | $revIdField = $slotQueryInfo['keys']['rev_id']; |
2160 | $slotQueryConds = [ $revIdField => $revIds ]; |
2161 | |
2162 | if ( isset( $options['slots'] ) && is_array( $options['slots'] ) ) { |
2163 | $slotIds = []; |
2164 | foreach ( $options['slots'] as $slot ) { |
2165 | try { |
2166 | $slotIds[] = $this->slotRoleStore->getId( $slot ); |
2167 | } catch ( NameTableAccessException $exception ) { |
2168 | // Do not fail when slot has no id (unused slot) |
2169 | // This also means for this slot are never data in the database |
2170 | } |
2171 | } |
2172 | if ( $slotIds === [] ) { |
2173 | // Degenerate case: return no slots for each revision. |
2174 | $result->setResult( true, array_fill_keys( $revIds, [] ) ); |
2175 | return $result; |
2176 | } |
2177 | |
2178 | $roleIdField = $slotQueryInfo['keys']['role_id']; |
2179 | $slotQueryConds[$roleIdField] = $slotIds; |
2180 | } |
2181 | |
2182 | $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); |
2183 | $slotRows = $db->select( |
2184 | $slotQueryInfo['tables'], |
2185 | $slotQueryInfo['fields'], |
2186 | $slotQueryConds, |
2187 | __METHOD__, |
2188 | [], |
2189 | $slotQueryInfo['joins'] |
2190 | ); |
2191 | |
2192 | $slotContents = null; |
2193 | if ( $options['blobs'] ?? false ) { |
2194 | $blobAddresses = []; |
2195 | foreach ( $slotRows as $slotRow ) { |
2196 | $blobAddresses[] = $slotRow->content_address; |
2197 | } |
2198 | $slotContentFetchStatus = $this->blobStore |
2199 | ->getBlobBatch( $blobAddresses, $queryFlags ); |
2200 | foreach ( $slotContentFetchStatus->getErrors() as $error ) { |
2201 | $result->warning( $error['message'], ...$error['params'] ); |
2202 | } |
2203 | $slotContents = $slotContentFetchStatus->getValue(); |
2204 | } |
2205 | |
2206 | $slotRowsByRevId = []; |
2207 | foreach ( $slotRows as $slotRow ) { |
2208 | if ( $slotContents === null ) { |
2209 | // nothing to do |
2210 | } elseif ( isset( $slotContents[$slotRow->content_address] ) ) { |
2211 | $slotRow->blob_data = $slotContents[$slotRow->content_address]; |
2212 | } else { |
2213 | $result->warning( |
2214 | 'internalerror_info', |
2215 | "Couldn't find blob data for rev {$slotRow->slot_revision_id}" |
2216 | ); |
2217 | $slotRow->blob_data = null; |
2218 | } |
2219 | |
2220 | // conditional needed for SCHEMA_COMPAT_READ_OLD |
2221 | if ( !isset( $slotRow->role_name ) && isset( $slotRow->slot_role_id ) ) { |
2222 | $slotRow->role_name = $this->slotRoleStore->getName( (int)$slotRow->slot_role_id ); |
2223 | } |
2224 | |
2225 | // conditional needed for SCHEMA_COMPAT_READ_OLD |
2226 | if ( !isset( $slotRow->model_name ) && isset( $slotRow->content_model ) ) { |
2227 | $slotRow->model_name = $this->contentModelStore->getName( (int)$slotRow->content_model ); |
2228 | } |
2229 | |
2230 | $slotRowsByRevId[$slotRow->slot_revision_id][$slotRow->role_name] = $slotRow; |
2231 | } |
2232 | |
2233 | $result->setResult( true, $slotRowsByRevId ); |
2234 | return $result; |
2235 | } |
2236 | |
2237 | /** |
2238 | * Gets raw (serialized) content blobs for the given set of revisions. |
2239 | * Callers are responsible for unserializing and interpreting the content blobs |
2240 | * based on the model_name field and the slot role. |
2241 | * |
2242 | * This method is intended for bulk operations in maintenance scripts. |
2243 | * It may be chosen over newRevisionsFromBatch by code that are only interested |
2244 | * in raw content, as opposed to meta data. Code that needs to access meta data of revisions, |
2245 | * slots, or content objects should use newRevisionsFromBatch() instead. |
2246 | * |
2247 | * @param Traversable|array $rowsOrIds list of revision ids, or revision rows from a db query. |
2248 | * @param array|null $slots the role names for which to get slots. |
2249 | * @param int $queryFlags |
2250 | * |
2251 | * @return StatusValue a status containing, if isOK() returns true, a two-level nested |
2252 | * associative array, mapping from revision ID to an associative array that maps from |
2253 | * role name to an anonymous object containing two fields: |
2254 | * - model_name: the name of the content's model |
2255 | * - blob_data: serialized content data |
2256 | */ |
2257 | public function getContentBlobsForBatch( |
2258 | $rowsOrIds, |
2259 | $slots = null, |
2260 | $queryFlags = 0 |
2261 | ) { |
2262 | $result = $this->getSlotRowsForBatch( |
2263 | $rowsOrIds, |
2264 | [ 'slots' => $slots, 'blobs' => true ], |
2265 | $queryFlags |
2266 | ); |
2267 | |
2268 | if ( $result->isOK() ) { |
2269 | // strip out all internal meta data that we don't want to expose |
2270 | foreach ( $result->value as $revId => $rowsByRole ) { |
2271 | foreach ( $rowsByRole as $role => $slotRow ) { |
2272 | if ( is_array( $slots ) && !in_array( $role, $slots ) ) { |
2273 | // In SCHEMA_COMPAT_READ_OLD mode we may get the main slot even |
2274 | // if we didn't ask for it. |
2275 | unset( $result->value[$revId][$role] ); |
2276 | continue; |
2277 | } |
2278 | |
2279 | $result->value[$revId][$role] = (object)[ |
2280 | 'blob_data' => $slotRow->blob_data, |
2281 | 'model_name' => $slotRow->model_name, |
2282 | ]; |
2283 | } |
2284 | } |
2285 | } |
2286 | |
2287 | return $result; |
2288 | } |
2289 | |
2290 | /** |
2291 | * Given a set of conditions, fetch a revision |
2292 | * |
2293 | * This method should be used if we are pretty sure the revision exists. |
2294 | * Unless $flags has READ_LATEST set, this method will first try to find the revision |
2295 | * on a replica before hitting the primary database. |
2296 | * |
2297 | * MCR migration note: this corresponded to Revision::newFromConds |
2298 | * |
2299 | * @param array $conditions |
2300 | * @param int $flags (optional) |
2301 | * @param PageIdentity|null $page (optional) |
2302 | * @param array $options (optional) additional query options |
2303 | * |
2304 | * @return RevisionRecord|null |
2305 | */ |
2306 | private function newRevisionFromConds( |
2307 | array $conditions, |
2308 | int $flags = IDBAccessObject::READ_NORMAL, |
2309 | PageIdentity $page = null, |
2310 | array $options = [] |
2311 | ) { |
2312 | $db = $this->getDBConnectionRefForQueryFlags( $flags ); |
2313 | $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $page, $options ); |
2314 | |
2315 | // Make sure new pending/committed revision are visible later on |
2316 | // within web requests to certain avoid bugs like T93866 and T94407. |
2317 | if ( !$rev |
2318 | && !( $flags & IDBAccessObject::READ_LATEST ) |
2319 | && $this->loadBalancer->hasStreamingReplicaServers() |
2320 | && $this->loadBalancer->hasOrMadeRecentPrimaryChanges() |
2321 | ) { |
2322 | $flags = IDBAccessObject::READ_LATEST; |
2323 | $dbw = $this->getDBConnection( DB_PRIMARY ); |
2324 | $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $page, $options ); |
2325 | } |
2326 | |
2327 | return $rev; |
2328 | } |
2329 | |
2330 | /** |
2331 | * Given a set of conditions, fetch a revision from |
2332 | * the given database connection. |
2333 | * |
2334 | * MCR migration note: this corresponded to Revision::loadFromConds |
2335 | * |
2336 | * @param IDatabase $db |
2337 | * @param array $conditions |
2338 | * @param int $flags (optional) |
2339 | * @param PageIdentity|null $page (optional) additional query options |
2340 | * @param array $options (optional) additional query options |
2341 | * |
2342 | * @return RevisionRecord|null |
2343 | */ |
2344 | private function loadRevisionFromConds( |
2345 | IDatabase $db, |
2346 | array $conditions, |
2347 | int $flags = IDBAccessObject::READ_NORMAL, |
2348 | PageIdentity $page = null, |
2349 | array $options = [] |
2350 | ) { |
2351 | $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags, $options ); |
2352 | if ( $row ) { |
2353 | return $this->newRevisionFromRow( $row, $flags, $page ); |
2354 | } |
2355 | |
2356 | return null; |
2357 | } |
2358 | |
2359 | /** |
2360 | * Throws an exception if the given database connection does not belong to the wiki this |
2361 | * RevisionStore is bound to. |
2362 | * |
2363 | * @param IReadableDatabase $db |
2364 | */ |
2365 | private function checkDatabaseDomain( IReadableDatabase $db ) { |
2366 | $dbDomain = $db->getDomainID(); |
2367 | $storeDomain = $this->loadBalancer->resolveDomainID( $this->wikiId ); |
2368 | if ( $dbDomain === $storeDomain ) { |
2369 | return; |
2370 | } |
2371 | |
2372 | throw new RuntimeException( "DB connection domain '$dbDomain' does not match '$storeDomain'" ); |
2373 | } |
2374 | |
2375 | /** |
2376 | * Given a set of conditions, return a row with the |
2377 | * fields necessary to build RevisionRecord objects. |
2378 | * |
2379 | * MCR migration note: this corresponded to Revision::fetchFromConds |
2380 | * |
2381 | * @param IDatabase $db |
2382 | * @param array $conditions |
2383 | * @param int $flags (optional) |
2384 | * @param array $options (optional) additional query options |
2385 | * |
2386 | * @return \stdClass|false data row as a raw object |
2387 | */ |
2388 | private function fetchRevisionRowFromConds( |
2389 | IDatabase $db, |
2390 | array $conditions, |
2391 | int $flags = IDBAccessObject::READ_NORMAL, |
2392 | array $options = [] |
2393 | ) { |
2394 | $this->checkDatabaseDomain( $db ); |
2395 | |
2396 | $queryBuilder = $this->newSelectQueryBuilder( $db ) |
2397 | ->joinComment() |
2398 | ->joinPage() |
2399 | ->joinUser() |
2400 | ->where( $conditions ) |
2401 | ->options( $options ); |
2402 | if ( ( $flags & IDBAccessObject::READ_LOCKING ) == IDBAccessObject::READ_LOCKING ) { |
2403 | $queryBuilder->forUpdate(); |
2404 | } |
2405 | return $queryBuilder->caller( __METHOD__ )->fetchRow(); |
2406 | } |
2407 | |
2408 | /** |
2409 | * Return the tables, fields, and join conditions to be selected to create |
2410 | * a new RevisionStoreRecord object. |
2411 | * |
2412 | * MCR migration note: this replaced Revision::getQueryInfo |
2413 | * |
2414 | * If the format of fields returned changes in any way then the cache key provided by |
2415 | * self::getRevisionRowCacheKey should be updated. |
2416 | * |
2417 | * @since 1.31 |
2418 | * @deprecated since 1.41 use RevisionStore::newSelectQueryBuilder() instead. |
2419 | * |
2420 | * @param array $options Any combination of the following strings |
2421 | * - 'page': Join with the page table, and select fields to identify the page |
2422 | * - 'user': Join with the user table, and select the user name |
2423 | * |
2424 | * @return array[] With three keys: |
2425 | * - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables` |
2426 | * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields` |
2427 | * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds` |
2428 | * @phan-return array{tables:string[],fields:string[],joins:array} |
2429 | */ |
2430 | public function getQueryInfo( $options = [] ) { |
2431 | $ret = [ |
2432 | 'tables' => [], |
2433 | 'fields' => [], |
2434 | 'joins' => [], |
2435 | ]; |
2436 | |
2437 | $ret['tables'] = array_merge( $ret['tables'], [ |
2438 | 'revision', |
2439 | 'actor_rev_user' => 'actor', |
2440 | ] ); |
2441 | $ret['fields'] = array_merge( $ret['fields'], [ |
2442 | 'rev_id', |
2443 | 'rev_page', |
2444 | 'rev_actor' => 'rev_actor', |
2445 | 'rev_user' => 'actor_rev_user.actor_user', |
2446 | 'rev_user_text' => 'actor_rev_user.actor_name', |
2447 | 'rev_timestamp', |
2448 | 'rev_minor_edit', |
2449 | 'rev_deleted', |
2450 | 'rev_len', |
2451 | 'rev_parent_id', |
2452 | 'rev_sha1', |
2453 | ] ); |
2454 | $ret['joins']['actor_rev_user'] = [ 'JOIN', "actor_rev_user.actor_id = rev_actor" ]; |
2455 | |
2456 | $commentQuery = $this->commentStore->getJoin( 'rev_comment' ); |
2457 | $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] ); |
2458 | $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] ); |
2459 | $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] ); |
2460 | |
2461 | if ( in_array( 'page', $options, true ) ) { |
2462 | $ret['tables'][] = 'page'; |
2463 | $ret['fields'] = array_merge( $ret['fields'], [ |
2464 | 'page_namespace', |
2465 | 'page_title', |
2466 | 'page_id', |
2467 | 'page_latest', |
2468 | 'page_is_redirect', |
2469 | 'page_len', |
2470 | ] ); |
2471 | $ret['joins']['page'] = [ 'JOIN', [ 'page_id = rev_page' ] ]; |
2472 | } |
2473 | |
2474 | if ( in_array( 'user', $options, true ) ) { |
2475 | $ret['tables'][] = 'user'; |
2476 | $ret['fields'] = array_merge( $ret['fields'], [ |
2477 | 'user_name', |
2478 | ] ); |
2479 | $ret['joins']['user'] = [ |
2480 | 'LEFT JOIN', |
2481 | [ 'actor_rev_user.actor_user != 0', 'user_id = actor_rev_user.actor_user' ] |
2482 | ]; |
2483 | } |
2484 | |
2485 | if ( in_array( 'text', $options, true ) ) { |
2486 | throw new InvalidArgumentException( |
2487 | 'The `text` option is no longer supported in MediaWiki 1.35 and later.' |
2488 | ); |
2489 | } |
2490 | |
2491 | return $ret; |
2492 | } |
2493 | |
2494 | /** |
2495 | * @inheritDoc |
2496 | */ |
2497 | public function newSelectQueryBuilder( IReadableDatabase $dbr ): RevisionSelectQueryBuilder { |
2498 | return new RevisionSelectQueryBuilder( $dbr ); |
2499 | } |
2500 | |
2501 | /** |
2502 | * @inheritDoc |
2503 | */ |
2504 | public function newArchiveSelectQueryBuilder( IReadableDatabase $dbr ): ArchiveSelectQueryBuilder { |
2505 | return new ArchiveSelectQueryBuilder( $dbr ); |
2506 | } |
2507 | |
2508 | /** |
2509 | * Return the tables, fields, and join conditions to be selected to create |
2510 | * a new SlotRecord. |
2511 | * |
2512 | * @since 1.32 |
2513 | * |
2514 | * @param array $options Any combination of the following strings |
2515 | * - 'content': Join with the content table, and select content meta-data fields |
2516 | * - 'model': Join with the content_models table, and select the model_name field. |
2517 | * Only applicable if 'content' is also set. |
2518 | * - 'role': Join with the slot_roles table, and select the role_name field |
2519 | * |
2520 | * @return array[] With three keys: |
2521 | * - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables` |
2522 | * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields` |
2523 | * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds` |
2524 | * - keys: (associative array) to look up fields to match against. |
2525 | * In particular, the field that can be used to find slots by rev_id |
2526 | * can be found in ['keys']['rev_id']. |
2527 | * @phan-return array{tables:string[],fields:string[],joins:array,keys:array} |
2528 | */ |
2529 | public function getSlotsQueryInfo( $options = [] ) { |
2530 | $ret = [ |
2531 | 'tables' => [], |
2532 | 'fields' => [], |
2533 | 'joins' => [], |
2534 | 'keys' => [], |
2535 | ]; |
2536 | |
2537 | $ret['keys']['rev_id'] = 'slot_revision_id'; |
2538 | $ret['keys']['role_id'] = 'slot_role_id'; |
2539 | |
2540 | $ret['tables'][] = 'slots'; |
2541 | $ret['fields'] = array_merge( $ret['fields'], [ |
2542 | 'slot_revision_id', |
2543 | 'slot_content_id', |
2544 | 'slot_origin', |
2545 | 'slot_role_id', |
2546 | ] ); |
2547 | |
2548 | if ( in_array( 'role', $options, true ) ) { |
2549 | // Use left join to attach role name, so we still find the revision row even |
2550 | // if the role name is missing. This triggers a more obvious failure mode. |
2551 | $ret['tables'][] = 'slot_roles'; |
2552 | $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ]; |
2553 | $ret['fields'][] = 'role_name'; |
2554 | } |
2555 | |
2556 | if ( in_array( 'content', $options, true ) ) { |
2557 | $ret['keys']['model_id'] = 'content_model'; |
2558 | |
2559 | $ret['tables'][] = 'content'; |
2560 | $ret['fields'] = array_merge( $ret['fields'], [ |
2561 | 'content_size', |
2562 | 'content_sha1', |
2563 | 'content_address', |
2564 | 'content_model', |
2565 | ] ); |
2566 | $ret['joins']['content'] = [ 'JOIN', [ 'slot_content_id = content_id' ] ]; |
2567 | |
2568 | if ( in_array( 'model', $options, true ) ) { |
2569 | // Use left join to attach model name, so we still find the revision row even |
2570 | // if the model name is missing. This triggers a more obvious failure mode. |
2571 | $ret['tables'][] = 'content_models'; |
2572 | $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ]; |
2573 | $ret['fields'][] = 'model_name'; |
2574 | } |
2575 | |
2576 | } |
2577 | |
2578 | return $ret; |
2579 | } |
2580 | |
2581 | /** |
2582 | * Determine whether the parameter is a row containing all the fields |
2583 | * that RevisionStore needs to create a RevisionRecord from the row. |
2584 | * |
2585 | * @param mixed $row |
2586 | * @param string $table 'archive' or empty |
2587 | * @return bool |
2588 | */ |
2589 | public function isRevisionRow( $row, string $table = '' ) { |
2590 | if ( !( $row instanceof stdClass ) ) { |
2591 | return false; |
2592 | } |
2593 | $queryInfo = $table === 'archive' ? $this->getArchiveQueryInfo() : $this->getQueryInfo(); |
2594 | foreach ( $queryInfo['fields'] as $alias => $field ) { |
2595 | $name = is_numeric( $alias ) ? $field : $alias; |
2596 | if ( !property_exists( $row, $name ) ) { |
2597 | return false; |
2598 | } |
2599 | } |
2600 | return true; |
2601 | } |
2602 | |
2603 | /** |
2604 | * Return the tables, fields, and join conditions to be selected to create |
2605 | * a new RevisionArchiveRecord object. |
2606 | * |
2607 | * Since 1.34, ar_user and ar_user_text have not been present in the |
2608 | * database, but they continue to be available in query results as |
2609 | * aliases. |
2610 | * |
2611 | * MCR migration note: this replaced Revision::getArchiveQueryInfo |
2612 | * |
2613 | * @since 1.31 |
2614 | * @deprecated since 1.41 use RevisionStore::newArchiveSelectQueryBuilder() instead. |
2615 | * |
2616 | * @return array[] With three keys: |
2617 | * - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables` |
2618 | * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields` |
2619 | * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds` |
2620 | * @phan-return array{tables:string[],fields:string[],joins:array} |
2621 | */ |
2622 | public function getArchiveQueryInfo() { |
2623 | $commentQuery = $this->commentStore->getJoin( 'ar_comment' ); |
2624 | $ret = [ |
2625 | 'tables' => [ |
2626 | 'archive', |
2627 | 'archive_actor' => 'actor' |
2628 | ] + $commentQuery['tables'], |
2629 | 'fields' => [ |
2630 | 'ar_id', |
2631 | 'ar_page_id', |
2632 | 'ar_namespace', |
2633 | 'ar_title', |
2634 | 'ar_rev_id', |
2635 | 'ar_timestamp', |
2636 | 'ar_minor_edit', |
2637 | 'ar_deleted', |
2638 | 'ar_len', |
2639 | 'ar_parent_id', |
2640 | 'ar_sha1', |
2641 | 'ar_actor', |
2642 | 'ar_user' => 'archive_actor.actor_user', |
2643 | 'ar_user_text' => 'archive_actor.actor_name', |
2644 | ] + $commentQuery['fields'], |
2645 | 'joins' => [ |
2646 | 'archive_actor' => [ 'JOIN', 'actor_id=ar_actor' ] |
2647 | ] + $commentQuery['joins'], |
2648 | ]; |
2649 | |
2650 | return $ret; |
2651 | } |
2652 | |
2653 | /** |
2654 | * Do a batched query for the sizes of a set of revisions. |
2655 | * |
2656 | * MCR migration note: this replaced Revision::getParentLengths |
2657 | * |
2658 | * @param int[] $revIds |
2659 | * @return int[] associative array mapping revision IDs from $revIds to the nominal size |
2660 | * of the corresponding revision. |
2661 | */ |
2662 | public function getRevisionSizes( array $revIds ) { |
2663 | $dbr = $this->getDBConnection( DB_REPLICA ); |
2664 | $revLens = []; |
2665 | if ( !$revIds ) { |
2666 | return $revLens; // empty |
2667 | } |
2668 | |
2669 | $res = $dbr->newSelectQueryBuilder() |
2670 | ->select( [ 'rev_id', 'rev_len' ] ) |
2671 | ->from( 'revision' ) |
2672 | ->where( [ 'rev_id' => $revIds ] ) |
2673 | ->caller( __METHOD__ )->fetchResultSet(); |
2674 | |
2675 | foreach ( $res as $row ) { |
2676 | $revLens[$row->rev_id] = intval( $row->rev_len ); |
2677 | } |
2678 | |
2679 | return $revLens; |
2680 | } |
2681 | |
2682 | /** |
2683 | * Implementation of getPreviousRevision and getNextRevision. |
2684 | * |
2685 | * @param RevisionRecord $rev |
2686 | * @param int $flags |
2687 | * @param string $dir 'next' or 'prev' |
2688 | * @return RevisionRecord|null |
2689 | */ |
2690 | private function getRelativeRevision( RevisionRecord $rev, $flags, $dir ) { |
2691 | $op = $dir === 'next' ? '>' : '<'; |
2692 | $sort = $dir === 'next' ? 'ASC' : 'DESC'; |
2693 | |
2694 | $revisionIdValue = $rev->getId( $this->wikiId ); |
2695 | |
2696 | if ( !$revisionIdValue || !$rev->getPageId( $this->wikiId ) ) { |
2697 | // revision is unsaved or otherwise incomplete |
2698 | return null; |
2699 | } |
2700 | |
2701 | if ( $rev instanceof RevisionArchiveRecord ) { |
2702 | // revision is deleted, so it's not part of the page history |
2703 | return null; |
2704 | } |
2705 | |
2706 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
2707 | $dbType = DB_PRIMARY; |
2708 | } else { |
2709 | $dbType = DB_REPLICA; |
2710 | } |
2711 | $db = $this->getDBConnection( $dbType ); |
2712 | |
2713 | $ts = $rev->getTimestamp() ?? $this->getTimestampFromId( $revisionIdValue, $flags ); |
2714 | if ( $ts === false ) { |
2715 | // XXX Should this be moved into getTimestampFromId? |
2716 | $ts = $db->newSelectQueryBuilder() |
2717 | ->select( 'ar_timestamp' ) |
2718 | ->from( 'archive' ) |
2719 | ->where( [ 'ar_rev_id' => $revisionIdValue ] ) |
2720 | ->caller( __METHOD__ )->fetchField(); |
2721 | if ( $ts === false ) { |
2722 | // XXX Is this reachable? How can we have a page id but no timestamp? |
2723 | return null; |
2724 | } |
2725 | } |
2726 | |
2727 | $revId = $db->selectField( 'revision', 'rev_id', |
2728 | [ |
2729 | 'rev_page' => $rev->getPageId( $this->wikiId ), |
2730 | $db->buildComparison( $op, [ |
2731 | 'rev_timestamp' => $db->timestamp( $ts ), |
2732 | 'rev_id' => $revisionIdValue, |
2733 | ] ), |
2734 | ], |
2735 | __METHOD__, |
2736 | [ |
2737 | 'ORDER BY' => [ "rev_timestamp $sort", "rev_id $sort" ], |
2738 | 'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319 |
2739 | ] |
2740 | ); |
2741 | |
2742 | if ( $revId === false ) { |
2743 | return null; |
2744 | } |
2745 | |
2746 | return $this->getRevisionById( intval( $revId ), $flags ); |
2747 | } |
2748 | |
2749 | /** |
2750 | * Get the revision before $rev in the page's history, if any. |
2751 | * Will return null for the first revision but also for deleted or unsaved revisions. |
2752 | * |
2753 | * MCR migration note: this replaced Revision::getPrevious |
2754 | * |
2755 | * @see PageArchive::getPreviousRevisionRecord |
2756 | * |
2757 | * @param RevisionRecord $rev |
2758 | * @param int $flags (optional) $flags include: |
2759 | * IDBAccessObject::READ_LATEST: Select the data from the primary DB |
2760 | * |
2761 | * @return RevisionRecord|null |
2762 | */ |
2763 | public function getPreviousRevision( RevisionRecord $rev, $flags = IDBAccessObject::READ_NORMAL ) { |
2764 | return $this->getRelativeRevision( $rev, $flags, 'prev' ); |
2765 | } |
2766 | |
2767 | /** |
2768 | * Get the revision after $rev in the page's history, if any. |
2769 | * Will return null for the latest revision but also for deleted or unsaved revisions. |
2770 | * |
2771 | * MCR migration note: this replaced Revision::getNext |
2772 | * |
2773 | * @param RevisionRecord $rev |
2774 | * @param int $flags (optional) $flags include: |
2775 | * IDBAccessObject::READ_LATEST: Select the data from the primary DB |
2776 | * @return RevisionRecord|null |
2777 | */ |
2778 | public function getNextRevision( RevisionRecord $rev, $flags = IDBAccessObject::READ_NORMAL ) { |
2779 | return $this->getRelativeRevision( $rev, $flags, 'next' ); |
2780 | } |
2781 | |
2782 | /** |
2783 | * Get previous revision Id for this page_id |
2784 | * This is used to populate rev_parent_id on save |
2785 | * |
2786 | * MCR migration note: this corresponded to Revision::getPreviousRevisionId |
2787 | * |
2788 | * @param IDatabase $db |
2789 | * @param RevisionRecord $rev |
2790 | * |
2791 | * @return int |
2792 | */ |
2793 | private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) { |
2794 | $this->checkDatabaseDomain( $db ); |
2795 | |
2796 | if ( $rev->getPageId( $this->wikiId ) === null ) { |
2797 | return 0; |
2798 | } |
2799 | # Use page_latest if ID is not given |
2800 | if ( !$rev->getId( $this->wikiId ) ) { |
2801 | $prevId = $db->newSelectQueryBuilder() |
2802 | ->select( 'page_latest' ) |
2803 | ->from( 'page' ) |
2804 | ->where( [ 'page_id' => $rev->getPageId( $this->wikiId ) ] ) |
2805 | ->caller( __METHOD__ )->fetchField(); |
2806 | } else { |
2807 | $prevId = $db->newSelectQueryBuilder() |
2808 | ->select( 'rev_id' ) |
2809 | ->from( 'revision' ) |
2810 | ->where( [ 'rev_page' => $rev->getPageId( $this->wikiId ) ] ) |
2811 | ->andWhere( 'rev_id < ' . $rev->getId( $this->wikiId ) ) |
2812 | ->orderBy( 'rev_id DESC' ) |
2813 | ->caller( __METHOD__ )->fetchField(); |
2814 | } |
2815 | return intval( $prevId ); |
2816 | } |
2817 | |
2818 | /** |
2819 | * Get rev_timestamp from rev_id, without loading the rest of the row. |
2820 | * |
2821 | * Historically, there was an extra Title parameter that was passed before $id. This is no |
2822 | * longer needed and is deprecated in 1.34. |
2823 | * |
2824 | * MCR migration note: this replaced Revision::getTimestampFromId |
2825 | * |
2826 | * @param int $id |
2827 | * @param int $flags |
2828 | * @return string|false False if not found |
2829 | */ |
2830 | public function getTimestampFromId( $id, $flags = 0 ) { |
2831 | if ( $id instanceof Title ) { |
2832 | // Old deprecated calling convention supported for backwards compatibility |
2833 | $id = $flags; |
2834 | $flags = func_num_args() > 2 ? func_get_arg( 2 ) : 0; |
2835 | } |
2836 | |
2837 | // T270149: Bail out if we know the query will definitely return false. Some callers are |
2838 | // passing RevisionRecord::getId() call directly as $id which can possibly return null. |
2839 | // Null $id or $id <= 0 will lead to useless query with WHERE clause of 'rev_id IS NULL' |
2840 | // or 'rev_id = 0', but 'rev_id' is always greater than zero and cannot be null. |
2841 | // @todo typehint $id and remove the null check |
2842 | if ( $id === null || $id <= 0 ) { |
2843 | return false; |
2844 | } |
2845 | |
2846 | $db = $this->getDBConnectionRefForQueryFlags( $flags ); |
2847 | |
2848 | $timestamp = |
2849 | $db->newSelectQueryBuilder() |
2850 | ->select( 'rev_timestamp' ) |
2851 | ->from( 'revision' ) |
2852 | ->where( [ 'rev_id' => $id ] ) |
2853 | ->caller( __METHOD__ )->fetchField(); |
2854 | |
2855 | return ( $timestamp !== false ) ? MWTimestamp::convert( TS_MW, $timestamp ) : false; |
2856 | } |
2857 | |
2858 | /** |
2859 | * Get count of revisions per page...not very efficient |
2860 | * |
2861 | * MCR migration note: this replaced Revision::countByPageId |
2862 | * |
2863 | * @param IReadableDatabase $db |
2864 | * @param int $id Page id |
2865 | * @return int |
2866 | */ |
2867 | public function countRevisionsByPageId( IReadableDatabase $db, $id ) { |
2868 | $this->checkDatabaseDomain( $db ); |
2869 | |
2870 | $row = $db->newSelectQueryBuilder() |
2871 | ->select( [ 'revCount' => 'COUNT(*)' ] ) |
2872 | ->from( 'revision' ) |
2873 | ->where( [ 'rev_page' => $id ] ) |
2874 | ->caller( __METHOD__ )->fetchRow(); |
2875 | if ( $row ) { |
2876 | return intval( $row->revCount ); |
2877 | } |
2878 | return 0; |
2879 | } |
2880 | |
2881 | /** |
2882 | * Get count of revisions per page...not very efficient |
2883 | * |
2884 | * MCR migration note: this replaced Revision::countByTitle |
2885 | * |
2886 | * @param IDatabase $db |
2887 | * @param PageIdentity $page |
2888 | * @return int |
2889 | */ |
2890 | public function countRevisionsByTitle( IDatabase $db, PageIdentity $page ) { |
2891 | $id = $this->getArticleId( $page ); |
2892 | if ( $id ) { |
2893 | return $this->countRevisionsByPageId( $db, $id ); |
2894 | } |
2895 | return 0; |
2896 | } |
2897 | |
2898 | /** |
2899 | * Check if no edits were made by other users since |
2900 | * the time a user started editing the page. Limit to |
2901 | * 50 revisions for the sake of performance. |
2902 | * |
2903 | * MCR migration note: this replaced Revision::userWasLastToEdit |
2904 | * |
2905 | * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression |
2906 | * logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit |
2907 | * had been deprecated since 1.24 (the Revision class was removed entirely in 1.37). |
2908 | * |
2909 | * @param IDatabase $db The Database to perform the check on. |
2910 | * @param int $pageId The ID of the page in question |
2911 | * @param int $userId The ID of the user in question |
2912 | * @param string $since Look at edits since this time |
2913 | * |
2914 | * @return bool True if the given user was the only one to edit since the given timestamp |
2915 | */ |
2916 | public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) { |
2917 | $this->checkDatabaseDomain( $db ); |
2918 | |
2919 | if ( !$userId ) { |
2920 | return false; |
2921 | } |
2922 | |
2923 | $queryBuilder = $this->newSelectQueryBuilder( $db ) |
2924 | ->where( [ |
2925 | 'rev_page' => $pageId, |
2926 | $db->expr( 'rev_timestamp', '>', $db->timestamp( $since ) ) |
2927 | ] ) |
2928 | ->orderBy( 'rev_timestamp', SelectQueryBuilder::SORT_ASC ) |
2929 | ->limit( 50 ); |
2930 | $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet(); |
2931 | foreach ( $res as $row ) { |
2932 | if ( $row->rev_user != $userId ) { |
2933 | return false; |
2934 | } |
2935 | } |
2936 | return true; |
2937 | } |
2938 | |
2939 | /** |
2940 | * Load a revision based on a known page ID and current revision ID from the DB |
2941 | * |
2942 | * This method allows for the use of caching, though accessing anything that normally |
2943 | * requires permission checks (aside from the text) will trigger a small DB lookup. |
2944 | * |
2945 | * MCR migration note: this replaced Revision::newKnownCurrent |
2946 | * |
2947 | * @param PageIdentity $page the associated page |
2948 | * @param int $revId current revision of this page. Defaults to $title->getLatestRevID(). |
2949 | * |
2950 | * @return RevisionRecord|false Returns false if missing |
2951 | */ |
2952 | public function getKnownCurrentRevision( PageIdentity $page, $revId = 0 ) { |
2953 | $db = $this->getDBConnection( DB_REPLICA ); |
2954 | $revIdPassed = $revId; |
2955 | $pageId = $this->getArticleId( $page ); |
2956 | if ( !$pageId ) { |
2957 | return false; |
2958 | } |
2959 | |
2960 | if ( !$revId ) { |
2961 | if ( $page instanceof Title ) { |
2962 | $revId = $page->getLatestRevID(); |
2963 | } else { |
2964 | $pageRecord = $this->pageStore->getPageByReference( $page ); |
2965 | if ( $pageRecord ) { |
2966 | $revId = $pageRecord->getLatest( $this->getWikiId() ); |
2967 | } |
2968 | } |
2969 | } |
2970 | |
2971 | if ( !$revId ) { |
2972 | $this->logger->warning( |
2973 | 'No latest revision known for page {page} even though it exists with page ID {page_id}', [ |
2974 | 'page' => $page->__toString(), |
2975 | 'page_id' => $pageId, |
2976 | 'wiki_id' => $this->getWikiId() ?: 'local', |
2977 | ] ); |
2978 | return false; |
2979 | } |
2980 | |
2981 | // Load the row from cache if possible. If not possible, populate the cache. |
2982 | // As a minor optimization, remember if this was a cache hit or miss. |
2983 | // We can sometimes avoid a database query later if this is a cache miss. |
2984 | $fromCache = true; |
2985 | $row = $this->cache->getWithSetCallback( |
2986 | // Page/rev IDs passed in from DB to reflect history merges |
2987 | $this->getRevisionRowCacheKey( $db, $pageId, $revId ), |
2988 | WANObjectCache::TTL_WEEK, |
2989 | function ( $curValue, &$ttl, array &$setOpts ) use ( |
2990 | $db, $revId, &$fromCache |
2991 | ) { |
2992 | $setOpts += Database::getCacheSetOptions( $db ); |
2993 | $row = $this->fetchRevisionRowFromConds( $db, [ 'rev_id' => intval( $revId ) ] ); |
2994 | if ( $row ) { |
2995 | $fromCache = false; |
2996 | } |
2997 | return $row; // don't cache negatives |
2998 | } |
2999 | ); |
3000 | |
3001 | // Reflect revision deletion and user renames. |
3002 | if ( $row ) { |
3003 | $title = $this->ensureRevisionRowMatchesPage( $row, $page, [ |
3004 | 'from_cache_flag' => $fromCache, |
3005 | 'page_id_initial' => $pageId, |
3006 | 'rev_id_used' => $revId, |
3007 | 'rev_id_requested' => $revIdPassed, |
3008 | ] ); |
3009 | |
3010 | return $this->newRevisionFromRow( $row, 0, $title, $fromCache ); |
3011 | } else { |
3012 | return false; |
3013 | } |
3014 | } |
3015 | |
3016 | /** |
3017 | * Get the first revision of a given page. |
3018 | * |
3019 | * @since 1.35 |
3020 | * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36 |
3021 | * @param int $flags |
3022 | * @return RevisionRecord|null |
3023 | */ |
3024 | public function getFirstRevision( |
3025 | $page, |
3026 | int $flags = IDBAccessObject::READ_NORMAL |
3027 | ): ?RevisionRecord { |
3028 | if ( $page instanceof LinkTarget ) { |
3029 | // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756) |
3030 | $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null; |
3031 | } |
3032 | return $this->newRevisionFromConds( |
3033 | [ |
3034 | 'page_namespace' => $page->getNamespace(), |
3035 | 'page_title' => $page->getDBkey() |
3036 | ], |
3037 | $flags, |
3038 | $page, |
3039 | [ |
3040 | 'ORDER BY' => [ 'rev_timestamp ASC', 'rev_id ASC' ], |
3041 | 'IGNORE INDEX' => [ 'revision' => 'rev_timestamp' ], // See T159319 |
3042 | ] |
3043 | ); |
3044 | } |
3045 | |
3046 | /** |
3047 | * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] ) |
3048 | * Caching rows without 'page' or 'user' could lead to issues. |
3049 | * If the format of the rows returned by the query provided by getQueryInfo changes the |
3050 | * cache key should be updated to avoid conflicts. |
3051 | * |
3052 | * @param IDatabase $db |
3053 | * @param int $pageId |
3054 | * @param int $revId |
3055 | * @return string |
3056 | */ |
3057 | private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) { |
3058 | return $this->cache->makeGlobalKey( |
3059 | self::ROW_CACHE_KEY, |
3060 | $db->getDomainID(), |
3061 | $pageId, |
3062 | $revId |
3063 | ); |
3064 | } |
3065 | |
3066 | /** |
3067 | * Asserts that if revision is provided, it's saved and belongs to the page with provided pageId. |
3068 | * @param string $paramName |
3069 | * @param int $pageId |
3070 | * @param RevisionRecord|null $rev |
3071 | * @throws InvalidArgumentException |
3072 | */ |
3073 | private function assertRevisionParameter( $paramName, $pageId, RevisionRecord $rev = null ) { |
3074 | if ( $rev ) { |
3075 | if ( $rev->getId( $this->wikiId ) === null ) { |
3076 | throw new InvalidArgumentException( "Unsaved {$paramName} revision passed" ); |
3077 | } |
3078 | if ( $rev->getPageId( $this->wikiId ) !== $pageId ) { |
3079 | throw new InvalidArgumentException( |
3080 | "Revision {$rev->getId( $this->wikiId )} doesn't belong to page {$pageId}" |
3081 | ); |
3082 | } |
3083 | } |
3084 | } |
3085 | |
3086 | /** |
3087 | * Converts revision limits to query conditions. |
3088 | * |
3089 | * @param ISQLPlatform $dbr |
3090 | * @param RevisionRecord|null $old Old revision. |
3091 | * If null is provided, count starting from the first revision (inclusive). |
3092 | * @param RevisionRecord|null $new New revision. |
3093 | * If null is provided, count until the last revision (inclusive). |
3094 | * @param string|array $options Single option, or an array of options: |
3095 | * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. |
3096 | * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. |
3097 | * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. |
3098 | * @return array |
3099 | */ |
3100 | private function getRevisionLimitConditions( |
3101 | ISQLPlatform $dbr, |
3102 | RevisionRecord $old = null, |
3103 | RevisionRecord $new = null, |
3104 | $options = [] |
3105 | ) { |
3106 | $options = (array)$options; |
3107 | if ( in_array( self::INCLUDE_OLD, $options ) || in_array( self::INCLUDE_BOTH, $options ) ) { |
3108 | $oldCmp = '>='; |
3109 | } else { |
3110 | $oldCmp = '>'; |
3111 | } |
3112 | if ( in_array( self::INCLUDE_NEW, $options ) || in_array( self::INCLUDE_BOTH, $options ) ) { |
3113 | $newCmp = '<='; |
3114 | } else { |
3115 | $newCmp = '<'; |
3116 | } |
3117 | |
3118 | $conds = []; |
3119 | if ( $old ) { |
3120 | $conds[] = $dbr->buildComparison( $oldCmp, [ |
3121 | 'rev_timestamp' => $dbr->timestamp( $old->getTimestamp() ), |
3122 | 'rev_id' => $old->getId( $this->wikiId ), |
3123 | ] ); |
3124 | } |
3125 | if ( $new ) { |
3126 | $conds[] = $dbr->buildComparison( $newCmp, [ |
3127 | 'rev_timestamp' => $dbr->timestamp( $new->getTimestamp() ), |
3128 | 'rev_id' => $new->getId( $this->wikiId ), |
3129 | ] ); |
3130 | } |
3131 | return $conds; |
3132 | } |
3133 | |
3134 | /** |
3135 | * Get IDs of revisions between the given revisions. |
3136 | * |
3137 | * @since 1.36 |
3138 | * |
3139 | * @param int $pageId The id of the page |
3140 | * @param RevisionRecord|null $old Old revision. |
3141 | * If null is provided, count starting from the first revision (inclusive). |
3142 | * @param RevisionRecord|null $new New revision. |
3143 | * If null is provided, count until the last revision (inclusive). |
3144 | * @param int|null $max Limit of Revisions to count, will be incremented by |
3145 | * one to detect truncations. |
3146 | * @param string|array $options Single option, or an array of options: |
3147 | * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. |
3148 | * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. |
3149 | * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. |
3150 | * @param string|null $order The direction in which the revisions should be sorted. |
3151 | * Possible values: |
3152 | * - RevisionStore::ORDER_OLDEST_TO_NEWEST |
3153 | * - RevisionStore::ORDER_NEWEST_TO_OLDEST |
3154 | * - null for no specific ordering (default value) |
3155 | * @param int $flags |
3156 | * @throws InvalidArgumentException in case either revision is unsaved or |
3157 | * the revisions do not belong to the same page or unknown option is passed. |
3158 | * @return int[] |
3159 | */ |
3160 | public function getRevisionIdsBetween( |
3161 | int $pageId, |
3162 | RevisionRecord $old = null, |
3163 | RevisionRecord $new = null, |
3164 | ?int $max = null, |
3165 | $options = [], |
3166 | ?string $order = null, |
3167 | int $flags = IDBAccessObject::READ_NORMAL |
3168 | ): array { |
3169 | $this->assertRevisionParameter( 'old', $pageId, $old ); |
3170 | $this->assertRevisionParameter( 'new', $pageId, $new ); |
3171 | |
3172 | $options = (array)$options; |
3173 | $includeOld = in_array( self::INCLUDE_OLD, $options ) || |
3174 | in_array( self::INCLUDE_BOTH, $options ); |
3175 | $includeNew = in_array( self::INCLUDE_NEW, $options ) || |
3176 | in_array( self::INCLUDE_BOTH, $options ); |
3177 | |
3178 | // No DB query needed if old and new are the same revision. |
3179 | // Can't check for consecutive revisions with 'getParentId' for a similar |
3180 | // optimization as edge cases exist when there are revisions between |
3181 | // a revision and it's parent. See T185167 for more details. |
3182 | if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) { |
3183 | return $includeOld || $includeNew ? [ $new->getId( $this->wikiId ) ] : []; |
3184 | } |
3185 | |
3186 | $db = $this->getDBConnectionRefForQueryFlags( $flags ); |
3187 | $queryBuilder = $db->newSelectQueryBuilder() |
3188 | ->select( 'rev_id' ) |
3189 | ->from( 'revision' ) |
3190 | ->where( [ |
3191 | 'rev_page' => $pageId, |
3192 | $db->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0' |
3193 | ] ) |
3194 | ->andWhere( $this->getRevisionLimitConditions( $db, $old, $new, $options ) ); |
3195 | |
3196 | if ( $order !== null ) { |
3197 | $queryBuilder->orderBy( [ 'rev_timestamp', 'rev_id' ], $order ); |
3198 | } |
3199 | if ( $max !== null ) { |
3200 | // extra to detect truncation |
3201 | $queryBuilder->limit( $max + 1 ); |
3202 | } |
3203 | |
3204 | $values = $queryBuilder->caller( __METHOD__ )->fetchFieldValues(); |
3205 | return array_map( 'intval', $values ); |
3206 | } |
3207 | |
3208 | /** |
3209 | * Get the authors between the given revisions or revisions. |
3210 | * Used for diffs and other things that really need it. |
3211 | * |
3212 | * @since 1.35 |
3213 | * |
3214 | * @param int $pageId The id of the page |
3215 | * @param RevisionRecord|null $old Old revision. |
3216 | * If null is provided, count starting from the first revision (inclusive). |
3217 | * @param RevisionRecord|null $new New revision. |
3218 | * If null is provided, count until the last revision (inclusive). |
3219 | * @param Authority|null $performer the user whose access rights to apply |
3220 | * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations. |
3221 | * @param string|array $options Single option, or an array of options: |
3222 | * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. |
3223 | * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. |
3224 | * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. |
3225 | * @throws InvalidArgumentException in case either revision is unsaved or |
3226 | * the revisions do not belong to the same page or unknown option is passed. |
3227 | * @return UserIdentity[] Names of revision authors in the range |
3228 | */ |
3229 | public function getAuthorsBetween( |
3230 | $pageId, |
3231 | RevisionRecord $old = null, |
3232 | RevisionRecord $new = null, |
3233 | Authority $performer = null, |
3234 | $max = null, |
3235 | $options = [] |
3236 | ) { |
3237 | $this->assertRevisionParameter( 'old', $pageId, $old ); |
3238 | $this->assertRevisionParameter( 'new', $pageId, $new ); |
3239 | $options = (array)$options; |
3240 | |
3241 | // No DB query needed if old and new are the same revision. |
3242 | // Can't check for consecutive revisions with 'getParentId' for a similar |
3243 | // optimization as edge cases exist when there are revisions between |
3244 | //a revision and it's parent. See T185167 for more details. |
3245 | if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) { |
3246 | if ( !$options ) { |
3247 | return []; |
3248 | } elseif ( $performer ) { |
3249 | return [ $new->getUser( RevisionRecord::FOR_THIS_USER, $performer ) ]; |
3250 | } else { |
3251 | return [ $new->getUser() ]; |
3252 | } |
3253 | } |
3254 | |
3255 | $dbr = $this->getDBConnection( DB_REPLICA ); |
3256 | $conds = array_merge( |
3257 | [ |
3258 | 'rev_page' => $pageId, |
3259 | $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . " = 0" |
3260 | ], |
3261 | $this->getRevisionLimitConditions( $dbr, $old, $new, $options ) |
3262 | ); |
3263 | |
3264 | $queryOpts = [ 'DISTINCT' ]; |
3265 | if ( $max !== null ) { |
3266 | $queryOpts['LIMIT'] = $max + 1; |
3267 | } |
3268 | |
3269 | return array_map( function ( $row ) { |
3270 | return $this->actorStore->newActorFromRowFields( |
3271 | $row->rev_user, |
3272 | $row->rev_user_text, |
3273 | $row->rev_actor |
3274 | ); |
3275 | }, iterator_to_array( $dbr->select( |
3276 | [ 'revision', 'revision_actor' => 'actor' ], |
3277 | [ |
3278 | 'rev_actor', |
3279 | 'rev_user' => 'revision_actor.actor_user', |
3280 | 'rev_user_text' => 'revision_actor.actor_name', |
3281 | ], |
3282 | $conds, |
3283 | __METHOD__, |
3284 | $queryOpts, |
3285 | [ 'revision_actor' => [ 'JOIN', 'revision_actor.actor_id = rev_actor' ] ] |
3286 | ) ) ); |
3287 | } |
3288 | |
3289 | /** |
3290 | * Get the number of authors between the given revisions. |
3291 | * Used for diffs and other things that really need it. |
3292 | * |
3293 | * @since 1.35 |
3294 | * |
3295 | * @param int $pageId The id of the page |
3296 | * @param RevisionRecord|null $old Old revision . |
3297 | * If null is provided, count starting from the first revision (inclusive). |
3298 | * @param RevisionRecord|null $new New revision. |
3299 | * If null is provided, count until the last revision (inclusive). |
3300 | * @param Authority|null $performer the user whose access rights to apply |
3301 | * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations. |
3302 | * @param string|array $options Single option, or an array of options: |
3303 | * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. |
3304 | * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. |
3305 | * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. |
3306 | * @throws InvalidArgumentException in case either revision is unsaved or |
3307 | * the revisions do not belong to the same page or unknown option is passed. |
3308 | * @return int Number of revisions authors in the range. |
3309 | */ |
3310 | public function countAuthorsBetween( |
3311 | $pageId, |
3312 | RevisionRecord $old = null, |
3313 | RevisionRecord $new = null, |
3314 | Authority $performer = null, |
3315 | $max = null, |
3316 | $options = [] |
3317 | ) { |
3318 | // TODO: Implement with a separate query to avoid cost of selecting unneeded fields |
3319 | // and creation of UserIdentity stuff. |
3320 | return count( $this->getAuthorsBetween( $pageId, $old, $new, $performer, $max, $options ) ); |
3321 | } |
3322 | |
3323 | /** |
3324 | * Get the number of revisions between the given revisions. |
3325 | * Used for diffs and other things that really need it. |
3326 | * |
3327 | * @since 1.35 |
3328 | * |
3329 | * @param int $pageId The id of the page |
3330 | * @param RevisionRecord|null $old Old revision. |
3331 | * If null is provided, count starting from the first revision (inclusive). |
3332 | * @param RevisionRecord|null $new New revision. |
3333 | * If null is provided, count until the last revision (inclusive). |
3334 | * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations. |
3335 | * @param string|array $options Single option, or an array of options: |
3336 | * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. |
3337 | * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. |
3338 | * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. |
3339 | * @throws InvalidArgumentException in case either revision is unsaved or |
3340 | * the revisions do not belong to the same page. |
3341 | * @return int Number of revisions between these revisions. |
3342 | */ |
3343 | public function countRevisionsBetween( |
3344 | $pageId, |
3345 | RevisionRecord $old = null, |
3346 | RevisionRecord $new = null, |
3347 | $max = null, |
3348 | $options = [] |
3349 | ) { |
3350 | $this->assertRevisionParameter( 'old', $pageId, $old ); |
3351 | $this->assertRevisionParameter( 'new', $pageId, $new ); |
3352 | |
3353 | // No DB query needed if old and new are the same revision. |
3354 | // Can't check for consecutive revisions with 'getParentId' for a similar |
3355 | // optimization as edge cases exist when there are revisions between |
3356 | //a revision and it's parent. See T185167 for more details. |
3357 | if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) { |
3358 | return 0; |
3359 | } |
3360 | |
3361 | $dbr = $this->getDBConnection( DB_REPLICA ); |
3362 | $conds = array_merge( |
3363 | [ |
3364 | 'rev_page' => $pageId, |
3365 | $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0" |
3366 | ], |
3367 | $this->getRevisionLimitConditions( $dbr, $old, $new, $options ) |
3368 | ); |
3369 | if ( $max !== null ) { |
3370 | return $dbr->selectRowCount( 'revision', '1', |
3371 | $conds, |
3372 | __METHOD__, |
3373 | [ 'LIMIT' => $max + 1 ] // extra to detect truncation |
3374 | ); |
3375 | } else { |
3376 | return (int)$dbr->newSelectQueryBuilder() |
3377 | ->select( 'count(*)' ) |
3378 | ->from( 'revision' ) |
3379 | ->where( $conds ) |
3380 | ->caller( __METHOD__ )->fetchField(); |
3381 | } |
3382 | } |
3383 | |
3384 | /** |
3385 | * Tries to find a revision identical to $revision in $searchLimit most recent revisions |
3386 | * of this page. The comparison is based on SHA1s of these revisions. |
3387 | * |
3388 | * @since 1.37 |
3389 | * |
3390 | * @param RevisionRecord $revision which revision to compare to |
3391 | * @param int $searchLimit How many recent revisions should be checked |
3392 | * |
3393 | * @return RevisionRecord|null |
3394 | */ |
3395 | public function findIdenticalRevision( |
3396 | RevisionRecord $revision, |
3397 | int $searchLimit |
3398 | ): ?RevisionRecord { |
3399 | $revision->assertWiki( $this->wikiId ); |
3400 | $db = $this->getDBConnection( DB_REPLICA ); |
3401 | $subquery = $this->newSelectQueryBuilder( $db ) |
3402 | ->joinComment() |
3403 | ->where( [ 'rev_page' => $revision->getPageId( $this->wikiId ) ] ) |
3404 | // Include 'rev_id' in the ordering in case there are multiple revs with same timestamp |
3405 | ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC ) |
3406 | // T354015 |
3407 | ->useIndex( [ 'revision' => 'rev_page_timestamp' ] ) |
3408 | ->limit( $searchLimit ) |
3409 | // skip the most recent edit, we can't revert to it anyway |
3410 | ->offset( 1 ) |
3411 | ->caller( __METHOD__ ); |
3412 | |
3413 | // fetchRow effectively uses LIMIT 1 clause, returning only the first result |
3414 | $revisionRow = $db->newSelectQueryBuilder() |
3415 | ->select( '*' ) |
3416 | ->from( $subquery, 'recent_revs' ) |
3417 | ->where( [ 'rev_sha1' => $revision->getSha1() ] ) |
3418 | ->caller( __METHOD__ )->fetchRow(); |
3419 | |
3420 | return $revisionRow ? $this->newRevisionFromRow( $revisionRow ) : null; |
3421 | } |
3422 | |
3423 | // TODO: move relevant methods from Title here, e.g. isBigDeletion, etc. |
3424 | } |