Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
NaiveSubphrasesSuggestionsBuilder
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 8
272
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getCharRange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExtraFields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequiredFields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 build
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 splitTranslatedPage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 tokenize
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace CirrusSearch\BuildDocument\Completion;
4
5use UnexpectedValueException;
6
7/**
8 * Simple class for SuggestionsBuilder that needs to munge the title
9 * into a list of "subphrases" suggestions.
10 * Subphrases are only generated for title, redirects are not yet supported.
11 * A set of new fields is used to insert these suggestions 'suggest-extra'
12 * is used by default but can be overridden with string[] getExtraFields().
13 */
14class NaiveSubphrasesSuggestionsBuilder implements ExtraSuggestionsBuilder {
15    /** @const string */
16    private const LANG_FIELD = 'language';
17
18    /** @const int */
19    private const MAX_SUBPHRASES = 10;
20
21    /** @const string subpage type */
22    public const SUBPAGE_TYPE = 'subpage';
23
24    /** @const string subpage type */
25    public const STARTS_WITH_ANY_WORDS_TYPE = 'anywords';
26
27    /**
28     * @var string[] list of regex char ranges indexed by type
29     */
30    private static $RANGES_BY_TYPE = [
31        self::SUBPAGE_TYPE => '\/',
32        self::STARTS_WITH_ANY_WORDS_TYPE => '\/\s',
33    ];
34
35    /** @var int */
36    private $maxSubPhrases;
37
38    /**
39     * @var string regex character range, this value must be a valid char
40     * range and will be used to build a regular expression like
41     * '[' . $charRange . ']'
42     */
43    private $charRange;
44
45    /**
46     * @param string $charRange character range used to split subphrases
47     * @param int $maxSubPhrases defaults to MAX_SUBPHRASES
48     */
49    public function __construct( $charRange, $maxSubPhrases = self::MAX_SUBPHRASES ) {
50        $this->charRange = $charRange;
51        $this->maxSubPhrases = $maxSubPhrases;
52    }
53
54    public static function create( array $config ) {
55        $limit = $config['limit'] ?? self::MAX_SUBPHRASES;
56        if ( !isset( self::$RANGES_BY_TYPE[$config['type']] ) ) {
57            throw new UnexpectedValueException( "Unsupported NaiveSubphrasesSuggestionsBuilder type " .
58                $config['type'] );
59        }
60        $cr = self::$RANGES_BY_TYPE[$config['type']];
61        return new self( $cr, $limit );
62    }
63
64    /**
65     * Get the char range used by this builder
66     * to split and generate subphrase suggestions
67     * @return string a valid regex char range that will be inserted inside
68     * square brackets.
69     */
70    protected function getCharRange() {
71        return $this->charRange;
72    }
73
74    /**
75     * List of FST fields where the subphrase suggestions
76     * will be added.
77     * @return string[]
78     */
79    protected function getExtraFields() {
80        return [ 'suggest-subphrases' ];
81    }
82
83    /**
84     * @inheritDoc
85     */
86    public function getRequiredFields() {
87        // This builder needs the language field
88        // to exclude subpages generated by the translate
89        // extension
90        return [ self::LANG_FIELD ];
91    }
92
93    /**
94     * @param mixed[] $inputDoc
95     * @param string $suggestType (title or redirect)
96     * @param int $score
97     * @param \Elastica\Document $suggestDoc suggestion type (title or redirect)
98     * @param int $targetNamespace
99     */
100    public function build( array $inputDoc, $suggestType, $score, \Elastica\Document $suggestDoc, $targetNamespace ) {
101        if ( $suggestType === SuggestBuilder::REDIRECT_SUGGESTION ) {
102            // It's unclear howto support redirects here.
103            // It seems hard to retrieve the best redirect if
104            // we destroy it with this builder. We would have to
105            // add a special code at search time and apply the
106            // same splitting strategy on retrieved redirects.
107            return;
108        }
109
110        $language = "";
111        if ( isset( $inputDoc[self::LANG_FIELD] ) ) {
112            $language = $inputDoc[self::LANG_FIELD];
113        }
114
115        $subPages = $this->tokenize( $inputDoc['title'], $language );
116        if ( $subPages ) {
117            $suggest = $suggestDoc->get( 'suggest' );
118            $suggest['input'] = $subPages;
119            foreach ( $this->getExtraFields() as $field ) {
120                $suggestDoc->set( $field, $suggest );
121            }
122        }
123    }
124
125    /**
126     * Split a translated page title into an array
127     * with the title at offset 0 and the language
128     * subpage at offset 1.
129     *
130     * e.g. splitTranslatedPage("Hello/en", "en")
131     *  - will output [ "Hello", "/en" ]
132     * e.g. splitTranslatedPage("Hello/test", "en")
133     *  - will output [ "Hello/test", "" ]
134     *
135     * @param string $title
136     * @param string $language
137     * @return string[]
138     */
139    public function splitTranslatedPage( $title, $language ) {
140        $langSubPage = '/' . $language;
141        if ( strlen( $langSubPage ) < strlen( $title ) &&
142            substr_compare( $title, $langSubPage, -strlen( $langSubPage ) ) == 0
143        ) {
144            return [ substr( $title, 0, -strlen( $langSubPage ) ), $langSubPage ];
145        } else {
146            return [ $title, "" ];
147        }
148    }
149
150    /**
151     * Tokenize the input $title by generating phrases suited
152     * for completion search.
153     * e.g. :
154     * $title = "Hello Beautifull Word/en";
155     * $builder->tokenize( $title, "en", "\\s" );
156     * will generate the following array:
157     *   [ "Beautifull Word/en", "Word/en" ]
158     *
159     * @param string $title
160     * @param string $language
161     * @return string[] tokenized phrasal suggestions
162     */
163    public function tokenize( $title, $language ) {
164        [ $title, $langSubPage ] = $this->splitTranslatedPage( $title, $language );
165
166        $cr = $this->getCharRange();
167        $matches = preg_split( "/[$cr]+/", $title, $this->maxSubPhrases + 1,
168            PREG_SPLIT_OFFSET_CAPTURE | PREG_SPLIT_NO_EMPTY );
169        // Remove the first one because it's the whole title
170        array_shift( $matches );
171        $subphrases = [];
172        foreach ( $matches as $m ) {
173            $subphrases[] = substr( $title, (int)$m[1] ) . $langSubPage;
174        }
175        return $subphrases;
176    }
177}