Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 475
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 475
0.00% covered (danger)
0.00%
0 / 3
18906
0.00% covered (danger)
0.00%
0 / 1
 onParserFirstCallInit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renderDynamicPageList
0.00% covered (danger)
0.00%
0 / 441
0.00% covered (danger)
0.00%
0 / 1
17292
 processQuery
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\DynamicPageList;
4
5use ImageGalleryBase;
6use MediaWiki\Hook\ParserFirstCallInitHook;
7use MediaWiki\MediaWikiServices;
8use MediaWiki\Parser\DateFormatter;
9use MediaWiki\Parser\Parser;
10use MediaWiki\Parser\ParserOptions;
11use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
12use MediaWiki\Registration\ExtensionRegistry;
13use MediaWiki\Title\Title;
14use MediaWiki\WikiMap\WikiMap;
15use PageImages\PageImages;
16use UnexpectedValueException;
17use Wikimedia\ObjectCache\WANObjectCache;
18use Wikimedia\Rdbms\Database;
19use Wikimedia\Rdbms\IExpression;
20use Wikimedia\Rdbms\IReadableDatabase;
21use Wikimedia\Rdbms\LikeValue;
22use Wikimedia\Rdbms\SelectQueryBuilder;
23
24class Hooks implements
25    ParserFirstCallInitHook
26{
27
28    /**
29     * Set up the <DynamicPageList> tag.
30     * @param Parser $parser
31     */
32    public function onParserFirstCallInit( $parser ) {
33        $parser->setHook( 'DynamicPageList', [ self::class, 'renderDynamicPageList' ] );
34    }
35
36    /**
37     * The callback function for converting the input text to HTML output
38     * @param string $input
39     * @param array $args
40     * @param Parser $mwParser
41     * @return string
42     */
43    public static function renderDynamicPageList( $input, $args, $mwParser ) {
44        global $wgDLPmaxCategories, $wgDLPMaxResultCount, $wgDLPMaxCacheTime,
45            $wgDLPAllowUnlimitedResults, $wgDLPAllowUnlimitedCategories;
46
47        if ( $wgDLPMaxCacheTime !== false ) {
48            $mwParser->getOutput()->updateCacheExpiry( $wgDLPMaxCacheTime );
49        }
50        $mwParser->addTrackingCategory( 'intersection-category' );
51
52        $countSet = false;
53        $count = 0;
54
55        $startList = '<ul>';
56        $endList = '</ul>';
57        $startItem = '<li>';
58        $endItem = '</li>';
59        $inlineMode = false;
60
61        $useGallery = false;
62        $pageImagesEnabled = ExtensionRegistry::getInstance()->isLoaded( 'PageImages' );
63        $galleryFileSize = false;
64        $galleryFileName = true;
65        $galleryImageHeight = 0;
66        $galleryImageWidth = 0;
67        $galleryNumbRows = 0;
68        $galleryCaption = '';
69        $gallery = null;
70
71        $orderMethod = 'categoryadd';
72        $order = 'descending';
73        $redirects = 'exclude';
74        $stable = 'include';
75        $quality = 'include';
76        $flaggedRevs = false;
77
78        $namespaceFiltering = false;
79        $namespaceIndex = 0;
80
81        $offset = 0;
82
83        $googleHack = false;
84
85        $suppressErrors = false;
86        $suppressPCErrors = false;
87        $showNamespace = true;
88        $addFirstCategoryDate = false;
89        $ignoreSubpages = false;
90        $dateFormat = '';
91        $stripYear = false;
92
93        $linkOptions = [];
94        $categories = [];
95        $excludeCategories = [];
96
97        $services = MediaWikiServices::getInstance();
98        $parser = $services->getParserFactory()->create();
99        $parser->setPage( $mwParser->getPage() );
100        $poptions = new ParserOptions( $mwParser->getUserIdentity() );
101
102        $contLang = $services->getContentLanguage();
103
104        $parameters = explode( "\n", $input );
105        foreach ( $parameters as $parameter ) {
106            $paramField = explode( '=', $parameter, 2 );
107            if ( count( $paramField ) < 2 ) {
108                continue;
109            }
110            $type = trim( $paramField[0] );
111            $arg = trim( $paramField[1] );
112            switch ( $type ) {
113                case 'category':
114                    $title = Title::makeTitleSafe(
115                        NS_CATEGORY,
116                        $parser->transformMsg( $arg, $poptions, $mwParser->getTitle() )
117                    );
118                    if ( $title !== null ) {
119                        $categories[] = $title;
120                    }
121                    break;
122                case 'notcategory':
123                    $title = Title::makeTitleSafe(
124                        NS_CATEGORY,
125                        $parser->transformMsg( $arg, $poptions, $mwParser->getTitle() )
126                    );
127                    if ( $title !== null ) {
128                        $excludeCategories[] = $title;
129                    }
130                    break;
131                case 'namespace':
132                    $ns = $contLang->getNsIndex( $arg );
133                    if ( $ns !== null ) {
134                        $namespaceIndex = $ns;
135                        $namespaceFiltering = true;
136                    } else {
137                        // Note, since intval("some string") = 0
138                        // this considers pretty much anything
139                        // invalid here as the main namespace.
140                        // This was probably originally a bug,
141                        // but is now depended upon by people
142                        // writing things like namespace=main
143                        // so be careful when changing this code.
144                        $namespaceIndex = intval( $arg );
145                        $namespaceFiltering = ( $namespaceIndex >= 0 );
146                    }
147                    break;
148                case 'count':
149                    // ensure that $count is a number;
150                    $count = intval( $arg );
151                    $countSet = true;
152                    break;
153                case 'offset':
154                    $offset = intval( $arg );
155                    break;
156                case 'imagewidth':
157                    $galleryImageWidth = intval( $arg );
158                    break;
159                case 'imageheight':
160                    $galleryImageHeight = intval( $arg );
161                    break;
162                case 'imagesperrow':
163                    $galleryNumbRows = intval( $arg );
164                    break;
165                case 'mode':
166                    switch ( $arg ) {
167                        case 'gallery':
168                            $useGallery = true;
169                            $gallery = ImageGalleryBase::factory();
170                            $gallery->setParser( $mwParser );
171                            $startList = '';
172                            $endList = '';
173                            $startItem = '';
174                            $endItem = '';
175                            break;
176                        case 'none':
177                            $startList = '';
178                            $endList = '';
179                            $startItem = '';
180                            $endItem = '<br />';
181                            $inlineMode = false;
182                            break;
183                        case 'ordered':
184                            $startList = '<ol>';
185                            $endList = '</ol>';
186                            $startItem = '<li>';
187                            $endItem = '</li>';
188                            $inlineMode = false;
189                            break;
190                        case 'inline':
191                            // aka comma separated list
192                            $startList = '';
193                            $endList = '';
194                            $startItem = '';
195                            $endItem = '';
196                            $inlineMode = true;
197                            break;
198                        case 'unordered':
199                        default:
200                            $startList = '<ul>';
201                            $endList = '</ul>';
202                            $startItem = '<li>';
203                            $endItem = '</li>';
204                            $inlineMode = false;
205                            break;
206                    }
207                    break;
208                case 'gallerycaption':
209                    // Should perhaps actually parse caption instead
210                    // as links and what not in caption might be useful.
211                    $galleryCaption = $parser->transformMsg( $arg, $poptions, $mwParser->getTitle() );
212                    break;
213                case 'galleryshowfilesize':
214                    if ( $arg == 'no' || $arg == 'false' ) {
215                        $galleryFileSize = false;
216                    } else {
217                        $galleryFileSize = true;
218                    }
219                    break;
220                case 'galleryshowfilename':
221                    if ( $arg == 'no' || $arg == 'false' ) {
222                        $galleryFileName = false;
223                    } else {
224                        $galleryFileName = true;
225                    }
226                    break;
227                case 'order':
228                    if ( $arg == 'ascending' ) {
229                        $order = 'ascending';
230                    } else {
231                        $order = 'descending';
232                    }
233                    break;
234                case 'ordermethod':
235                    switch ( $arg ) {
236                        case 'lastedit':
237                            $orderMethod = 'lastedit';
238                            break;
239                        case 'length':
240                            $orderMethod = 'length';
241                            break;
242                        case 'created':
243                            $orderMethod = 'created';
244                            break;
245                        case 'sortkey':
246                        case 'categorysortkey':
247                            $orderMethod = 'categorysortkey';
248                            break;
249                        case 'popularity':
250                            // no HitCounters since MW1.25
251                            $orderMethod = 'categoryadd';
252                            break;
253                        case 'categoryadd':
254                        default:
255                            $orderMethod = 'categoryadd';
256                            break;
257                    }
258                    break;
259                case 'redirects':
260                    switch ( $arg ) {
261                        case 'include':
262                            $redirects = 'include';
263                            break;
264                        case 'only':
265                            $redirects = 'only';
266                            break;
267                        case 'exclude':
268                        default:
269                            $redirects = 'exclude';
270                            break;
271                    }
272                    break;
273                case 'stablepages':
274                    switch ( $arg ) {
275                        case 'include':
276                            $stable = 'include';
277                            break;
278                        case 'only':
279                            $flaggedRevs = true;
280                            $stable = 'only';
281                            break;
282                        case 'exclude':
283                        default:
284                            $flaggedRevs = true;
285                            $stable = 'exclude';
286                            break;
287                    }
288                    break;
289                case 'qualitypages':
290                    switch ( $arg ) {
291                        case 'include':
292                            $quality = 'include';
293                            break;
294                        case 'only':
295                            $flaggedRevs = true;
296                            $quality = 'only';
297                            break;
298                        case 'exclude':
299                        default:
300                            $flaggedRevs = true;
301                            $quality = 'exclude';
302                            break;
303                    }
304                    break;
305                case 'suppresserrors':
306                    if ( $arg == 'true' || $arg === 'all' ) {
307                        $suppressErrors = true;
308                        if ( $arg === 'all' ) {
309                            $suppressPCErrors = true;
310                        }
311                    } else {
312                        $suppressErrors = false;
313                    }
314                    break;
315                case 'addfirstcategorydate':
316                    if ( $arg === 'true' ) {
317                        $addFirstCategoryDate = true;
318                    } elseif ( preg_match( '/^(?:[ymd]{2,3}|ISO 8601)$/', $arg ) ) {
319                        // if it more or less is valid dateformat.
320                        $addFirstCategoryDate = true;
321                        $dateFormat = $arg;
322                        if ( strlen( $dateFormat ) == 2 ) {
323                            # DateFormatter does not support no year. work around
324                            $dateFormat .= 'y';
325                            $stripYear = true;
326                        }
327                    } else {
328                        $addFirstCategoryDate = false;
329                    }
330                    break;
331                case 'shownamespace':
332                    $showNamespace = $arg !== 'false';
333                    break;
334                case 'ignoresubpages':
335                    $ignoreSubpages = ( $arg === 'true' );
336                    break;
337                case 'googlehack':
338                    $googleHack = $arg !== 'false';
339                    break;
340                case 'nofollow':
341                    # bug 6658
342                    if ( $arg !== 'false' ) {
343                        $linkOptions['rel'] = 'nofollow';
344                    }
345                    break;
346            }
347        }
348
349        $catCount = count( $categories );
350        $excludeCatCount = count( $excludeCategories );
351        $totalCatCount = $catCount + $excludeCatCount;
352
353        if ( $catCount < 1 && !$namespaceFiltering ) {
354            if ( $suppressErrors ) {
355                return '';
356            }
357
358            // "!!no included categories!!"
359            return wfMessage( 'intersection_noincludecats' )->inContentLanguage()->escaped();
360        }
361
362        if ( $totalCatCount > $wgDLPmaxCategories && !$wgDLPAllowUnlimitedCategories ) {
363            if ( $suppressErrors ) {
364                return '';
365            }
366
367            // "!!too many categories!!"
368            return wfMessage( 'intersection_toomanycats' )->inContentLanguage()->escaped();
369        }
370
371        if ( $countSet ) {
372            if ( $count < 1 ) {
373                $count = 1;
374            }
375            if ( $count > $wgDLPMaxResultCount ) {
376                $count = $wgDLPMaxResultCount;
377            }
378        } elseif ( !$wgDLPAllowUnlimitedResults ) {
379            $count = $wgDLPMaxResultCount;
380            $countSet = true;
381        }
382
383        // disallow showing date if the query doesn't have an inclusion category parameter
384        if ( $catCount < 1 ) {
385            $addFirstCategoryDate = false;
386            // don't sort by fields relating to categories if there are no categories.
387            if ( $orderMethod === 'categoryadd' || $orderMethod === 'categorysortkey' ) {
388                $orderMethod = 'created';
389            }
390        }
391
392        // build the SQL query
393        $dbr = $services->getConnectionProvider()->getReplicaDatabase( false, 'vslow' );
394        $queryBuilder = $dbr->newSelectQueryBuilder()
395            ->select( [ 'page_namespace', 'page_title' ] )
396            ->from( 'page' );
397
398        if ( $googleHack ) {
399            $queryBuilder->field( 'page_id' );
400        }
401
402        if ( $addFirstCategoryDate ) {
403            $queryBuilder->field( 'c1.cl_timestamp' );
404        }
405
406        if ( $namespaceFiltering ) {
407            $queryBuilder->where( [ 'page_namespace' => $namespaceIndex ] );
408        }
409
410        // Bug 14943 - Allow filtering based on FlaggedRevs stability.
411        // Check if the extension actually exists before changing the query...
412        if ( $flaggedRevs && ExtensionRegistry::getInstance()->isLoaded( 'FlaggedRevs' ) ) {
413            $queryBuilder->leftJoin( 'flaggedpages', null, 'page_id = fp_page_id' );
414
415            if ( $stable == 'only' ) {
416                $queryBuilder->where( $dbr->expr( 'fp_stable', '!=', null ) );
417            } elseif ( $stable == 'exclude' ) {
418                $queryBuilder->where( [ 'fp_stable' => null ] );
419            }
420
421            if ( $quality == 'only' ) {
422                $queryBuilder->where( $dbr->expr( 'fp_quality', '>=', 1 ) );
423            } elseif ( $quality == 'exclude' ) {
424                $queryBuilder->where( $dbr->expr( 'fp_quality', '=', 0 )->or( 'fp_quality', '=', null ) );
425            }
426        }
427
428        if ( $redirects == 'only' ) {
429            $queryBuilder->where( [ 'page_is_redirect' => 1 ] );
430        } elseif ( $redirects == 'exclude' ) {
431            $queryBuilder->where( [ 'page_is_redirect' => 0 ] );
432        }
433
434        if ( $ignoreSubpages ) {
435            $queryBuilder->where( $dbr->expr( 'page_title', IExpression::NOT_LIKE,
436                new LikeValue( $dbr->anyString(), '/', $dbr->anyString() ) ) );
437        }
438
439        if ( $useGallery && $pageImagesEnabled ) {
440            $queryBuilder->leftJoin( 'page_props', 'pp1', [
441                'pp1.pp_propname' => PageImages::PROP_NAME_FREE,
442                'page_id = pp1.pp_page'
443            ] );
444            $queryBuilder->field( 'pp1.pp_value', 'pageimage_free' );
445
446            $queryBuilder->leftJoin( 'page_props', 'pp2', [
447                'pp2.pp_propname' => PageImages::PROP_NAME,
448                'page_id = pp2.pp_page'
449            ] );
450            $queryBuilder->field( 'pp2.pp_value', 'pageimage_nonfree' );
451        }
452
453        // Alias each category as c1, c2, etc.
454        $currentTableNumber = 1;
455
456        foreach ( $categories as $cat ) {
457            $currentCategorylinksAlias = "c$currentTableNumber";
458            $currentLinktargetAlias = "lt$currentTableNumber";
459            $queryBuilder->join( 'categorylinks', $currentCategorylinksAlias, [
460                "page_id = {$currentCategorylinksAlias}.cl_from",
461            ] );
462            $queryBuilder->join( 'linktarget', $currentLinktargetAlias, [
463                "{$currentCategorylinksAlias}.cl_target_id = {$currentLinktargetAlias}.lt_id",
464            ] );
465            $queryBuilder->where( [
466                "{$currentLinktargetAlias}.lt_title" => $cat->getDBKey(),
467                "{$currentLinktargetAlias}.lt_namespace" => $cat->getNamespace(),
468            ] );
469            $currentTableNumber++;
470        }
471
472        foreach ( $excludeCategories as $cat ) {
473            $currentCategorylinksAlias = "c$currentTableNumber";
474            $subquery = $dbr->newSelectQueryBuilder()
475                ->select( 'cl_from' )
476                ->from( 'categorylinks' )
477                ->join( 'linktarget', null, [ 'cl_target_id = lt_id' ] )
478                ->where( [
479                    'lt_title' => $cat->getDBKey(),
480                    'lt_namespace' => $cat->getNamespace(),
481                ] )
482                ->caller( __METHOD__ );
483            $queryBuilder->leftJoin( $subquery, "excluded_pages{$currentTableNumber}", [
484                "page_id = excluded_pages{$currentTableNumber}.cl_from",
485            ] );
486            $queryBuilder->where( [ "excluded_pages{$currentTableNumber}.cl_from" => null ] );
487            $currentTableNumber++;
488        }
489
490        if ( $order === 'descending' ) {
491            $sqlOrder = SelectQueryBuilder::SORT_DESC;
492        } else {
493            $sqlOrder = SelectQueryBuilder::SORT_ASC;
494        }
495
496        switch ( $orderMethod ) {
497            case 'lastedit':
498                $queryBuilder->orderBy( 'page_touched', $sqlOrder );
499                break;
500            case 'length':
501                $queryBuilder->orderBy( 'page_len', $sqlOrder );
502                break;
503            case 'created':
504                // Since they're never reused and increasing
505                $queryBuilder->orderBy( 'page_id', $sqlOrder );
506                break;
507            case 'categorysortkey':
508                $queryBuilder->orderBy( [ 'c1.cl_type', 'c1.cl_sortkey' ], $sqlOrder );
509                break;
510            case 'categoryadd':
511                $queryBuilder->orderBy( 'c1.cl_timestamp', $sqlOrder );
512                break;
513            default:
514                // Should never reach here
515                throw new UnexpectedValueException( "Invalid ordermethod $orderMethod" );
516        }
517
518        if ( $countSet ) {
519            $queryBuilder->limit( $count );
520        }
521        if ( $offset > 0 ) {
522            $queryBuilder->offset( $offset );
523        }
524
525        // To track down what page offending queries are on.
526        // For some reason, $fname doesn't get escaped by our code?!
527        $pageName = str_replace( [ '*', '/' ], '-', $mwParser->getTitle()->getPrefixedDBkey() );
528        $rows = self::processQuery( $pageName, $dbr, $queryBuilder );
529        if ( $rows === false ) {
530            // This error path is very fast (We exit immediately if poolcounter is full)
531            // Thus it should be safe to try again in ~5 minutes.
532            $mwParser->getOutput()->updateCacheExpiry( 4 * 60 + mt_rand( 0, 120 ) );
533            // Pool counter all threads in use.
534            if ( $suppressPCErrors ) {
535                return '';
536            }
537            return wfMessage( 'intersection_pcerror' )->inContentLanguage()->escaped();
538        }
539        if ( count( $rows ) == 0 ) {
540            if ( $suppressErrors ) {
541                return '';
542            }
543
544            return wfMessage( 'intersection_noresults' )->inContentLanguage()->escaped();
545        }
546
547        $df = null;
548        if ( $dateFormat !== '' && $addFirstCategoryDate ) {
549            $df = DateFormatter::getInstance();
550        }
551
552        // process results of query, outputing equivalent of <li>[[Article]]</li>
553        // for each result, or something similar if the list uses other
554        // startlist/endlist
555        $articleList = [];
556        $linkRenderer = $services->getLinkRenderer();
557        foreach ( $rows as $row ) {
558            $title = Title::makeTitle( $row->page_namespace, $row->page_title );
559            $categoryDate = '';
560            if ( $addFirstCategoryDate ) {
561                if ( $dateFormat !== '' ) {
562                    // this is a tad ugly
563                    // use DateFormatter, and support discarding year.
564                    $categoryDate = wfTimestamp( TS_ISO_8601, $row->cl_timestamp );
565                    if ( $stripYear ) {
566                        $categoryDate = $contLang->getMonthName( (int)substr( $categoryDate, 5, 2 ) )
567                            . ' ' . substr( $categoryDate, 8, 2 );
568                    } else {
569                        $categoryDate = substr( $categoryDate, 0, 10 );
570                    }
571                    $categoryDate = $df->reformat( $dateFormat, $categoryDate, [ 'match-whole' ] );
572                } else {
573                    $categoryDate = $contLang->date( wfTimestamp( TS_MW, $row->cl_timestamp ) );
574                }
575                if ( $useGallery ) {
576                    $categoryDate .= ' ';
577                } else {
578                    $categoryDate .= wfMessage( 'colon-separator' )->text();
579                }
580            }
581
582            $query = [];
583            if ( $googleHack ) {
584                $query['dpl_id'] = intval( $row->page_id );
585            }
586
587            if ( $showNamespace ) {
588                $titleText = $title->getPrefixedText();
589            } else {
590                $titleText = $title->getText();
591            }
592
593            if ( $useGallery ) {
594                $link = '';
595                if ( $galleryFileName ) {
596                    $link = $linkRenderer->makeKnownLink(
597                        $title,
598                        $titleText,
599                        [ 'class' => 'galleryfilename galleryfilename-truncate' ]
600                    ) . "\n";
601                }
602
603                $file = null;
604                if ( $title->getNamespace() !== NS_FILE && $pageImagesEnabled ) {
605                    $file = $row->pageimage_free ?: $row->pageimage_nonfree;
606                }
607
608                // Note, $categoryDate is treated as raw html
609                // this is safe since the only html present
610                // would come from the dateformatter <span>.
611                if ( $file !== null ) {
612                    $gallery->add(
613                        Title::makeTitle( NS_FILE, $file ),
614                        $link . $categoryDate,
615                        $file,
616                        $title->getLinkURL()
617                    );
618                } else {
619                    $gallery->add(
620                        $title,
621                        $link . $categoryDate,
622                        $title->getText()
623                    );
624                }
625            } else {
626                // FIXME: per T17739 and T22818, forcearticlepath
627                // was used, this may be unnecessary nowadays
628                // depending on a full rollout of GoogleNewsSitemap
629                $articleList[] = htmlspecialchars( $categoryDate ) .
630                    MediaWikiServices::getInstance()->getLinkRendererFactory()
631                        ->createFromLegacyOptions( [ 'forcearticlepath' ] )->makeKnownLink(
632                            $title,
633                            $titleText,
634                            $linkOptions,
635                            $query
636                    );
637            }
638        }
639
640        if ( $useGallery ) {
641            $gallery->setHideBadImages();
642            $gallery->setShowFilename( false );
643            $gallery->setShowBytes( $galleryFileSize );
644            if ( $galleryImageHeight > 0 ) {
645                $gallery->setHeights( (string)$galleryImageHeight );
646            }
647            if ( $galleryImageWidth > 0 ) {
648                $gallery->setWidths( (string)$galleryImageWidth );
649            }
650            if ( $galleryNumbRows > 0 ) {
651                $gallery->setPerRow( $galleryNumbRows );
652            }
653            if ( $galleryCaption !== '' ) {
654                // gallery class escapes string
655                $gallery->setCaption( $galleryCaption );
656            }
657            return $gallery->toHtml();
658        }
659
660        // start unordered list
661        $output = $startList . "\n" . $startItem;
662        if ( $inlineMode ) {
663            $output .= $contLang->commaList( $articleList );
664        } else {
665            $output .= implode( "$endItem \n$startItem", $articleList );
666        }
667        $output .= $endItem . $endList . "\n";
668        // end unordered list
669
670        return $output;
671    }
672
673    /**
674     * @param string $pageName Name of page (for logging purposes)
675     * @param IReadableDatabase $dbr
676     * @param SelectQueryBuilder $queryBuilder
677     * @return array|bool List of stdObj's or false on poolcounter being full
678     */
679    public static function processQuery(
680        string $pageName,
681        IReadableDatabase $dbr,
682        SelectQueryBuilder $queryBuilder
683    ) {
684        global $wgDLPQueryCacheTime, $wgDLPMaxQueryTime;
685        $qname = __METHOD__ . ' - ' . $pageName;
686        if ( $wgDLPMaxQueryTime ) {
687            $queryBuilder->setMaxExecutionTime( $wgDLPMaxQueryTime );
688        }
689
690        $doQuery = static function () use ( $qname, $queryBuilder ) {
691            $res = $queryBuilder->caller( $qname )->fetchResultSet();
692            // Serializing a ResultWrapper doesn't work.
693            return iterator_to_array( $res );
694        };
695
696        // We're probably already inside a pool-counter lock due to parse, so nowait.
697        $poolCounterKey = "nowait:dpl-query:" . WikiMap::getCurrentWikiId();
698        // The goal here is to have an emergency shutoff break to prevent a query
699        // pile-up if a large number of slow DPL queries are run at once.
700        // This is meant to be in total across the wiki. The WANObjectCache stuff below this
701        // is meant to make the somewhat common case of the same DPL query being run multiple
702        // times due to template usage fast, where this is not meant to speed things up, but
703        // to have an emergency stop before things get out of hand.
704        // Recommended config is probably something like 15 workers normally and
705        // 5 workers if DB seems to have excessive load.
706        $worker = new PoolCounterWorkViaCallback( 'DPL', $poolCounterKey, [
707            'doWork' => $doQuery,
708        ] );
709
710        if ( $wgDLPQueryCacheTime <= 0 ) {
711            return $worker->execute();
712        }
713
714        // This is meant to guard against the case where a lot of pages get parsed at once
715        // all with the same query. See T262240. This should be a short cache, e.g. 120 seconds.
716        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
717        // Don't use actual input as hash, because some per-page parsing can happen to options.
718        $query = ( clone $queryBuilder )->getSQL();
719        return $cache->getWithSetCallback(
720            $cache->makeKey( "DPLQuery", hash( "sha256", $query ) ),
721            $wgDLPQueryCacheTime,
722            static function ( $oldVal, &$ttl, &$setOpts ) use ( $worker, $dbr ){
723                // TODO: Maybe could do something like check max(cl_timestamp) in
724                // category and the count in category.cat_pages, and invalidate if
725                // it appears like someone added or removed something from the category.
726                $setOpts += Database::getCacheSetOptions( $dbr );
727                $res = $worker->execute();
728                if ( $res === false ) {
729                    // Do not cache errors.
730                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
731                    // If we have oldVal, prefer it to error
732                    if ( is_array( $oldVal ) ) {
733                        return $oldVal;
734                    }
735                }
736                return $res;
737            },
738            [
739                'lowTTL' => min( $cache::TTL_MINUTE, floor( $wgDLPQueryCacheTime * 0.75 ) ),
740                'pcTTL' => min( $cache::TTL_PROC_LONG, $wgDLPQueryCacheTime )
741            ]
742        );
743    }
744
745}