Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
83.57% |
117 / 140 |
|
71.43% |
10 / 14 |
CRAP | |
0.00% |
0 / 1 |
ClassicInterwikiLookup | |
83.57% |
117 / 140 |
|
71.43% |
10 / 14 |
59.65 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
isValidInterwiki | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
fetch | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
invalidateCache | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getPregenValue | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
7.54 | |||
load | |
86.96% |
20 / 23 |
|
0.00% |
0 / 1 |
11.27 | |||
loadFromDB | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
makeFromRow | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
makeFromPregen | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getAllPrefixesPregenerated | |
92.00% |
23 / 25 |
|
0.00% |
0 / 1 |
10.05 | |||
buildCdbHash | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
getAllPrefixesDB | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
getAllPrefixes | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
selectFields | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 |
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 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Interwiki; |
22 | |
23 | use Interwiki; |
24 | use MapCacheLRU; |
25 | use MediaWiki\Config\ServiceOptions; |
26 | use MediaWiki\HookContainer\HookContainer; |
27 | use MediaWiki\HookContainer\HookRunner; |
28 | use MediaWiki\Language\Language; |
29 | use MediaWiki\Languages\LanguageNameUtils; |
30 | use MediaWiki\MainConfigNames; |
31 | use MediaWiki\WikiMap\WikiMap; |
32 | use Wikimedia\ObjectCache\WANObjectCache; |
33 | use Wikimedia\Rdbms\IConnectionProvider; |
34 | |
35 | /** |
36 | * InterwikiLookup backed by the `interwiki` database table or $wgInterwikiCache. |
37 | * |
38 | * By default this uses the SQL backend (`interwiki` database table) and includes |
39 | * two levels of caching. When parsing a wiki page, many interwiki lookups may |
40 | * be required and thus there is in-class caching for repeat lookups. To reduce |
41 | * database pressure, there is also WANObjectCache for each prefix. |
42 | * |
43 | * Optionally, a pregenerated dataset can be statically set via $wgInterwikiCache, |
44 | * in which case there are no calls to either database or WANObjectCache. |
45 | * |
46 | * @since 1.28 |
47 | */ |
48 | class ClassicInterwikiLookup implements InterwikiLookup { |
49 | /** |
50 | * @internal For use by ServiceWiring |
51 | */ |
52 | public const CONSTRUCTOR_OPTIONS = [ |
53 | MainConfigNames::InterwikiExpiry, |
54 | MainConfigNames::InterwikiCache, |
55 | MainConfigNames::InterwikiScopes, |
56 | MainConfigNames::InterwikiFallbackSite, |
57 | MainConfigNames::InterwikiMagic, |
58 | MainConfigNames::VirtualDomainsMapping, |
59 | 'wikiId', |
60 | ]; |
61 | |
62 | private ServiceOptions $options; |
63 | private Language $contLang; |
64 | private WANObjectCache $wanCache; |
65 | private HookRunner $hookRunner; |
66 | private IConnectionProvider $dbProvider; |
67 | private LanguageNameUtils $languageNameUtils; |
68 | |
69 | /** @var MapCacheLRU<Interwiki|false> */ |
70 | private MapCacheLRU $instances; |
71 | /** |
72 | * Specify number of domains to check for messages: |
73 | * - 1: Just local wiki level |
74 | * - 2: wiki and global levels |
75 | * - 3: site level as well as wiki and global levels |
76 | */ |
77 | private int $interwikiScopes; |
78 | /** Complete pregenerated data if available */ |
79 | private ?array $data; |
80 | private string $wikiId; |
81 | private ?string $thisSite = null; |
82 | private array $virtualDomainsMapping; |
83 | |
84 | /** |
85 | * @param ServiceOptions $options |
86 | * @param Language $contLang Language object used to convert prefixes to lower case |
87 | * @param WANObjectCache $wanCache Cache for interwiki info retrieved from the database |
88 | * @param HookContainer $hookContainer |
89 | * @param IConnectionProvider $dbProvider |
90 | * @param LanguageNameUtils $languageNameUtils |
91 | */ |
92 | public function __construct( |
93 | ServiceOptions $options, |
94 | Language $contLang, |
95 | WANObjectCache $wanCache, |
96 | HookContainer $hookContainer, |
97 | IConnectionProvider $dbProvider, |
98 | LanguageNameUtils $languageNameUtils |
99 | ) { |
100 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
101 | $this->options = $options; |
102 | |
103 | $this->contLang = $contLang; |
104 | $this->wanCache = $wanCache; |
105 | $this->hookRunner = new HookRunner( $hookContainer ); |
106 | $this->dbProvider = $dbProvider; |
107 | $this->languageNameUtils = $languageNameUtils; |
108 | |
109 | $this->instances = new MapCacheLRU( 1000 ); |
110 | $this->interwikiScopes = $options->get( MainConfigNames::InterwikiScopes ); |
111 | |
112 | $interwikiData = $options->get( MainConfigNames::InterwikiCache ); |
113 | $this->data = is_array( $interwikiData ) ? $interwikiData : null; |
114 | $this->wikiId = $options->get( 'wikiId' ); |
115 | $this->virtualDomainsMapping = $options->get( MainConfigNames::VirtualDomainsMapping ) ?? []; |
116 | } |
117 | |
118 | /** |
119 | * @inheritDoc |
120 | * @param string $prefix |
121 | * @return bool |
122 | */ |
123 | public function isValidInterwiki( $prefix ) { |
124 | $iw = $this->fetch( $prefix ); |
125 | return (bool)$iw; |
126 | } |
127 | |
128 | /** |
129 | * @inheritDoc |
130 | * @param string|null $prefix |
131 | * @return Interwiki|null|false |
132 | */ |
133 | public function fetch( $prefix ) { |
134 | if ( $prefix === null || $prefix === '' ) { |
135 | return null; |
136 | } |
137 | |
138 | $prefix = $this->contLang->lc( $prefix ); |
139 | |
140 | return $this->instances->getWithSetCallback( |
141 | $prefix, |
142 | function () use ( $prefix ) { |
143 | return $this->load( $prefix ); |
144 | } |
145 | ); |
146 | } |
147 | |
148 | /** |
149 | * Purge the instance cache and memcached for an interwiki prefix |
150 | * |
151 | * Note that memcached is not used when $wgInterwikiCache |
152 | * is enabled, as the pregenerated data will be used statically |
153 | * without need for memcached. |
154 | * |
155 | * @param string $prefix |
156 | */ |
157 | public function invalidateCache( $prefix ) { |
158 | $this->instances->clear( $prefix ); |
159 | |
160 | $key = $this->wanCache->makeKey( 'interwiki', $prefix ); |
161 | $this->wanCache->delete( $key ); |
162 | } |
163 | |
164 | /** |
165 | * Get value from pregenerated data |
166 | * |
167 | * @param string $prefix |
168 | * @return string|false The pregen value or false if prefix is not known |
169 | */ |
170 | private function getPregenValue( string $prefix ) { |
171 | // Lazily resolve site name |
172 | if ( $this->interwikiScopes >= 3 && !$this->thisSite ) { |
173 | $this->thisSite = $this->data['__sites:' . $this->wikiId] |
174 | ?? $this->options->get( MainConfigNames::InterwikiFallbackSite ); |
175 | } |
176 | |
177 | $value = $this->data[$this->wikiId . ':' . $prefix] ?? false; |
178 | // Site level |
179 | if ( $value === false && $this->interwikiScopes >= 3 ) { |
180 | $value = $this->data["_{$this->thisSite}:{$prefix}"] ?? false; |
181 | } |
182 | // Global level |
183 | if ( $value === false && $this->interwikiScopes >= 2 ) { |
184 | $value = $this->data["__global:{$prefix}"] ?? false; |
185 | } |
186 | |
187 | return $value; |
188 | } |
189 | |
190 | /** |
191 | * Fetch interwiki data and create an Interwiki object. |
192 | * |
193 | * Use pregenerated data if enabled. Otherwise try memcached first |
194 | * and fallback to a DB query. |
195 | * |
196 | * @param string $prefix The interwiki prefix |
197 | * @return Interwiki|false False is prefix is invalid |
198 | */ |
199 | private function load( $prefix ) { |
200 | if ( $this->data !== null ) { |
201 | $value = $this->getPregenValue( $prefix ); |
202 | return $value ? $this->makeFromPregen( $prefix, $value ) : false; |
203 | } |
204 | |
205 | $iwData = []; |
206 | $abort = !$this->hookRunner->onInterwikiLoadPrefix( $prefix, $iwData ); |
207 | if ( isset( $iwData['iw_url'] ) ) { |
208 | // Hook provided data |
209 | return $this->makeFromRow( $iwData ); |
210 | } |
211 | if ( $abort ) { |
212 | // Hook indicated no other source may be considered |
213 | return false; |
214 | } |
215 | |
216 | $iwData = $this->wanCache->getWithSetCallback( |
217 | $this->wanCache->makeKey( 'interwiki', $prefix ), |
218 | $this->options->get( MainConfigNames::InterwikiExpiry ), |
219 | function ( $oldValue, &$ttl, array &$setOpts ) use ( $prefix ) { |
220 | // Global interlanguage link |
221 | if ( $this->options->get( MainConfigNames::InterwikiMagic ) |
222 | && $this->languageNameUtils->getLanguageName( $prefix ) |
223 | ) { |
224 | $row = $this->loadFromDB( $prefix, 'virtual-interwiki-interlanguage' ); |
225 | } else { |
226 | // Local interwiki link |
227 | $row = $this->loadFromDB( $prefix ); |
228 | |
229 | // Global interwiki link (not interlanguage) |
230 | if ( !$row && isset( $this->virtualDomainsMapping['virtual-interwiki'] ) ) { |
231 | $row = $this->loadFromDB( $prefix, 'virtual-interwiki' ); |
232 | } |
233 | } |
234 | |
235 | return $row ? (array)$row : '!NONEXISTENT'; |
236 | } |
237 | ); |
238 | |
239 | // Handle non-existent case |
240 | return is_array( $iwData ) ? $this->makeFromRow( $iwData ) : false; |
241 | } |
242 | |
243 | /* |
244 | * Fetch interwiki data from a DB query. |
245 | * |
246 | * @param string $prefix The interwiki prefix |
247 | * @param string|false $domain Domain ID, or false for the current domain |
248 | * @return stdClass|false interwiki data |
249 | */ |
250 | private function loadFromDB( $prefix, $domain = false ) { |
251 | $dbr = $this->dbProvider->getReplicaDatabase( $domain ); |
252 | $row = $dbr->newSelectQueryBuilder() |
253 | ->select( self::selectFields() ) |
254 | ->from( 'interwiki' ) |
255 | ->where( [ 'iw_prefix' => $prefix ] ) |
256 | ->caller( __METHOD__ )->fetchRow(); |
257 | |
258 | return $row; |
259 | } |
260 | |
261 | /** |
262 | * @param array $row Row from the interwiki table, possibly via memcached |
263 | * @return Interwiki |
264 | */ |
265 | private function makeFromRow( array $row ) { |
266 | $url = $row['iw_url']; |
267 | $local = $row['iw_local'] ?? 0; |
268 | $trans = $row['iw_trans'] ?? 0; |
269 | $api = $row['iw_api'] ?? ''; |
270 | $wikiId = $row['iw_wikiid'] ?? ''; |
271 | |
272 | return new Interwiki( null, $url, $api, $wikiId, $local, $trans ); |
273 | } |
274 | |
275 | /** |
276 | * @param string $prefix |
277 | * @param string $value |
278 | * @return Interwiki |
279 | */ |
280 | private function makeFromPregen( string $prefix, string $value ) { |
281 | // Split values |
282 | [ $local, $url ] = explode( ' ', $value, 2 ); |
283 | return new Interwiki( $prefix, $url, '', '', (int)$local ); |
284 | } |
285 | |
286 | /** |
287 | * Fetch all interwiki prefixes from pregenerated data |
288 | * |
289 | * @param null|string $local |
290 | * @return array Database-like rows |
291 | */ |
292 | private function getAllPrefixesPregenerated( $local ) { |
293 | // Lazily resolve site name |
294 | if ( $this->interwikiScopes >= 3 && !$this->thisSite ) { |
295 | $this->thisSite = $this->data['__sites:' . $this->wikiId] |
296 | ?? $this->options->get( MainConfigNames::InterwikiFallbackSite ); |
297 | } |
298 | |
299 | // List of interwiki sources |
300 | $sources = []; |
301 | // Global level |
302 | if ( $this->interwikiScopes >= 2 ) { |
303 | $sources[] = '__global'; |
304 | } |
305 | // Site level |
306 | if ( $this->interwikiScopes >= 3 ) { |
307 | $sources[] = '_' . $this->thisSite; |
308 | } |
309 | $sources[] = $this->wikiId; |
310 | |
311 | $data = []; |
312 | foreach ( $sources as $source ) { |
313 | $list = $this->data['__list:' . $source] ?? ''; |
314 | foreach ( explode( ' ', $list ) as $iw_prefix ) { |
315 | $row = $this->data["{$source}:{$iw_prefix}"] ?? null; |
316 | if ( !$row ) { |
317 | continue; |
318 | } |
319 | |
320 | [ $iw_local, $iw_url ] = explode( ' ', $row ); |
321 | |
322 | if ( $local !== null && $local != $iw_local ) { |
323 | continue; |
324 | } |
325 | |
326 | $data[$iw_prefix] = [ |
327 | 'iw_prefix' => $iw_prefix, |
328 | 'iw_url' => $iw_url, |
329 | 'iw_local' => $iw_local, |
330 | ]; |
331 | } |
332 | } |
333 | |
334 | return array_values( $data ); |
335 | } |
336 | |
337 | /** |
338 | * Build an array in the format accepted by $wgInterwikiCache. |
339 | * |
340 | * Given the array returned by getAllPrefixes(), build a PHP array which |
341 | * can be given to self::__construct() as $interwikiData, i.e. as the |
342 | * value of $wgInterwikiCache. This is used to construct mock |
343 | * interwiki lookup services for testing (in particular, parsertests). |
344 | * |
345 | * @param array $allPrefixes An array of interwiki information such as |
346 | * would be returned by ::getAllPrefixes() |
347 | * @param int $scope The scope at which to insert interwiki prefixes. |
348 | * See the $interwikiScopes parameter to ::__construct(). |
349 | * @param ?string $thisSite The value of $thisSite, if $scope is 3. |
350 | * @return array |
351 | */ |
352 | public static function buildCdbHash( |
353 | array $allPrefixes, int $scope = 1, ?string $thisSite = null |
354 | ): array { |
355 | $result = []; |
356 | $wikiId = WikiMap::getCurrentWikiId(); |
357 | $keyPrefix = ( $scope >= 2 ) ? '__global' : $wikiId; |
358 | if ( $scope >= 3 && $thisSite ) { |
359 | $result[ "__sites:$wikiId" ] = $thisSite; |
360 | $keyPrefix = "_$thisSite"; |
361 | } |
362 | $list = []; |
363 | foreach ( $allPrefixes as $iwInfo ) { |
364 | $prefix = $iwInfo['iw_prefix']; |
365 | $result["$keyPrefix:$prefix"] = implode( ' ', [ |
366 | $iwInfo['iw_local'] ?? 0, $iwInfo['iw_url'] |
367 | ] ); |
368 | $list[] = $prefix; |
369 | } |
370 | $result["__list:$keyPrefix"] = implode( ' ', $list ); |
371 | $result["__list:__sites"] = $wikiId; |
372 | return $result; |
373 | } |
374 | |
375 | /** |
376 | * Fetch all interwiki prefixes from DB |
377 | * |
378 | * @param bool|null $local |
379 | * @return array[] Database rows |
380 | */ |
381 | private function getAllPrefixesDB( $local ) { |
382 | $where = []; |
383 | if ( $local !== null ) { |
384 | $where['iw_local'] = (int)$local; |
385 | } |
386 | |
387 | $dbr = $this->dbProvider->getReplicaDatabase(); |
388 | $res = $dbr->newSelectQueryBuilder() |
389 | ->select( self::selectFields() ) |
390 | ->from( 'interwiki' ) |
391 | ->where( $where ) |
392 | ->orderBy( 'iw_prefix' ) |
393 | ->caller( __METHOD__ )->fetchResultSet(); |
394 | |
395 | $retval = []; |
396 | foreach ( $res as $row ) { |
397 | $retval[] = (array)$row; |
398 | } |
399 | return $retval; |
400 | } |
401 | |
402 | /** |
403 | * Fetch all interwiki data |
404 | * |
405 | * @param string|null $local If set, limit returned data to local or non-local interwikis |
406 | * @return array[] Database-like interwiki rows |
407 | */ |
408 | public function getAllPrefixes( $local = null ) { |
409 | if ( $this->data !== null ) { |
410 | return $this->getAllPrefixesPregenerated( $local ); |
411 | } else { |
412 | return $this->getAllPrefixesDB( $local ); |
413 | } |
414 | } |
415 | |
416 | /** |
417 | * List of interwiki table fields to select. |
418 | * |
419 | * @return string[] |
420 | */ |
421 | private static function selectFields() { |
422 | return [ |
423 | 'iw_prefix', |
424 | 'iw_url', |
425 | 'iw_api', |
426 | 'iw_wikiid', |
427 | 'iw_local', |
428 | 'iw_trans' |
429 | ]; |
430 | } |
431 | |
432 | } |