Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TextIndexField
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 7
1560
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 setTextOptions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTextOptions
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 getMapping
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
240
 configureHighlighting
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
90
 initFlags
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSimilarity
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace CirrusSearch\Search;
4
5use CirrusSearch\CirrusSearch;
6use CirrusSearch\Maintenance\MappingConfigBuilder;
7use CirrusSearch\Profile\SearchProfileService;
8use CirrusSearch\SearchConfig;
9use SearchEngine;
10use SearchIndexField;
11
12/**
13 * Index field representing keyword.
14 * Keywords use special analyzer.
15 * @package CirrusSearch
16 */
17class TextIndexField extends CirrusIndexField {
18    /**
19     * Distance that lucene places between multiple values of the same field.
20     * Set pretty high to prevent accidental phrase queries between those values.
21     */
22    public const POSITION_INCREMENT_GAP = 10;
23
24    /* Bit field parameters for string fields.
25     *   ENABLE_NORMS: Enable norms on the field.  Good for text you search against but useless
26     *     for fields that don't get involved in the score.
27     *   COPY_TO_SUGGEST: Copy the contents of this field to the suggest field for "Did you mean".
28     *   COPY_TO_SUGGEST_VARIANT: Copy the contents of this field to the secondary suggest field for
29     *     ab testing "Did you mean".
30     *   SPEED_UP_HIGHLIGHTING: Store extra data in the field to speed up highlighting.  This is important for
31     *     long strings or fields with many values.
32     *   SUPPORT_REGEX: If the wikimedia-extra plugin is available add a trigram
33     *     index to speed up search.
34     */
35    public const ENABLE_NORMS = 0x1000000;
36    // FIXME: when exactly we want to disable norms for text fields?
37    public const COPY_TO_SUGGEST = 0x2000000;
38    public const COPY_TO_SUGGEST_VARIANT = 0x0200000;
39    public const SPEED_UP_HIGHLIGHTING = 0x4000000;
40    public const SUPPORT_REGEX = 0x8000000;
41    public const STRING_FIELD_MASK = 0xFFFFFF;
42
43    /**
44     * Extra definitions.
45     * @var array
46     */
47    protected $extra;
48    /**
49     * Text options for this field
50     * @var int
51     */
52    private $textOptions;
53
54    /**
55     * Name of the type in Elastic
56     * @var string
57     */
58    protected $typeName = 'text';
59
60    /**
61     * Are trigrams useful?
62     * @var bool
63     */
64    protected $allowTrigrams = false;
65
66    /**
67     * @param string $name
68     * @param string $type
69     * @param SearchConfig $config
70     * @param array $extra
71     */
72    public function __construct( $name, $type, SearchConfig $config, $extra = [] ) {
73        parent::__construct( $name, $type, $config );
74
75        $this->extra = $extra;
76
77        if ( $config->getElement( 'CirrusSearchWikimediaExtraPlugin', 'regex' ) &&
78            in_array( 'build', $config->getElement( 'CirrusSearchWikimediaExtraPlugin', 'regex' ) )
79        ) {
80            $this->allowTrigrams = true;
81        }
82    }
83
84    /**
85     * Set text options for this field if non-default
86     * @param int $options
87     * @return self
88     */
89    public function setTextOptions( $options ) {
90        $this->textOptions = $options;
91        return $this;
92    }
93
94    /**
95     * Get text options for this field
96     * @param int $mappingFlags
97     * @return int
98     */
99    protected function getTextOptions( $mappingFlags ) {
100        if ( $this->textOptions !== null ) {
101            return $this->textOptions;
102        }
103        $options = self::ENABLE_NORMS | self::SPEED_UP_HIGHLIGHTING;
104        if ( $this->config->get( 'CirrusSearchEnablePhraseSuggest' ) &&
105            $mappingFlags & MappingConfigBuilder::PHRASE_SUGGEST_USE_TEXT &&
106            !$this->checkFlag( SearchIndexField::FLAG_SCORING )
107        ) {
108            // SCORING fields are not copied since this info is already in other fields
109            $options |= self::COPY_TO_SUGGEST;
110            $options |= self::COPY_TO_SUGGEST_VARIANT;
111        }
112        if ( $this->checkFlag( SearchIndexField::FLAG_NO_HIGHLIGHT ) ) {
113            // Disable highlighting is asked to
114            $options &= ~self::SPEED_UP_HIGHLIGHTING;
115        }
116        return $options;
117    }
118
119    /**
120     * @param SearchEngine $engine
121     * @return array
122     */
123    public function getMapping( SearchEngine $engine ) {
124        if ( !( $engine instanceof CirrusSearch ) ) {
125            throw new \LogicException( "Cannot map CirrusSearch fields for another engine." );
126        }
127        $this->initFlags();
128        /**
129         * @var CirrusSearch $engine
130         */
131        $field = parent::getMapping( $engine );
132
133        if ( $this->config->get( 'CirrusSearchEnablePhraseSuggest' ) &&
134             $this->checkFlag( self::COPY_TO_SUGGEST )
135        ) {
136            $field[ 'copy_to' ][] = 'suggest';
137        }
138        if ( $this->config->get( 'CirrusSearchEnablePhraseSuggest' ) &&
139             $this->config->get( 'CirrusSearchPhraseSuggestBuildVariant' ) &&
140             $this->checkFlag( self::COPY_TO_SUGGEST_VARIANT )
141        ) {
142            $field[ 'copy_to' ][] = 'suggest_variant';
143        }
144
145        if ( $this->checkFlag( self::FLAG_NO_INDEX ) ) {
146            // no need to configure further a not-indexed field
147            return $field;
148        }
149
150        $extra = $this->extra;
151
152        if ( $this->mappingFlags & MappingConfigBuilder::PREFIX_START_WITH_ANY ) {
153            $extra[] = [
154                'analyzer' => 'word_prefix',
155                'search_analyzer' => 'plain_search',
156                'index_options' => 'docs'
157            ];
158        }
159        if ( $this->checkFlag( SearchIndexField::FLAG_CASEFOLD ) ) {
160            $extra[] = [
161                'type' => 'keyword',
162                'normalizer' => 'lowercase_keyword',
163            ];
164        }
165
166        if ( $this->allowTrigrams && $this->checkFlag( self::SUPPORT_REGEX ) ) {
167            $extra[] = [
168                'fieldName' => 'trigram',
169                'norms' => false,
170                'type' => 'text',
171                'analyzer' => 'trigram_anchored',
172                'search_analyzer' => 'trigram',
173                'index_options' => 'docs',
174            ];
175        }
176
177        // multi_field is dead in 1.0 so we do this which actually looks less gnarly.
178        $field += [
179            'analyzer' => 'text',
180            'search_analyzer' => 'text_search',
181            'position_increment_gap' => self::POSITION_INCREMENT_GAP,
182            'similarity' => self::getSimilarity( $this->config, $this->name ),
183            'fields' => [
184                'plain' => [
185                    'type' => 'text',
186                    'analyzer' => 'plain',
187                    'search_analyzer' => 'plain_search',
188                    'position_increment_gap' => self::POSITION_INCREMENT_GAP,
189                    'similarity' => self::getSimilarity( $this->config, $this->name, 'plain' ),
190                ],
191            ]
192        ];
193        $disableNorms = !$this->checkFlag( self::ENABLE_NORMS );
194        if ( $disableNorms ) {
195            $field['norms'] = false;
196            $field[ 'fields' ][ 'plain' ][ 'norms' ] = false;
197        }
198        foreach ( $extra as $extraField ) {
199            $extraName = $extraField[ 'fieldName' ] ?? $extraField[ 'analyzer' ] ?? $extraField['normalizer'];
200            unset( $extraField[ 'fieldName' ] );
201
202            $field[ 'fields' ][ $extraName ] = array_merge( [
203                'similarity' => self::getSimilarity( $this->config, $this->name, $extraName ),
204                'type' => 'text',
205            ], $extraField );
206
207            if ( $disableNorms ) {
208                $field[ 'fields' ][ $extraName ][ 'norms' ] = false;
209            }
210        }
211        $this->configureHighlighting( $field,
212            [ 'plain', 'prefix', 'prefix_asciifolding', 'near_match', 'near_match_asciifolding' ] );
213        return $field;
214    }
215
216    /**
217     * Adapt the field options according to the highlighter used
218     * @param mixed[] &$field the mapping options being built
219     * @param string[] $subFields list of subfields to configure
220     * @param bool $rootField configure the root field (defaults to true)
221     */
222    protected function configureHighlighting( array &$field, array $subFields, $rootField = true ) {
223        if ( $this->mappingFlags & MappingConfigBuilder::OPTIMIZE_FOR_EXPERIMENTAL_HIGHLIGHTER ) {
224            if ( $this->checkFlag( self::SPEED_UP_HIGHLIGHTING ) ) {
225                if ( $rootField ) {
226                    $field[ 'index_options' ] = 'offsets';
227                }
228                foreach ( $subFields as $fieldName ) {
229                    if ( isset( $field[ 'fields' ][ $fieldName ] ) ) {
230                        $field[ 'fields' ][ $fieldName ][ 'index_options' ] = 'offsets';
231                    }
232                }
233            }
234        } else {
235            // We use the FVH on all fields so turn on term vectors
236            if ( $rootField ) {
237                $field[ 'term_vector' ] = 'with_positions_offsets';
238            }
239            foreach ( $subFields as $fieldName ) {
240                if ( isset( $field[ 'fields' ][ $fieldName ] ) ) {
241                    $field[ 'fields' ][ $fieldName ][ 'term_vector' ] = 'with_positions_offsets';
242                }
243            }
244        }
245    }
246
247    /**
248     * Init the field flags
249     */
250    protected function initFlags() {
251        $this->flags =
252            ( $this->flags & self::STRING_FIELD_MASK ) | $this->getTextOptions( $this->mappingFlags );
253    }
254
255    /**
256     * Get the field similarity
257     * @param SearchConfig $config
258     * @param string $field
259     * @param string|null $analyzer
260     * @return string
261     */
262    public static function getSimilarity( SearchConfig $config, $field, $analyzer = null ) {
263        $similarity = $config->getProfileService()->loadProfile( SearchProfileService::SIMILARITY );
264        $fieldSimilarity = $similarity['fields'][$field] ?? $similarity['fields']['__default__'] ?? null;
265        if ( $analyzer !== null && isset( $similarity['fields']["$field.$analyzer"] ) ) {
266            $fieldSimilarity = $similarity['fields']["$field.$analyzer"];
267        }
268        if ( $fieldSimilarity === null ) {
269            throw new \RuntimeException( "Invalid similarity profile, unable to infer the similarity for " .
270                "the field $field, (defining a __default__ field might solve the issue" );
271        }
272        return $fieldSimilarity;
273    }
274}