Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
25.53% covered (danger)
25.53%
12 / 47
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageExplainer
25.53% covered (danger)
25.53%
12 / 47
50.00% covered (danger)
50.00%
2 / 4
42.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 explain
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 resolve
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 runExplain
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace CirrusSearch;
4
5use Elastica\Exception\ResponseException;
6use Elastica\Request;
7use MediaWiki\Title\TitleFactory;
8
9/**
10 * Runs a single-document _explain for one page against a query.
11 *
12 * Unlike a query-level explain, this can help explain why a page does not
13 * match a query.
14 *
15 * Returns an array describing whether the page is in the index, whether it
16 * matched, and the raw Lucene explanation.
17 *
18 * Index is derived from the page's own namespace independent of the
19 * request's search namespaces.
20 *
21 * @license GPL-2.0-or-later
22 */
23class PageExplainer {
24
25    private Connection $connection;
26    private SearchConfig $config;
27    private TitleFactory $titleFactory;
28    private string $indexBaseName;
29
30    public function __construct(
31        Connection $connection,
32        SearchConfig $config,
33        TitleFactory $titleFactory,
34        string $indexBaseName
35    ) {
36        $this->connection = $connection;
37        $this->config = $config;
38        $this->titleFactory = $titleFactory;
39        $this->indexBaseName = $indexBaseName;
40    }
41
42    /**
43     * Resolve the page id, issue the `_explain` and shape the response into the
44     * dump blob.
45     *
46     * A page id that does not resolve to a local title (deleted / unknown) and a
47     * document absent from the index both collapse to found:false; a debug probe
48     * has no need to tell a deleted page from an unindexed one.
49     *
50     * @param int $pageId local mediawiki page id to explain
51     * @param array $queryClause the query clause to explain against
52     * @return array{found:bool,matched?:bool,explanation?:array,query:array,index:?string,docId:?string}
53     */
54    public function explain( int $pageId, array $queryClause ): array {
55        $routing = $this->resolve( $pageId );
56        if ( $routing === null ) {
57            return [
58                'found' => false,
59                'query' => $queryClause,
60                'index' => null,
61                'docId' => null,
62            ];
63        }
64        return $this->runExplain( $routing['index'], $routing['docId'], $queryClause );
65    }
66
67    /**
68     * Resolve a local page id to the canonical index name and document id
69     *
70     * @param int $pageId local mediawiki page id
71     * @return array{index:string,docId:string}|null the index name and document
72     *  id to `_explain`, or null when the page id does not resolve to a local
73     *  title (deleted / unknown id).
74     */
75    public function resolve( int $pageId ): ?array {
76        $title = $this->titleFactory->newFromID( $pageId );
77        if ( $title === null ) {
78            return null;
79        }
80        $suffix = $this->connection->getIndexSuffixForNamespace( $title->getNamespace() );
81        return [
82            'index' => $this->connection->getIndexName( $this->indexBaseName, $suffix ),
83            'docId' => $this->config->makeId( $pageId ),
84        ];
85    }
86
87    /**
88     * Issue the `_explain` and shape the response into the dump blob.
89     *
90     * @param string $indexName index resolved from the page's namespace
91     * @param string $docId opensearch document id for the page
92     * @param array $queryClause the query clause to explain
93     * @return array the explain blob
94     */
95    private function runExplain( string $indexName, string $docId, array $queryClause ): array {
96        $notFound = [
97            'found' => false,
98            'query' => $queryClause,
99            'index' => $indexName,
100            'docId' => $docId,
101        ];
102        try {
103            $response = $this->connection->getClient()->request(
104                // rawurlencode the doc id: with CirrusSearchPrefixIds the id is
105                // "wikiid|pageid", and the bare "|" is not a legal URL path char.
106                "$indexName/_explain/" . rawurlencode( $docId ),
107                Request::GET,
108                [ 'query' => $queryClause ]
109            );
110        } catch ( ResponseException $e ) {
111            // A missing document yields a 404; treat it as "not in the search
112            // index" rather than a query failure. Anything else is a real error.
113            if ( $e->getResponse()->getStatus() === 404 ) {
114                return $notFound;
115            }
116            throw $e;
117        }
118
119        $data = $response->getData();
120        if ( !isset( $data['explanation'] ) ) {
121            // Some backends answer a missing doc with a 404 carrying matched:false
122            // and no explanation rather than throwing; same "not indexed" reading.
123            return $notFound;
124        }
125        return [
126            'found' => true,
127            'matched' => $data['matched'] ?? false,
128            'explanation' => $data['explanation'],
129            'query' => $queryClause,
130            'index' => $indexName,
131            'docId' => $docId,
132        ];
133    }
134}