Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
59.26% |
80 / 135 |
|
71.43% |
10 / 14 |
CRAP | |
0.00% |
0 / 1 |
PageStore | |
59.26% |
80 / 135 |
|
71.43% |
10 / 14 |
112.17 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
incrementStats | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPageForLink | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
getPageByName | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
getPageByNameViaLinkCache | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
42 | |||
getPageByText | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getExistingPageByText | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getPageById | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
getPageByReference | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
loadPageFromConditions | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
newPageRecordFromRow | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getSelectFields | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
newSelectQueryBuilder | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
getSubpages | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Page; |
4 | |
5 | use EmptyIterator; |
6 | use IDBAccessObject; |
7 | use InvalidArgumentException; |
8 | use Iterator; |
9 | use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; |
10 | use MediaWiki\Cache\LinkCache; |
11 | use MediaWiki\Config\ServiceOptions; |
12 | use MediaWiki\DAO\WikiAwareEntity; |
13 | use MediaWiki\MainConfigNames; |
14 | use MediaWiki\Title\MalformedTitleException; |
15 | use MediaWiki\Title\NamespaceInfo; |
16 | use MediaWiki\Title\TitleParser; |
17 | use NullStatsdDataFactory; |
18 | use stdClass; |
19 | use Wikimedia\Assert\Assert; |
20 | use Wikimedia\Parsoid\Core\LinkTarget as ParsoidLinkTarget; |
21 | use Wikimedia\Rdbms\IDatabase; |
22 | use Wikimedia\Rdbms\ILoadBalancer; |
23 | use Wikimedia\Rdbms\IReadableDatabase; |
24 | |
25 | /** |
26 | * @since 1.36 |
27 | * @unstable |
28 | */ |
29 | class PageStore implements PageLookup { |
30 | |
31 | /** @var ServiceOptions */ |
32 | private $options; |
33 | |
34 | /** @var ILoadBalancer */ |
35 | private $dbLoadBalancer; |
36 | |
37 | /** @var NamespaceInfo */ |
38 | private $namespaceInfo; |
39 | |
40 | /** @var TitleParser */ |
41 | private $titleParser; |
42 | |
43 | /** @var LinkCache|null */ |
44 | private $linkCache; |
45 | |
46 | /** @var StatsdDataFactoryInterface */ |
47 | private $stats; |
48 | |
49 | /** @var string|false */ |
50 | private $wikiId; |
51 | |
52 | /** |
53 | * @internal for use by service wiring |
54 | */ |
55 | public const CONSTRUCTOR_OPTIONS = [ |
56 | MainConfigNames::PageLanguageUseDB, |
57 | ]; |
58 | |
59 | /** |
60 | * @param ServiceOptions $options |
61 | * @param ILoadBalancer $dbLoadBalancer |
62 | * @param NamespaceInfo $namespaceInfo |
63 | * @param TitleParser $titleParser |
64 | * @param ?LinkCache $linkCache |
65 | * @param ?StatsdDataFactoryInterface $stats |
66 | * @param false|string $wikiId |
67 | */ |
68 | public function __construct( |
69 | ServiceOptions $options, |
70 | ILoadBalancer $dbLoadBalancer, |
71 | NamespaceInfo $namespaceInfo, |
72 | TitleParser $titleParser, |
73 | ?LinkCache $linkCache, |
74 | ?StatsdDataFactoryInterface $stats, |
75 | $wikiId = WikiAwareEntity::LOCAL |
76 | ) { |
77 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
78 | |
79 | $this->options = $options; |
80 | $this->dbLoadBalancer = $dbLoadBalancer; |
81 | $this->namespaceInfo = $namespaceInfo; |
82 | $this->titleParser = $titleParser; |
83 | $this->wikiId = $wikiId; |
84 | $this->linkCache = $linkCache; |
85 | $this->stats = $stats ?: new NullStatsdDataFactory(); |
86 | |
87 | if ( $wikiId !== WikiAwareEntity::LOCAL && $linkCache ) { |
88 | // LinkCache currently doesn't support cross-wiki PageReferences. |
89 | // Once it does, this check can go away. At that point, LinkCache should |
90 | // probably also no longer be optional. |
91 | throw new InvalidArgumentException( "Can't use LinkCache with pages from $wikiId" ); |
92 | } |
93 | } |
94 | |
95 | /** |
96 | * @param string $metric |
97 | */ |
98 | private function incrementStats( string $metric ) { |
99 | $this->stats->increment( "PageStore.{$metric}" ); |
100 | } |
101 | |
102 | /** |
103 | * @param ParsoidLinkTarget $link |
104 | * @param int $queryFlags |
105 | * |
106 | * @return ProperPageIdentity |
107 | */ |
108 | public function getPageForLink( |
109 | ParsoidLinkTarget $link, |
110 | int $queryFlags = IDBAccessObject::READ_NORMAL |
111 | ): ProperPageIdentity { |
112 | Assert::parameter( !$link->isExternal(), '$link', 'must not be external' ); |
113 | Assert::parameter( $link->getDBkey() !== '', '$link', 'must not be relative' ); |
114 | |
115 | $ns = $link->getNamespace(); |
116 | |
117 | // Map Media links to File namespace |
118 | if ( $ns === NS_MEDIA ) { |
119 | $ns = NS_FILE; |
120 | } |
121 | |
122 | Assert::parameter( $ns >= 0, '$link', 'namespace must not be virtual' ); |
123 | |
124 | $page = $this->getPageByName( $ns, $link->getDBkey(), $queryFlags ); |
125 | |
126 | if ( !$page ) { |
127 | $page = new PageIdentityValue( 0, $ns, $link->getDBkey(), $this->wikiId ); |
128 | } |
129 | |
130 | return $page; |
131 | } |
132 | |
133 | /** |
134 | * @param int $namespace |
135 | * @param string $dbKey |
136 | * @param int $queryFlags |
137 | * |
138 | * @return ExistingPageRecord|null |
139 | */ |
140 | public function getPageByName( |
141 | int $namespace, |
142 | string $dbKey, |
143 | int $queryFlags = IDBAccessObject::READ_NORMAL |
144 | ): ?ExistingPageRecord { |
145 | Assert::parameter( $dbKey !== '', '$dbKey', 'must not be empty' ); |
146 | Assert::parameter( !strpos( $dbKey, ' ' ), '$dbKey', 'must not contain spaces' ); |
147 | Assert::parameter( $namespace >= 0, '$namespace', 'must not be virtual' ); |
148 | |
149 | $conds = [ |
150 | 'page_namespace' => $namespace, |
151 | 'page_title' => $dbKey, |
152 | ]; |
153 | |
154 | if ( $this->linkCache ) { |
155 | return $this->getPageByNameViaLinkCache( $namespace, $dbKey, $queryFlags ); |
156 | } else { |
157 | return $this->loadPageFromConditions( $conds, $queryFlags ); |
158 | } |
159 | } |
160 | |
161 | /** |
162 | * @param int $namespace |
163 | * @param string $dbKey |
164 | * @param int $queryFlags |
165 | * |
166 | * @return ExistingPageRecord|null |
167 | */ |
168 | private function getPageByNameViaLinkCache( |
169 | int $namespace, |
170 | string $dbKey, |
171 | int $queryFlags = IDBAccessObject::READ_NORMAL |
172 | ): ?ExistingPageRecord { |
173 | $conds = [ |
174 | 'page_namespace' => $namespace, |
175 | 'page_title' => $dbKey, |
176 | ]; |
177 | |
178 | if ( $queryFlags === IDBAccessObject::READ_NORMAL && $this->linkCache->isBadLink( $conds ) ) { |
179 | $this->incrementStats( "LinkCache.hit.bad.early" ); |
180 | return null; |
181 | } |
182 | |
183 | $caller = __METHOD__; |
184 | $hitOrMiss = 'hit'; |
185 | |
186 | // Try to get the row from LinkCache, providing a callback to fetch it if it's not cached. |
187 | // When getGoodLinkRow() returns, LinkCache should have an entry for the row, good or bad. |
188 | $row = $this->linkCache->getGoodLinkRow( |
189 | $namespace, |
190 | $dbKey, |
191 | function ( IDatabase $dbr, $ns, $dbkey, array $options ) |
192 | use ( $conds, $caller, &$hitOrMiss ) |
193 | { |
194 | $hitOrMiss = 'miss'; |
195 | $row = $this->newSelectQueryBuilder( $dbr ) |
196 | ->fields( $this->getSelectFields() ) |
197 | ->conds( $conds ) |
198 | ->options( $options ) |
199 | ->caller( $caller ) |
200 | ->fetchRow(); |
201 | |
202 | return $row; |
203 | }, |
204 | $queryFlags |
205 | ); |
206 | |
207 | if ( $row ) { |
208 | try { |
209 | // NOTE: LinkCache may not include namespace and title in the cached row, |
210 | // since it's already used as the cache key! |
211 | $row->page_namespace = $namespace; |
212 | $row->page_title = $dbKey; |
213 | $page = $this->newPageRecordFromRow( $row ); |
214 | |
215 | // We were able to use the row we got from link cache. |
216 | $this->incrementStats( "LinkCache.{$hitOrMiss}.good" ); |
217 | } catch ( InvalidArgumentException $e ) { |
218 | // The cached row was incomplete or corrupt, |
219 | // just keep going and load from the database. |
220 | $page = $this->loadPageFromConditions( $conds, $queryFlags ); |
221 | |
222 | if ( $page ) { |
223 | // PageSelectQueryBuilder should have added the full row to the LinkCache now. |
224 | $this->incrementStats( "LinkCache.{$hitOrMiss}.incomplete.loaded" ); |
225 | } else { |
226 | // If we get here, an incomplete row was cached, but we failed to |
227 | // load the full row from the database. This should only happen |
228 | // if the page was deleted under out feet, which should be very rare. |
229 | // Update the LinkCache to reflect the new situation. |
230 | $this->linkCache->addBadLinkObj( $conds ); |
231 | $this->incrementStats( "LinkCache.{$hitOrMiss}.incomplete.missing" ); |
232 | } |
233 | } |
234 | } else { |
235 | $this->incrementStats( "LinkCache.{$hitOrMiss}.bad.late" ); |
236 | $page = null; |
237 | } |
238 | |
239 | return $page; |
240 | } |
241 | |
242 | /** |
243 | * @since 1.37 |
244 | * |
245 | * @param string $text |
246 | * @param int $defaultNamespace Namespace to assume per default (usually NS_MAIN) |
247 | * @param int $queryFlags |
248 | * |
249 | * @return ProperPageIdentity|null |
250 | */ |
251 | public function getPageByText( |
252 | string $text, |
253 | int $defaultNamespace = NS_MAIN, |
254 | int $queryFlags = IDBAccessObject::READ_NORMAL |
255 | ): ?ProperPageIdentity { |
256 | try { |
257 | $title = $this->titleParser->parseTitle( $text, $defaultNamespace ); |
258 | return $this->getPageForLink( $title, $queryFlags ); |
259 | } catch ( MalformedTitleException | InvalidArgumentException $e ) { |
260 | // Note that even some well-formed links are still invalid parameters |
261 | // for getPageForLink(), e.g. interwiki links or special pages. |
262 | return null; |
263 | } |
264 | } |
265 | |
266 | /** |
267 | * @since 1.37 |
268 | * |
269 | * @param string $text |
270 | * @param int $defaultNamespace Namespace to assume per default (usually NS_MAIN) |
271 | * @param int $queryFlags |
272 | * |
273 | * @return ExistingPageRecord|null |
274 | */ |
275 | public function getExistingPageByText( |
276 | string $text, |
277 | int $defaultNamespace = NS_MAIN, |
278 | int $queryFlags = IDBAccessObject::READ_NORMAL |
279 | ): ?ExistingPageRecord { |
280 | $pageIdentity = $this->getPageByText( $text, $defaultNamespace, $queryFlags ); |
281 | if ( !$pageIdentity ) { |
282 | return null; |
283 | } |
284 | return $this->getPageByReference( $pageIdentity, $queryFlags ); |
285 | } |
286 | |
287 | /** |
288 | * @param int $pageId |
289 | * @param int $queryFlags |
290 | * |
291 | * @return ExistingPageRecord|null |
292 | */ |
293 | public function getPageById( |
294 | int $pageId, |
295 | int $queryFlags = IDBAccessObject::READ_NORMAL |
296 | ): ?ExistingPageRecord { |
297 | Assert::parameter( $pageId > 0, '$pageId', 'must be greater than zero' ); |
298 | |
299 | $conds = [ |
300 | 'page_id' => $pageId, |
301 | ]; |
302 | |
303 | // XXX: no caching needed? |
304 | |
305 | return $this->loadPageFromConditions( $conds, $queryFlags ); |
306 | } |
307 | |
308 | /** |
309 | * @param PageReference $page |
310 | * @param int $queryFlags |
311 | * |
312 | * @return ExistingPageRecord|null The page's PageRecord, or null if the page was not found. |
313 | */ |
314 | public function getPageByReference( |
315 | PageReference $page, |
316 | int $queryFlags = IDBAccessObject::READ_NORMAL |
317 | ): ?ExistingPageRecord { |
318 | $page->assertWiki( $this->wikiId ); |
319 | Assert::parameter( $page->getNamespace() >= 0, '$page', 'namespace must not be virtual' ); |
320 | |
321 | if ( $page instanceof ExistingPageRecord && $queryFlags === IDBAccessObject::READ_NORMAL ) { |
322 | return $page; |
323 | } |
324 | if ( $page instanceof PageIdentity ) { |
325 | Assert::parameter( $page->canExist(), '$page', 'Must be a proper page' ); |
326 | } |
327 | return $this->getPageByName( $page->getNamespace(), $page->getDBkey(), $queryFlags ); |
328 | } |
329 | |
330 | /** |
331 | * @param array $conds |
332 | * @param int $queryFlags |
333 | * |
334 | * @return ExistingPageRecord|null |
335 | */ |
336 | private function loadPageFromConditions( |
337 | array $conds, |
338 | int $queryFlags = IDBAccessObject::READ_NORMAL |
339 | ): ?ExistingPageRecord { |
340 | $queryBuilder = $this->newSelectQueryBuilder( $queryFlags ) |
341 | ->conds( $conds ) |
342 | ->caller( __METHOD__ ); |
343 | |
344 | // @phan-suppress-next-line PhanTypeMismatchReturnSuperType |
345 | return $queryBuilder->fetchPageRecord(); |
346 | } |
347 | |
348 | /** |
349 | * @internal |
350 | * |
351 | * @param stdClass $row |
352 | * |
353 | * @return ExistingPageRecord |
354 | */ |
355 | public function newPageRecordFromRow( stdClass $row ): ExistingPageRecord { |
356 | return new PageStoreRecord( |
357 | $row, |
358 | $this->wikiId |
359 | ); |
360 | } |
361 | |
362 | /** |
363 | * @internal |
364 | * |
365 | * @return string[] |
366 | */ |
367 | public function getSelectFields(): array { |
368 | $fields = [ |
369 | 'page_id', |
370 | 'page_namespace', |
371 | 'page_title', |
372 | 'page_is_redirect', |
373 | 'page_is_new', |
374 | 'page_touched', |
375 | 'page_links_updated', |
376 | 'page_latest', |
377 | 'page_len', |
378 | 'page_content_model' |
379 | ]; |
380 | |
381 | if ( $this->options->get( MainConfigNames::PageLanguageUseDB ) ) { |
382 | $fields[] = 'page_lang'; |
383 | } |
384 | |
385 | // Since we are putting rows into LinkCache, we need to include all fields |
386 | // that LinkCache needs. |
387 | $fields = array_unique( |
388 | array_merge( $fields, LinkCache::getSelectFields() ) |
389 | ); |
390 | |
391 | return $fields; |
392 | } |
393 | |
394 | /** |
395 | * @unstable |
396 | * |
397 | * @param IReadableDatabase|int $dbOrFlags The database connection to use, or a READ_XXX constant |
398 | * indicating what kind of database connection to use. |
399 | * |
400 | * @return PageSelectQueryBuilder |
401 | */ |
402 | public function newSelectQueryBuilder( $dbOrFlags = IDBAccessObject::READ_NORMAL ): PageSelectQueryBuilder { |
403 | if ( $dbOrFlags instanceof IReadableDatabase ) { |
404 | $db = $dbOrFlags; |
405 | $flags = IDBAccessObject::READ_NORMAL; |
406 | } else { |
407 | if ( ( $dbOrFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
408 | $db = $this->dbLoadBalancer->getConnection( DB_PRIMARY, [], $this->wikiId ); |
409 | } else { |
410 | $db = $this->dbLoadBalancer->getConnection( DB_REPLICA, [], $this->wikiId ); |
411 | } |
412 | $flags = $dbOrFlags; |
413 | } |
414 | |
415 | $queryBuilder = new PageSelectQueryBuilder( $db, $this, $this->linkCache ); |
416 | $queryBuilder->recency( $flags ); |
417 | |
418 | return $queryBuilder; |
419 | } |
420 | |
421 | /** |
422 | * Get all subpages of this page. |
423 | * Will return an empty list of the namespace doesn't support subpages. |
424 | * |
425 | * @param PageIdentity $page |
426 | * @param int $limit Maximum number of subpages to fetch |
427 | * |
428 | * @return Iterator<ExistingPageRecord> |
429 | */ |
430 | public function getSubpages( PageIdentity $page, int $limit ): Iterator { |
431 | if ( !$this->namespaceInfo->hasSubpages( $page->getNamespace() ) ) { |
432 | return new EmptyIterator(); |
433 | } |
434 | |
435 | return $this->newSelectQueryBuilder() |
436 | ->whereTitlePrefix( $page->getNamespace(), $page->getDBkey() . '/' ) |
437 | ->orderByTitle() |
438 | ->limit( $limit ) |
439 | ->caller( __METHOD__ ) |
440 | ->fetchPageRecords(); |
441 | } |
442 | |
443 | } |