Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 35
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 / 35
0.00% covered (danger)
0.00%
0 / 8
240
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 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 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 ): self {
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( string $title, string $language ): array {
140        $langSubPage = '/' . $language;
141        if ( str_ends_with( $title, $langSubPage ) ) {
142            return [ substr( $title, 0, -strlen( $langSubPage ) ), $langSubPage ];
143        } else {
144            return [ $title, "" ];
145        }
146    }
147
148    /**
149     * Tokenize the input $title by generating phrases suited
150     * for completion search.
151     * e.g. :
152     * $title = "Hello Beautifull Word/en";
153     * $builder->tokenize( $title, "en", "\\s" );
154     * will generate the following array:
155     *   [ "Beautifull Word/en", "Word/en" ]
156     *
157     * @param string $title
158     * @param string $language
159     * @return string[] tokenized phrasal suggestions
160     */
161    public function tokenize( $title, $language ) {
162        [ $title, $langSubPage ] = $this->splitTranslatedPage( $title, $language );
163
164        $cr = $this->getCharRange();
165        $matches = preg_split( "/[$cr]+/", $title, $this->maxSubPhrases + 1,
166            PREG_SPLIT_OFFSET_CAPTURE | PREG_SPLIT_NO_EMPTY );
167        // Remove the first one because it's the whole title
168        array_shift( $matches );
169        $subphrases = [];
170        foreach ( $matches as $m ) {
171            $subphrases[] = substr( $title, (int)$m[1] ) . $langSubPage;
172        }
173        return $subphrases;
174    }
175}