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