Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.26% covered (warning)
59.26%
80 / 135
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageStore
59.26% covered (warning)
59.26%
80 / 135
71.43% covered (warning)
71.43%
10 / 14
112.17
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 incrementStats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageForLink
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getPageByName
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getPageByNameViaLinkCache
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
42
 getPageByText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getExistingPageByText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getPageById
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getPageByReference
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 loadPageFromConditions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 newPageRecordFromRow
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getSelectFields
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 newSelectQueryBuilder
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getSubpages
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Page;
4
5use EmptyIterator;
6use IDBAccessObject;
7use InvalidArgumentException;
8use Iterator;
9use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
10use MediaWiki\Cache\LinkCache;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\DAO\WikiAwareEntity;
13use MediaWiki\MainConfigNames;
14use MediaWiki\Title\MalformedTitleException;
15use MediaWiki\Title\NamespaceInfo;
16use MediaWiki\Title\TitleParser;
17use NullStatsdDataFactory;
18use stdClass;
19use Wikimedia\Assert\Assert;
20use Wikimedia\Parsoid\Core\LinkTarget as ParsoidLinkTarget;
21use Wikimedia\Rdbms\IDatabase;
22use Wikimedia\Rdbms\ILoadBalancer;
23use Wikimedia\Rdbms\IReadableDatabase;
24
25/**
26 * @since 1.36
27 * @unstable
28 */
29class 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}