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