Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
42.86% covered (danger)
42.86%
84 / 196
22.73% covered (danger)
22.73%
5 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Util
42.86% covered (danger)
42.86%
84 / 196
22.73% covered (danger)
22.73%
5 / 22
1274.17
0.00% covered (danger)
0.00%
0 / 1
 getNamespaceText
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 recordPoolStats
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 wrapWithPoolStats
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 doPoolCounterWork
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 parsePotentialPercent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 parseSettingsInMessage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 overrideYesNo
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 overrideNumeric
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
72
 getDefaultBoostTemplates
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getOnWikiBoostTemplates
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
5.02
 stripQuestionMarks
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 getExecutionId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 resetExecutionId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequestSetToken
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 stripPrivateIps
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 generateIdentToken
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getExecutionContext
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 isEmpty
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
7
 setIfDefined
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 getStatsFactory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 looksLikeAutomation
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
6.29
 processSearchRawReturn
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace CirrusSearch;
4
5use InvalidArgumentException;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\Exception\MWException;
8use MediaWiki\Logger\LoggerFactory;
9use MediaWiki\MediaWikiServices;
10use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
11use MediaWiki\Request\WebRequest;
12use MediaWiki\Status\Status;
13use MediaWiki\Title\Title;
14use MediaWiki\Title\TitleFormatter;
15use MediaWiki\User\UserIdentity;
16use MediaWiki\WikiMap\WikiMap;
17use Wikimedia\IPUtils;
18use Wikimedia\Stats\StatsFactory;
19use Wikimedia\UUID\GlobalIdGenerator;
20
21/**
22 * Random utility functions that don't have a better home
23 *
24 * @license GPL-2.0-or-later
25 */
26class Util {
27    /**
28     * Cache getDefaultBoostTemplates()
29     *
30     * @var array|null boost templates
31     */
32    private static $defaultBoostTemplates = null;
33
34    /**
35     * @var string|null Id identifying this php execution
36     */
37    private static $executionId;
38
39    /**
40     * Get the textual representation of a namespace with underscores stripped, varying
41     * by gender if need be.
42     *
43     * @param Title $title The page title to use
44     * @param TitleFormatter|null $formatter When provided, resolve the namespace text via
45     *  the injected formatter instead of Title::getNsText(); preferred in contexts where
46     *  global state should be avoided.
47     * @return string|false
48     */
49    public static function getNamespaceText( Title $title, ?TitleFormatter $formatter = null ) {
50        if ( $formatter === null ) {
51            $ret = $title->getNsText();
52        } else {
53            try {
54                $ret = $formatter->getNamespaceName( $title->getNamespace(), $title->getDBkey() );
55            } catch ( InvalidArgumentException $ex ) {
56                wfDebug( __METHOD__ . ': ' . $ex->getMessage() );
57                $ret = false;
58            }
59        }
60        return is_string( $ret ) ? strtr( $ret, '_', ' ' ) : $ret;
61    }
62
63    /**
64     * Set label and statsd BC setup for pool counter metrics
65     * @param string $type The pool counter type, such as CirrusSearch-Search
66     * @param bool $isSuccess If the pool counter gave a success, or failed the request
67     * @param float $observation the time it took to update the counter
68     * @return void
69     */
70    private static function recordPoolStats( string $type, bool $isSuccess, float $observation ): void {
71        $pos = strpos( $type, '-' );
72        if ( $pos !== false ) {
73            $type = substr( $type, $pos + 1 );
74        }
75        self::getStatsFactory()
76            ->getTiming( "pool_counter_seconds" )
77            ->setLabel( "type", $type )
78            ->setLabel( "status", $isSuccess ? "success" : "failure" )
79            ->observe( $observation );
80    }
81
82    /**
83     * @param float $startPoolWork The time this pool request started, from microtime( true )
84     * @param string $type The pool counter type, such as CirrusSearch-Search
85     * @param bool $isSuccess If the pool counter gave a success, or failed the request
86     * @param callable $callback The function to wrap
87     * @return callable The original callback wrapped to collect pool counter stats
88     */
89    private static function wrapWithPoolStats( $startPoolWork,
90        $type,
91        $isSuccess,
92        callable $callback
93    ) {
94        return function ( ...$args ) use ( $type, $isSuccess, $callback, $startPoolWork ) {
95            self::recordPoolStats(
96                $type,
97                $isSuccess,
98                1000 * ( microtime( true ) - $startPoolWork ) );
99
100            return $callback( ...$args );
101        };
102    }
103
104    /**
105     * Wraps the complex pool counter interface to force the single call pattern
106     * that Cirrus always uses.
107     *
108     * @param string $type same as type parameter on PoolCounter::factory
109     * @param UserIdentity|null $user
110     * @param callable $workCallback callback when pool counter is acquired.  Called with
111     *  no parameters.
112     * @param string|null $busyErrorMsg The i18n key to return when the queue
113     *  is full, or null to use the default.
114     * @return mixed
115     */
116    public static function doPoolCounterWork( $type, $user, $workCallback, $busyErrorMsg = null ) {
117        global $wgCirrusSearchPoolCounterKey;
118
119        // By default the pool counter allows you to lock the same key with
120        // multiple types.  That might be useful but it isn't how Cirrus thinks.
121        // Instead, all keys are scoped to their type.
122
123        if ( !$user ) {
124            // We don't want to even use the pool counter if there isn't a user.
125            // Note that anonymous users are still users, this is most likely
126            // maintenance scripts.
127            // @todo Maintenenace scripts and jobs should already override
128            // poolcounters as necessary, can this be removed?
129            return $workCallback();
130        }
131
132        $key = "$type:$wgCirrusSearchPoolCounterKey";
133
134        $errorCallback = static function ( Status $status ) use ( $key, $busyErrorMsg ) {
135            $error = $status->getMessages()[0]->getKey();
136
137            LoggerFactory::getInstance( LogChannel::DEFAULT )->warning(
138                "Pool error on {key}:  {error}",
139                [ 'key' => $key, 'error' => $error ]
140            );
141            if ( $error === 'pool-queuefull' ) {
142                return Status::newFatal( $busyErrorMsg ?: 'cirrussearch-too-busy-error' );
143            }
144            return Status::newFatal( 'cirrussearch-backend-error' );
145        };
146
147        // wrap some stats collection on the success/failure handlers
148        $startPoolWork = microtime( true );
149        $workCallback = self::wrapWithPoolStats( $startPoolWork, $type, true, $workCallback );
150        $errorCallback = self::wrapWithPoolStats( $startPoolWork, $type, false, $errorCallback );
151
152        $work = new PoolCounterWorkViaCallback( $type, $key, [
153            'doWork' => $workCallback,
154            'error' => $errorCallback,
155        ] );
156        return $work->execute();
157    }
158
159    /**
160     * @param string $str
161     * @return float
162     */
163    public static function parsePotentialPercent( $str ) {
164        $result = floatval( $str );
165        if ( strpos( $str, '%' ) === false ) {
166            return $result;
167        }
168        return $result / 100;
169    }
170
171    /**
172     * Parse a message content into an array. This function is generally used to
173     * parse settings stored as i18n messages (see cirrussearch-boost-templates).
174     *
175     * @param string $message
176     * @return string[]
177     */
178    public static function parseSettingsInMessage( $message ) {
179        $lines = explode( "\n", $message );
180        $lines = preg_replace( '/#.*$/', '', $lines ); // Remove comments
181        $lines = array_map( 'trim', $lines );          // Remove extra spaces
182        $lines = array_filter( $lines );               // Remove empty lines
183        return $lines;
184    }
185
186    /**
187     * Set $dest to the true/false from $request->getVal( $name ) if yes/no.
188     *
189     * @param mixed &$dest
190     * @param WebRequest $request
191     * @param string $name
192     */
193    public static function overrideYesNo( &$dest, $request, $name ) {
194        $val = $request->getVal( $name );
195        if ( $val !== null ) {
196            $dest = wfStringToBool( $val );
197        }
198    }
199
200    /**
201     * Set $dest to the numeric value from $request->getVal( $name ) if it is <= $limit
202     * or => $limit if upperLimit is false.
203     *
204     * @param mixed &$dest
205     * @param WebRequest $request
206     * @param string $name
207     * @param int|null $limit
208     * @param bool $upperLimit
209     */
210    public static function overrideNumeric( &$dest, $request, $name, $limit = null, $upperLimit = true ) {
211        $val = $request->getVal( $name );
212        if ( $val !== null && is_numeric( $val ) ) {
213            if ( $limit === null ) {
214                $dest = $val;
215            } elseif ( $upperLimit && $val <= $limit ) {
216                $dest = $val;
217            } elseif ( !$upperLimit && $val >= $limit ) {
218                $dest = $val;
219            }
220        }
221    }
222
223    /**
224     * Get boost templates configured in messages.
225     * @param SearchConfig|null $config Search config requesting the templates
226     * @return float[]
227     */
228    public static function getDefaultBoostTemplates( ?SearchConfig $config = null ) {
229        $config ??= MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( CirrusSearch::NAME );
230
231        $fromConfig = $config->get( CirrusConfigNames::BoostTemplates );
232        if ( $config->get( CirrusConfigNames::IgnoreOnWikiBoostTemplates ) ) {
233            // on wiki messages disabled, we can return this config
234            // directly
235            return $fromConfig;
236        }
237
238        $fromMessage = self::getOnWikiBoostTemplates( $config );
239        if ( !$fromMessage ) {
240            // the onwiki config is empty (or unknown for non-local
241            // config), we can fallback to templates from config
242            return $fromConfig;
243        }
244        return $fromMessage;
245    }
246
247    /**
248     * Load and cache boost templates configured on wiki via the system
249     * message 'cirrussearch-boost-templates'.
250     * If called from the local wiki the message will be cached.
251     * If called from a non local wiki an attempt to fetch this data from the cache is made.
252     * If an empty array is returned it means that no config is available on wiki
253     * or the value possibly unknown if run from a non local wiki.
254     *
255     * @param SearchConfig $config
256     * @return float[] indexed by template name
257     */
258    private static function getOnWikiBoostTemplates( SearchConfig $config ) {
259        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
260        $cacheKey = $cache->makeGlobalKey( 'cirrussearch-boost-templates', $config->getWikiId() );
261        if ( $config->getWikiId() == WikiMap::getCurrentWikiId() ) {
262            // Local wiki we can fetch boost templates from system
263            // message
264            if ( self::$defaultBoostTemplates !== null ) {
265                // This static cache is never set with non-local
266                // wiki data.
267                return self::$defaultBoostTemplates;
268            }
269
270            $templates = $cache->getWithSetCallback(
271                $cacheKey,
272                600,
273                static function () {
274                    $source = wfMessage( 'cirrussearch-boost-templates' )->inContentLanguage();
275                    if ( !$source->isDisabled() ) {
276                        $lines = Util::parseSettingsInMessage( $source->plain() );
277                        // Now parse the templates
278                        return Query\BoostTemplatesFeature::parseBoostTemplates( implode( ' ', $lines ) );
279                    }
280                    return [];
281                }
282            );
283            self::$defaultBoostTemplates = $templates;
284            return $templates;
285        }
286        // Here we're dealing with boost template from other wiki, try to fetch it if it exists
287        // otherwise, don't bother.
288        $nonLocalCache = $cache->get( $cacheKey );
289        if ( !is_array( $nonLocalCache ) ) {
290            // not yet in cache, value is unknown
291            // return empty array
292            return [];
293        }
294        return $nonLocalCache;
295    }
296
297    /**
298     * Strip question marks from queries, according to the defined stripping
299     * level, defined by $wgCirrusSearchStripQuestionMarks. Strip all ?s, those
300     * at word breaks, or only string-final. Ignore queries that are all
301     * punctuation or use insource. Don't remove escaped \?s, but unescape them.
302     *
303     * @param string $term
304     * @param string $strippingLevel Either "all", "break", "final", or "none"
305     * @return string modified term, based on strippingLevel
306     */
307    public static function stripQuestionMarks( $term, $strippingLevel ) {
308        if ( strpos( $term, 'insource:/' ) === false &&
309             strpos( $term, 'intitle:/' ) === false &&
310            !preg_match( '/^[\p{P}\p{Z}]+$/u', $term )
311        ) {
312            // FIXME: get rid of negative lookbehinds on (?<!\\\\)
313            // it may improperly transform \\? into \? instead of \\ and destroy properly escaped \
314            if ( $strippingLevel === 'final' ) {
315                // strip only query-final question marks that are not escaped
316                $term = preg_replace( "/((?<!\\\\)\?|\s)+$/", '', $term );
317                $term = preg_replace( '/\\\\\?/', '?', $term );
318            } elseif ( $strippingLevel === 'break' ) {
319                // strip question marks at word boundaries
320                $term = preg_replace( '/(?<!\\\\)\?+(\PL|$)/', '$1', $term );
321                $term = preg_replace( '/\\\\\?/', '?', $term );
322            } elseif ( $strippingLevel === 'all' ) {
323                // strip all unescaped question marks
324                $term = preg_replace( '/(?<!\\\\)\?+/', ' ', $term );
325                $term = preg_replace( '/\\\\\?/', '?', $term );
326            }
327        }
328        return $term;
329    }
330
331    /**
332     * Identifies a specific execution of php. That might be one web
333     * request, or multiple jobs run in the same executor. An execution id
334     * is valid over a brief timespan, perhaps a minute or two for some jobs.
335     *
336     * @return string unique identifier
337     */
338    public static function getExecutionId() {
339        if ( self::$executionId === null ) {
340            self::$executionId = (string)mt_rand();
341        }
342        return self::$executionId;
343    }
344
345    /**
346     * Unit tests only
347     */
348    public static function resetExecutionId() {
349        self::$executionId = null;
350    }
351
352    /**
353     * Get a token that (hopefully) uniquely identifies this search. It will be
354     * added to the search result page js config vars, and put into the url with
355     * history.replaceState(). This means click through's from supported browsers
356     * will record this token as part of the referrer.
357     *
358     * @param GlobalIdGenerator|null $gen id generator
359     * @return string
360     */
361    public static function getRequestSetToken( ?GlobalIdGenerator $gen = null ): string {
362        static $token;
363        if ( $token === null ) {
364            // random UID, 70B tokens have a collision probability of 4*10^-16
365            // so should work for marking unique queries.
366            $gen = $gen ?: MediaWikiServices::getInstance()->getGlobalIdGenerator();
367            $uuid = $gen->newUUIDv4();
368            // make it a little shorter by using straight base36
369            $hex = substr( $uuid, 0, 8 ) . substr( $uuid, 9, 4 ) .
370                substr( $uuid, 14, 4 ) . substr( $uuid, 19, 4 ) .
371                substr( $uuid, 24 );
372            $token = \Wikimedia\base_convert( $hex, 16, 36 );
373        }
374        return $token;
375    }
376
377    /**
378     * Strips private ip ranges from an x-forwarded-for header
379     *
380     * Private IP ranges (like 10.*) are typically added server
381     * side routing. Strip them to only see the external side
382     * of xff. Otherwise we would see variance due to internal
383     * routing changes.
384     * As a side effect it also normalizes away whitespace from
385     * the list.
386     *
387     * @param string|false $xff x-forwarded-for header
388     * @return string
389     */
390    private static function stripPrivateIps( $xff ): string {
391        if ( $xff === false ) {
392            return '';
393        }
394
395        $publicIPs = array_filter(
396            array_map( 'trim', explode( ',', $xff ) ),
397            static function ( string $ip ): bool {
398                if ( !filter_var( $ip, FILTER_VALIDATE_IP ) ) {
399                    return false;
400                }
401
402                return (bool)filter_var(
403                    $ip,
404                    FILTER_VALIDATE_IP,
405                    FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
406                );
407            }
408        );
409
410        return implode( ', ', $publicIPs );
411    }
412
413    /**
414     * @param string $extraData Extra information to mix into the hash
415     * @return string A token that identifies the source of the request
416     */
417    public static function generateIdentToken( $extraData = '' ) {
418        $request = RequestContext::getMain()->getRequest();
419        try {
420            $ip = $request->getIP();
421        } catch ( MWException ) {
422            // No ip, probably running cli?
423            $ip = 'unknown';
424        }
425        return md5( implode( ':', [
426            $extraData,
427            $ip,
428            self::stripPrivateIps( $request->getHeader( 'X-Forwarded-For' ) ),
429            $request->getHeader( 'User-Agent' ),
430        ] ) );
431    }
432
433    /**
434     * @return string The context the request is in. Either cli, api, web or misc.
435     */
436    public static function getExecutionContext() {
437        if ( PHP_SAPI === 'cli' ) {
438            return 'cli';
439        } elseif ( MW_ENTRY_POINT == 'api' ) {
440            return 'api';
441        } elseif ( MW_ENTRY_POINT == 'index' ) {
442            return 'web';
443        } else {
444            return 'misc';
445        }
446    }
447
448    /**
449     * Helper for PHP's annoying emptiness check.
450     * empty(0) should not be true!
451     * empty(false) should not be true!
452     * Empty arrays, strings, and nulls/undefined count as empty.
453     *
454     * False otherwise.
455     * @param mixed $v
456     * @return bool
457     */
458    public static function isEmpty( $v ) {
459        return ( is_array( $v ) && count( $v ) === 0 ) ||
460            ( is_object( $v ) && count( (array)$v ) === 0 ) ||
461            ( is_string( $v ) && strlen( $v ) === 0 ) ||
462            ( $v === null );
463    }
464
465    /**
466     * Helper function to conditionally set a key in a dest array only if it
467     * is defined in a source array.  This is just to help DRY up what would
468     * otherwise could be a long series of
469     * if ( isset($sourceArray[$key] )) { $destArray[$key] = $sourceArray[$key] }
470     * statements.  This also supports using a different key in the dest array,
471     * as well as mapping the value when assigning to $sourceArray.
472     *
473     * Usage:
474     * $arr1 = ['KEY1' => '123'];
475     * $arr2 = [];
476     *
477     * setIfDefined($arr1, 'KEY1', $arr2, 'key1', 'intval');
478     * // $arr2['key1'] is now set to 123 (integer value)
479     *
480     * setIfDefined($arr1, 'KEY2', $arr2);
481     * // $arr2 stays the same, because $arr1 does not have 'KEY2' defined.
482     *
483     * @param array $sourceArray the array from which to look for $sourceKey
484     * @param string $sourceKey the key to look for in $sourceArray
485     * @param array &$destArray by reference destination array in which to set value if defined
486     * @param string|null $destKey optional, key to use instead of $sourceKey in $destArray.
487     * @param callable|null $mapFn optional, If set, this will be called on the value before setting it.
488     * @param bool $checkEmpty If false, emptyiness of result after $mapFn is called will not be
489     *                 checked before setting on $destArray.  If true, it will, using Util::isEmpty.
490     *                 Default: true
491     * @return array
492     */
493    public static function setIfDefined(
494        array $sourceArray,
495        $sourceKey,
496        array &$destArray,
497        $destKey = null,
498        $mapFn = null,
499        $checkEmpty = true
500    ) {
501        if ( array_key_exists( $sourceKey, $sourceArray ) ) {
502            $val = $sourceArray[$sourceKey];
503            if ( $mapFn !== null ) {
504                $val = $mapFn( $val );
505            }
506            // Only set in $destArray if we are not checking emptiness,
507            // or if we are and the $val is not empty.
508            if ( !$checkEmpty || !self::isEmpty( $val ) ) {
509                $key = $destKey ?: $sourceKey;
510                $destArray[$key] = $val;
511            }
512        }
513        return $destArray;
514    }
515
516    /**
517     * @return StatsFactory prefixed with the "CirrusSearch" component
518     */
519    public static function getStatsFactory(): StatsFactory {
520        return MediaWikiServices::getInstance()->getStatsFactory()->withComponent( CirrusSearch::NAME );
521    }
522
523    /**
524     * @param SearchConfig $config Configuration of the check
525     * @param string $ip The address to check against, ipv4 or ipv6.
526     * @param string[] $headers Map from http header name to value. All names must be uppercased.
527     * @return bool True when the parameters appear to be a non-interactive use case.
528     */
529    public static function looksLikeAutomation( SearchConfig $config, string $ip, array $headers ): bool {
530        // Is there an http header that can be matched with regex to flag automation,
531        // such as the user-agent or a flag applied by some infrastructure?
532        $automationHeaders = $config->get( CirrusConfigNames::AutomationHeaderRegexes ) ?? [];
533        foreach ( $automationHeaders as $name => $pattern ) {
534            $name = strtoupper( $name );
535            if ( !isset( $headers[$name] ) ) {
536                continue;
537            }
538            $ret = preg_match( $pattern, $headers[$name] );
539            if ( $ret === 1 ) {
540                return true;
541            } elseif ( $ret === false ) {
542                LoggerFactory::getInstance( LogChannel::DEFAULT )->warning(
543                    "Invalid regex provided for header `$name` in `CirrusSearchAutomationHeaderRegexes`." );
544            }
545        }
546
547        // Does the ip address fall into a subnet known for automation?
548        $ranges = $config->get( CirrusConfigNames::AutomationCIDRs );
549        if ( IPUtils::isInRanges( $ip, $ranges ) ) {
550            return true;
551        }
552
553        // Default assumption that requests are interactive
554        return false;
555    }
556
557    /**
558     * If we're supposed to create raw result, create and return it,
559     * or output it and finish.
560     * @template T the type of the result passed and the return value of this function
561     *
562     * @param T $result Search result data
563     * @param WebRequest $request Request context
564     * @param CirrusDebugOptions $debugOptions
565     * @return T
566     */
567    public static function processSearchRawReturn( $result, WebRequest $request,
568                                                   CirrusDebugOptions $debugOptions ) {
569        $output = null;
570        $header = null;
571        if ( $debugOptions->getCirrusExplainFormat() !== null ) {
572            $header = 'Content-type: text/html; charset=UTF-8';
573            $printer = new ExplainPrinter( $debugOptions->getCirrusExplainFormat() );
574            $output = $printer->format( $result );
575        }
576
577        // This should always be true, except in the case of the test suite which wants the actual
578        // objects returned.
579        if ( $debugOptions->isDumpAndDie() ) {
580            if ( $output === null ) {
581                $header = 'Content-type: application/json; charset=UTF-8';
582                if ( $result === null ) {
583                    $output = '{}';
584                } else {
585                    $output = json_encode( $result, JSON_PRETTY_PRINT );
586                }
587            }
588
589            // When dumping the query we skip _everything_ but echoing the query.
590            RequestContext::getMain()->getOutput()->disable();
591            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable $header can't be null here
592            $request->response()->header( $header );
593            echo $output;
594            exit();
595        }
596
597        return $result;
598    }
599}