Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.11% covered (warning)
84.11%
90 / 107
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
LinkBatch
85.71% covered (warning)
85.71%
90 / 105
66.67% covered (warning)
66.67%
10 / 15
37.37
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 setCaller
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addUser
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addObj
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
4.73
 add
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 setArray
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isEmpty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageIdentities
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 executeInto
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 addResultToCache
86.84% covered (warning)
86.84%
33 / 38
0.00% covered (danger)
0.00%
0 / 1
7.11
 doQuery
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 doGenderQuery
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 constructSet
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
2.31
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Page;
8
9use InvalidArgumentException;
10use MediaWiki\Cache\GenderCache;
11use MediaWiki\Language\Language;
12use MediaWiki\Linker\LinksMigration;
13use MediaWiki\Linker\LinkTarget;
14use MediaWiki\Title\TitleFormatter;
15use MediaWiki\Title\TitleValue;
16use MediaWiki\User\TempUser\TempUserDetailsLookup;
17use MediaWiki\User\UserIdentity;
18use Psr\Log\LoggerInterface;
19use RuntimeException;
20use Wikimedia\Assert\Assert;
21use Wikimedia\Rdbms\IConnectionProvider;
22use Wikimedia\Rdbms\IResultWrapper;
23use Wikimedia\Rdbms\Platform\ISQLPlatform;
24
25/**
26 * Batch query for page metadata and feed to LinkCache.
27 *
28 * Use via MediaWikiServices::getLinkBatchFactory()->newLinkBatch(), and
29 * then call LinkBatch::execute().
30 *
31 * @see docs/LinkCache.md
32 * @see MediaWiki\Page\LinkCache
33 * @since 1.6
34 * @ingroup Page
35 */
36class LinkBatch {
37    /**
38     * @var array<int,array<string,mixed>> 2-d array, first index namespace, second index dbkey, value arbitrary
39     */
40    public $data = [];
41
42    /**
43     * @var UserIdentity[] Users to preload temporary account expiration status for
44     */
45    private array $users = [];
46
47    /**
48     * @var ProperPageIdentity[]|null page identity objects corresponding to the links in the batch
49     */
50    private $pageIdentities = null;
51
52    /**
53     * @var string|null For debugging which method is using this class.
54     */
55    protected $caller;
56
57    /**
58     * @var LinkCache
59     */
60    private $linkCache;
61
62    /**
63     * @var TitleFormatter
64     */
65    private $titleFormatter;
66
67    /**
68     * @var Language
69     */
70    private $contentLanguage;
71
72    /**
73     * @var GenderCache
74     */
75    private $genderCache;
76
77    /**
78     * @var IConnectionProvider
79     */
80    private $dbProvider;
81
82    /** @var LinksMigration */
83    private $linksMigration;
84
85    private TempUserDetailsLookup $tempUserDetailsLookup;
86
87    /** @var LoggerInterface */
88    private $logger;
89
90    /**
91     * @see \MediaWiki\Page\LinkBatchFactory
92     *
93     * @internal
94     * @param iterable<LinkTarget>|iterable<PageReference> $arr Initial titles to be added to the batch
95     * @param LinkCache $linkCache
96     * @param TitleFormatter $titleFormatter
97     * @param Language $contentLanguage
98     * @param GenderCache $genderCache
99     * @param IConnectionProvider $dbProvider
100     * @param LinksMigration $linksMigration
101     * @param TempUserDetailsLookup $tempUserDetailsLookup
102     * @param LoggerInterface $logger
103     */
104    public function __construct(
105        iterable $arr,
106        LinkCache $linkCache,
107        TitleFormatter $titleFormatter,
108        Language $contentLanguage,
109        GenderCache $genderCache,
110        IConnectionProvider $dbProvider,
111        LinksMigration $linksMigration,
112        TempUserDetailsLookup $tempUserDetailsLookup,
113        LoggerInterface $logger
114    ) {
115        $this->linkCache = $linkCache;
116        $this->titleFormatter = $titleFormatter;
117        $this->contentLanguage = $contentLanguage;
118        $this->genderCache = $genderCache;
119        $this->dbProvider = $dbProvider;
120        $this->linksMigration = $linksMigration;
121        $this->tempUserDetailsLookup = $tempUserDetailsLookup;
122        $this->logger = $logger;
123
124        foreach ( $arr as $item ) {
125            $this->addObj( $item );
126        }
127    }
128
129    /**
130     * Set the function name to attribute database queries to, in debug logs.
131     *
132     * @see Wikimedia\Rdbms\SelectQueryBuilder::caller
133     * @since 1.17
134     * @param string $caller
135     * @return self (since 1.32)
136     */
137    public function setCaller( $caller ): self {
138        $this->caller = $caller;
139        return $this;
140    }
141
142    /**
143     * Add user page and user talk page for a given user to this batch.
144     *
145     * Calling {@link execute} will also prefetch the expiration status of temporary accounts
146     * added this way, which is needed for the efficient rendering of user links via UserLinkRenderer.
147     *
148     * @since 1.44
149     */
150    public function addUser( UserIdentity $user ): void {
151        $this->users[$user->getName()] = $user;
152
153        $this->add( NS_USER, $user->getName() );
154        $this->add( NS_USER_TALK, $user->getName() );
155    }
156
157    /**
158     * @param LinkTarget|PageReference $link
159     */
160    public function addObj( $link ) {
161        if ( !$link ) {
162            // Don't die if we got null, just skip. There is nothing to do anyway.
163            // For now, let's avoid things like T282180. We should be more strict in the future.
164            $this->logger->warning(
165                'Skipping null link, probably due to a bad title.',
166                [ 'exception' => new RuntimeException() ]
167            );
168            return;
169        }
170        if ( $link instanceof LinkTarget && $link->isExternal() ) {
171            $this->logger->warning(
172                'Skipping interwiki link',
173                [ 'exception' => new RuntimeException() ]
174            );
175            return;
176        }
177
178        Assert::parameterType( [ LinkTarget::class, PageReference::class ], $link, '$link' );
179        $this->add( $link->getNamespace(), $link->getDBkey() );
180    }
181
182    /**
183     * @param int $ns
184     * @param string $dbkey
185     */
186    public function add( $ns, $dbkey ) {
187        if ( $ns < 0 || $dbkey === '' ) {
188            // T137083
189            return;
190        }
191        $this->data[$ns][strtr( $dbkey, ' ', '_' )] = 1;
192    }
193
194    /**
195     * Replace the link batch with a given 2-d array.
196     *
197     * First key is the namespace, second is the DB key, value arbitrary
198     *
199     * @param array<int,array<string,mixed>> $array
200     */
201    public function setArray( $array ) {
202        $this->data = $array;
203    }
204
205    /**
206     * Whether no pages have been added.
207     *
208     * @return bool
209     */
210    public function isEmpty() {
211        return $this->getSize() == 0;
212    }
213
214    /**
215     * Return the size of the batch.
216     *
217     * @return int
218     */
219    public function getSize() {
220        return count( $this->data );
221    }
222
223    /**
224     * Do the query and add the results to the LinkCache
225     *
226     * @return int[] Remaining unknown titles from PDBK to ID
227     */
228    public function execute() {
229        return $this->executeInto( $this->linkCache );
230    }
231
232    /**
233     * Do the query, add the results to the LinkCache object,
234     * and return ProperPageIdentity instances corresponding to the pages in the batch.
235     *
236     * @since 1.37
237     * @return ProperPageIdentity[] A list of ProperPageIdentities
238     */
239    public function getPageIdentities(): array {
240        if ( $this->pageIdentities === null ) {
241            $this->execute();
242        }
243
244        return $this->pageIdentities;
245    }
246
247    /**
248     * Do the query and add the results to a given LinkCache object
249     *
250     * @param LinkCache $cache
251     * @return int[] Remaining unknown titles from PDBK to ID
252     */
253    protected function executeInto( $cache ) {
254        $res = $this->doQuery();
255        $this->doGenderQuery();
256
257        // Prefetch expiration status for temporary accounts added to this batch via addUser()
258        // for efficient user link rendering (T358469).
259        if ( count( $this->users ) > 0 ) {
260            $this->tempUserDetailsLookup->preloadExpirationStatus( $this->users );
261        }
262
263        return $this->addResultToCache( $cache, $res );
264    }
265
266    /**
267     * Add a database result with page rows to the LinkCache.
268     *
269     * As normal, titles will go into the static Title cache field.
270     * This function *also* stores extra fields of the title used for link
271     * parsing to avoid extra DB queries.
272     *
273     * @param LinkCache $cache
274     * @param IResultWrapper $res
275     * @return int[] Remaining unknown titles from PDBK to ID
276     */
277    public function addResultToCache( $cache, $res ) {
278        if ( !$res ) {
279            return [];
280        }
281
282        $this->pageIdentities ??= [];
283
284        $ids = [];
285        $remaining = $this->data;
286
287        // For each returned entry, add it to the list of good links, and remove it from $remaining
288        foreach ( $res as $row ) {
289            try {
290                $title = new TitleValue( (int)$row->page_namespace, $row->page_title );
291
292                $cache->addGoodLinkObjFromRow( $title, $row );
293                $pdbk = $this->titleFormatter->getPrefixedDBkey( $title );
294                $ids[$pdbk] = $row->page_id;
295
296                $pageIdentity = PageIdentityValue::localIdentity(
297                    (int)$row->page_id,
298                    (int)$row->page_namespace,
299                    $row->page_title
300                );
301
302                $key = CacheKeyHelper::getKeyForPage( $pageIdentity );
303                $this->pageIdentities[$key] = $pageIdentity;
304            } catch ( InvalidArgumentException ) {
305                $this->logger->warning(
306                    'Encountered invalid title',
307                    [ 'title_namespace' => $row->page_namespace, 'title_dbkey' => $row->page_title ]
308                );
309            }
310
311            unset( $remaining[$row->page_namespace][$row->page_title] );
312        }
313
314        // The remaining links in $data are bad links, register them as such
315        foreach ( $remaining as $ns => $dbkeys ) {
316            foreach ( $dbkeys as $dbkey => $unused ) {
317                try {
318                    $title = new TitleValue( (int)$ns, (string)$dbkey );
319
320                    $cache->addBadLinkObj( $title );
321                    $pdbk = $this->titleFormatter->getPrefixedDBkey( $title );
322                    $ids[$pdbk] = 0;
323
324                    $pageIdentity = PageIdentityValue::localIdentity( 0, (int)$ns, $dbkey );
325                    $key = CacheKeyHelper::getKeyForPage( $pageIdentity );
326                    $this->pageIdentities[$key] = $pageIdentity;
327                } catch ( InvalidArgumentException ) {
328                    $this->logger->warning(
329                        'Encountered invalid title',
330                        [ 'title_namespace' => $ns, 'title_dbkey' => $dbkey ]
331                    );
332                }
333            }
334        }
335
336        return $ids;
337    }
338
339    /**
340     * Perform the existence test query
341     *
342     * @return IResultWrapper|false Result wrapper with page_id fields
343     */
344    public function doQuery() {
345        if ( $this->isEmpty() ) {
346            return false;
347        }
348
349        $caller = __METHOD__;
350        if ( strval( $this->caller ) !== '' ) {
351            $caller .= " (for {$this->caller})";
352        }
353
354        // This is similar to LinkHolderArray::replaceInternal
355        $dbr = $this->dbProvider->getReplicaDatabase();
356        return $dbr->newSelectQueryBuilder()
357            ->select( LinkCache::getSelectFields() )
358            ->from( 'page' )
359            ->where( $this->constructSet( 'page', $dbr ) )
360            ->caller( $caller )
361            ->fetchResultSet();
362    }
363
364    /**
365     * Execute and cache `{{GENDER:...}}` information for user pages in this LinkBatch
366     *
367     * @return bool Whether the query was successful
368     */
369    public function doGenderQuery() {
370        if ( $this->isEmpty() || !$this->contentLanguage->needsGenderDistinction() ) {
371            return false;
372        }
373
374        $this->genderCache->doLinkBatch( $this->data, $this->caller );
375
376        return true;
377    }
378
379    /**
380     * Construct a WHERE clause which will match all the given titles.
381     *
382     * It is the caller's responsibility to only call this if the LinkBatch is
383     * not empty, because there is no safe way to represent a SQL conditional
384     * for the empty set.
385     *
386     * @param string $prefix The appropriate table's field name prefix ('page', 'pl', etc)
387     * @param ISQLPlatform $db DB object to use
388     * @return string String with SQL where clause fragment
389     */
390    public function constructSet( $prefix, $db ) {
391        if ( isset( $this->linksMigration::$prefixToTableMapping[$prefix] ) ) {
392            [ $blNamespace, $blTitle ] = $this->linksMigration->getTitleFields(
393                $this->linksMigration::$prefixToTableMapping[$prefix]
394            );
395        } else {
396            $blNamespace = "{$prefix}_namespace";
397            $blTitle = "{$prefix}_title";
398        }
399        return $db->makeWhereFrom2d( $this->data, $blNamespace, $blTitle );
400    }
401}
402
403/** @deprecated class alias since 1.42 */
404class_alias( LinkBatch::class, 'LinkBatch' );
405
406/** @deprecated class alias since 1.45 */
407class_alias( LinkBatch::class, 'MediaWiki\Cache\LinkBatch' );