Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.59% covered (success)
98.59%
70 / 71
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
FetchPhaseConfigBuilder
98.59% covered (success)
98.59%
70 / 71
92.31% covered (success)
92.31%
12 / 13
25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 newHighlightField
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 addNewRegexHLField
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 supportsRegexFields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newRegexField
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addHLField
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getHLField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildHLConfig
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 withConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHLFieldsPerTargetAndPriority
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 suppressRedirectTitleHighlight
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configureDefaultFullTextFields
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 clearSkipIfLastMatched
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace CirrusSearch\Search\Fetch;
4
5use CirrusSearch\CirrusConfigNames;
6use CirrusSearch\SearchConfig;
7use CirrusSearch\Searcher;
8use Elastica\Query\AbstractQuery;
9use Wikimedia\Assert\Assert;
10
11/**
12 * Class holding the building state of the fetch phase elements of
13 * an elasticsearch query.
14 * Currently only supports the highlight section but can be extended to support
15 * source filtering and stored field.
16 */
17class FetchPhaseConfigBuilder implements HighlightFieldGenerator {
18
19    /** @var HighlightedField[] */
20    private $highlightedFields = [];
21
22    /** @var SearchConfig */
23    private $config;
24
25    /**
26     * @var string
27     */
28    private $factoryGroup;
29
30    /**
31     * @var bool
32     */
33    private $provideAllSnippets;
34
35    /**
36     * @var bool whether to drop the redirect.title highlight from the default
37     *  full text fields. Set by the query side when the search is in redirect
38     *  scope, where the matching redirect is its own result.
39     */
40    private bool $suppressRedirectTitleHighlight = false;
41
42    /**
43     * @param SearchConfig $config
44     * @param string|null $factoryGroup
45     * @param bool $provideAllSnippets
46     */
47    public function __construct(
48        SearchConfig $config,
49        $factoryGroup = null,
50        bool $provideAllSnippets = false
51    ) {
52        $this->config = $config;
53        $this->factoryGroup = $factoryGroup;
54        $this->provideAllSnippets = $provideAllSnippets;
55    }
56
57    /**
58     * @inheritDoc
59     */
60    public function newHighlightField(
61        $name,
62        $target,
63        $priority = HighlightedField::DEFAULT_TARGET_PRIORITY
64    ): BaseHighlightedField {
65        $useExp = $this->config->get( CirrusConfigNames::UseExperimentalHighlighter );
66        if ( $useExp ) {
67            $factories = ExperimentalHighlightedFieldBuilder::getFactories();
68        } else {
69            $factories = BaseHighlightedField::getFactories();
70        }
71        if ( $this->factoryGroup !== null && isset( $factories[$this->factoryGroup][$name] ) ) {
72            return ( $factories[$this->factoryGroup][$name] )( $this->config, $name, $target, $priority );
73        }
74        if ( $useExp ) {
75            return new ExperimentalHighlightedFieldBuilder( $name, $target, $priority );
76        } else {
77            return new BaseHighlightedField( $name, BaseHighlightedField::FVH_HL_TYPE, $target, $priority );
78        }
79    }
80
81    /**
82     * @param string $name
83     * @param string $target
84     * @param string $pattern
85     * @param bool $caseInsensitive
86     * @param int $priority
87     */
88    public function addNewRegexHLField(
89        $name,
90        $target,
91        $pattern,
92        $caseInsensitive,
93        $priority = HighlightedField::COSTLY_EXPERT_SYNTAX_PRIORITY
94    ) {
95        if ( !$this->supportsRegexFields() ) {
96            return;
97        }
98        $this->addHLField( $this->newRegexField( $name, $target, $pattern, $caseInsensitive, $priority ) );
99    }
100
101    /**
102     * Whether this builder can generate regex fields
103     * @return bool
104     */
105    public function supportsRegexFields() {
106        return (bool)$this->config->get( CirrusConfigNames::UseExperimentalHighlighter );
107    }
108
109    /**
110     * @inheritDoc
111     */
112    public function newRegexField(
113        $name,
114        $target,
115        $pattern,
116        $caseInsensitive,
117        $priority = HighlightedField::COSTLY_EXPERT_SYNTAX_PRIORITY,
118        $regexFlavor = 'lucene'
119    ): BaseHighlightedField {
120        Assert::precondition( $this->supportsRegexFields(), 'Regex fields not supported' );
121        return ExperimentalHighlightedFieldBuilder::newRegexField(
122            $this->config, $name, $target, $pattern, $caseInsensitive, $priority, $regexFlavor );
123    }
124
125    public function addHLField( HighlightedField $field ) {
126        $prev = $this->highlightedFields[$field->getFieldName()] ?? null;
127        if ( $prev === null ) {
128            $this->highlightedFields[$field->getFieldName()] = $field;
129        } else {
130            $this->highlightedFields[$field->getFieldName()] = $prev->merge( $field );
131        }
132    }
133
134    /**
135     * @param string $field
136     * @return HighlightedField|null
137     */
138    public function getHLField( $field ) {
139        return $this->highlightedFields[$field] ?? null;
140    }
141
142    /**
143     * @param AbstractQuery|null $mainHLQuery
144     * @return array
145     */
146    public function buildHLConfig( ?AbstractQuery $mainHLQuery = null ): array {
147        $fields = [];
148        foreach ( $this->highlightedFields as $field ) {
149            $arr = $field->toArray();
150            if ( $this->provideAllSnippets ) {
151                $arr = $this->clearSkipIfLastMatched( $arr );
152            }
153            $fields[$field->getFieldName()] = $arr;
154        }
155        $config = [
156            'pre_tags' => [ Searcher::HIGHLIGHT_PRE_MARKER ],
157            'post_tags' => [ Searcher::HIGHLIGHT_POST_MARKER ],
158            'fields' => $fields,
159        ];
160
161        if ( $mainHLQuery !== null ) {
162            $config['highlight_query'] = $mainHLQuery->toArray();
163        }
164
165        return $config;
166    }
167
168    public function withConfig( SearchConfig $config ): self {
169        return new self( $config, $this->factoryGroup );
170    }
171
172    /**
173     * Return the list of highlighted fields indexed per target
174     * and ordered by priority (reverse natural order)
175     * @return HighlightedField[][]
176     */
177    public function getHLFieldsPerTargetAndPriority(): array {
178        $fields = [];
179        foreach ( $this->highlightedFields as $f ) {
180            $fields[$f->getTarget()][] = $f;
181        }
182        return array_map(
183            static function ( array $v ) {
184                usort( $v, static function ( HighlightedField $g1, HighlightedField $g2 ) {
185                    return $g2->getPriority() <=> $g1->getPriority();
186                } );
187                return $v;
188            },
189            $fields
190        );
191    }
192
193    /**
194     * Drop the redirect.title highlight from the default full text fields.
195     *
196     * Used when the search is in redirect scope: the matching redirect is its
197     * own result, so highlighting redirect.title would produce a redundant and
198     * misleading "redirected from" snippet.
199     */
200    public function suppressRedirectTitleHighlight(): void {
201        $this->suppressRedirectTitleHighlight = true;
202    }
203
204    public function configureDefaultFullTextFields() {
205        // TODO: find a better place for this
206        // Title/redir/category/template
207        $field = $this->newHighlightField( 'title', HighlightedField::TARGET_TITLE_SNIPPET );
208        $this->addHLField( $field );
209        if ( !$this->suppressRedirectTitleHighlight ) {
210            $field = $this->newHighlightField( 'redirect.title', HighlightedField::TARGET_REDIRECT_SNIPPET );
211            $this->addHLField( $field->skipIfLastMatched() );
212        }
213        $field = $this->newHighlightField( 'category', HighlightedField::TARGET_CATEGORY_SNIPPET );
214        $this->addHLField( $field->skipIfLastMatched() );
215
216        $field = $this->newHighlightField( 'heading', HighlightedField::TARGET_SECTION_SNIPPET );
217        $this->addHLField( $field->skipIfLastMatched() );
218
219        // content
220        $field = $this->newHighlightField( 'text', HighlightedField::TARGET_MAIN_SNIPPET );
221        $this->addHLField( $field );
222
223        $field = $this->newHighlightField( 'auxiliary_text', HighlightedField::TARGET_MAIN_SNIPPET );
224        $this->addHLField( $field->skipIfLastMatched() );
225
226        $field = $this->newHighlightField( 'file_text', HighlightedField::TARGET_MAIN_SNIPPET );
227        $this->addHLField( $field->skipIfLastMatched() );
228    }
229
230    private function clearSkipIfLastMatched( array $arr ): array {
231        unset( $arr['options']['skip_if_last_matched'] );
232        if ( empty( $arr['options'] ) ) {
233            unset( $arr['options'] );
234        }
235        return $arr;
236    }
237}