Completed
Pull Request — master (#153)
by
unknown
01:36
created

Percolate::escapeString()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace Foolz\SphinxQL;
4
5
use Foolz\SphinxQL\Drivers\ConnectionInterface;
6
use Foolz\SphinxQL\Exception\SphinxQLException;
7
8
/**
9
 * Query Builder class for Percolate Queries.
10
 *
11
 * ### INSERT ###
12
 *
13
 * $query = (new Percolate($conn))
14
 *    ->insert('full text query terms', noEscape = false)       // Allowed only one insert per query (Symbol @ indicates field in sphinx.conf)
15
 *                                                                 No escape tag cancels characters shielding (default on)
16
 *    ->into('pq')                                              // Index for insert
17
 *    ->tags(['tag1','tag2'])                                   // Adding tags. Can be array ['tag1','tag2'] or string delimited by coma
18
 *    ->filter('price>3')                                       // Adding filter (Allowed only one)
19
 *    ->execute();
20
 *
21
 *
22
 * ### CALL PQ ###
23
 *
24
 *
25
 * $query = (new Percolate($conn))
26
 *    ->callPQ()
27
 *    ->from('pq')                                              // Index for call pq
28
 *    ->documents(['multiple documents', 'go this way'])        // see getDocuments function
29
 *    ->options([                                               // See https://docs.manticoresearch.com/latest/html/searching/percolate_query.html#call-pq
30
 *          Percolate::OPTION_VERBOSE => 1,
31
 *          Percolate::OPTION_DOCS_JSON => 1
32
 *    ])
33
 *    ->execute();
34
 *
35
 *
36
 */
37
class Percolate
38
{
39
40
    /**
41
     * @var ConnectionInterface
42
     */
43
    protected $connection;
44
45
    /**
46
     * Documents for CALL PQ
47
     *
48
     * @var array|string
49
     */
50
    protected $documents;
51
52
    /**
53
     * Index name
54
     *
55
     * @var string
56
     */
57
    protected $index;
58
59
    /**
60
     * Insert query
61
     *
62
     * @var string
63
     */
64
    protected $query;
65
66
    /**
67
     * Options for CALL PQ
68
     * @var array
69
     */
70
    protected $options = [self::OPTION_DOCS_JSON => 1];
71
72
    /**
73
     * @var array
74
     */
75
    protected $filters = [];
76
77
    /**
78
     * Query type (call | insert)
79
     *
80
     * @var string
81
     */
82
    protected $type = 'call';
83
84
    /** INSERT STATEMENT  **/
85
86
    protected $tags = [];
87
88
    /**
89
     * Throw exceptions flag.
90
     * Activates if option OPTION_DOCS_JSON setted
91
     *
92
     * @var int
93
     */
94
    protected $throwExceptions = 0;
95
    /**
96
     * @var array
97
     */
98
    protected $escapeChars = [
99
        '\\' => '\\\\',
100
        '-' => '\\-',
101
        '~' => '\\~',
102
        '<' => '\\<',
103
        '"' => '\\"',
104
        "'" => "\\'",
105
        '/' => '\\/',
106
        '!' => '\\!'
107
    ];
108
109
    /** @var SphinxQL */
110
    protected $sphinxQL;
111
112
    /**
113
     * CALL PQ option constants
114
     */
115
    const OPTION_DOCS_JSON = 'as docs_json';
116
    const OPTION_DOCS = 'as docs';
117
    const OPTION_VERBOSE = 'as verbose';
118
    const OPTION_QUERY = 'as query';
119
120
    /**
121
     * @param ConnectionInterface $connection
122
     */
123
    public function __construct(ConnectionInterface $connection)
124
    {
125
        $this->connection = $connection;
126
        $this->sphinxQL = new SphinxQL($this->connection);
127
128
    }
129
130
131
    /**
132
     * Clear all fields after execute
133
     */
134
    private function clear()
135
    {
136
        $this->documents = null;
137
        $this->index = null;
138
        $this->query = null;
139
        $this->options = [self::OPTION_DOCS_JSON => 1];
140
        $this->type = 'call';
141
        $this->filters = [];
142
        $this->tags = [];
143
    }
144
145
    /**
146
     * Analog into function
147
     * Sets index name for query
148
     *
149
     * @param string $index
150
     *
151
     * @return $this
152
     * @throws SphinxQLException
153
     */
154
    public function from($index)
155
    {
156
        if (empty($index)) {
157
            throw new SphinxQLException('Index can\'t be empty');
158
        }
159
160
        $this->index = trim($index);
161
        return $this;
162
    }
163
164
    /**
165
     * Analog from function
166
     * Sets index name for query
167
     *
168
     * @param string $index
169
     *
170
     * @return $this
171
     * @throws SphinxQLException
172
     */
173
    public function into($index)
174
    {
175
        if (empty($index)) {
176
            throw new SphinxQLException('Index can\'t be empty');
177
        }
178
        $this->index = trim($index);
179
        return $this;
180
    }
181
182
    /**
183
     * Replacing bad chars
184
     *
185
     * @param string $query
186
     *
187
     * @return string mixed
188
     */
189
    protected function escapeString($query)
190
    {
191
        return str_replace(
192
            array_keys($this->escapeChars),
193
            array_values($this->escapeChars),
194
            $query);
195
    }
196
197
198
    /**
199
     * @param $query
200
     * @return mixed
201
     */
202
    protected function clearString($query)
203
    {
204
        return str_replace(
205
            array_keys(array_merge($this->escapeChars, ['@' => ''])),
206
            ['', '', '', '', '', '', '', '', '', ''],
207
            $query);
208
    }
209
210
    /**
211
     * Adding tags for insert query
212
     *
213
     * @param array|string $tags
214
     *
215
     * @return $this
216
     */
217
    public function tags($tags)
218
    {
219
        if (is_array($tags)) {
220
            $tags = array_map([$this, 'escapeString'], $tags);
221
            $tags = implode(',', $tags);
222
        } else {
223
            $tags = $this->escapeString($tags);
224
        }
225
        $this->tags = $tags;
226
        return $this;
227
    }
228
229
    /**
230
     * Add filter for insert query
231
     *
232
     * @param string $filter
233
     * @return $this
234
     *
235
     * @throws SphinxQLException
236
     */
237
    public function filter($filter)
238
    {
239
        $filters = explode(',', $filter);
240
        if (!empty($filters[1])) {
241
            throw new SphinxQLException(
242
                'Allow only one filter. If there is a comma in the text, it must be shielded');
243
        }
244
        $this->filters = $this->clearString($filter);
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->clearString($filter) of type string is incompatible with the declared type array of property $filters.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
245
        return $this;
246
    }
247
248
    /**
249
     * Add insert query
250
     *
251
     * @param string $query
252
     * @param bool $noEscape
253
     *
254
     * @return $this
255
     * @throws SphinxQLException
256
     */
257
    public function insert($query, $noEscape = false)
258
    {
259
        $this->clear();
260
261
        if (empty($query)) {
262
            throw new SphinxQLException('Query can\'t be empty');
263
        }
264
        if (!$noEscape) {
265
            $query = $this->escapeString($query);
266
        }
267
        $this->query = $query;
268
        $this->type = 'insert';
269
270
        return $this;
271
    }
272
273
    /**
274
     * Generate array for insert, from setted class parameters
275
     *
276
     * @return array
277
     */
278
    private function generateInsert()
279
    {
280
        $insertArray = ['query' => $this->query];
281
282
        if (!empty($this->tags)) {
283
            $insertArray['tags'] = $this->tags;
284
        }
285
286
        if (!empty($this->filters)) {
287
            $insertArray['filters'] = $this->filters;
288
        }
289
290
        return $insertArray;
291
    }
292
293
    /**
294
     * Executs query and clear class parameters
295
     *
296
     * @return Drivers\ResultSetInterface
297
     * @throws SphinxQLException
298
     */
299
    public function execute()
300
    {
301
302
        if ($this->type == 'insert') {
303
            return $this->sphinxQL
304
                ->insert()
305
                ->into($this->index)
306
                ->set($this->generateInsert())
307
                ->execute();
308
        }
309
310
        return $this->sphinxQL
311
            ->query("CALL PQ ('" .
312
                $this->index . "', " . $this->getDocuments() . " " . $this->getOptions() . ")")
313
            ->execute();
314
    }
315
316
    /**
317
     * Set one option for CALL PQ
318
     *
319
     * @param string $key
320
     * @param int $value
321
     *
322
     * @return $this
323
     * @throws SphinxQLException
324
     */
325
    private function setOption($key, $value)
326
    {
327
        $value = intval($value);
328
        if (!in_array($key, [
329
            self::OPTION_DOCS_JSON,
330
            self::OPTION_DOCS,
331
            self::OPTION_VERBOSE,
332
            self::OPTION_QUERY
333
        ])) {
334
            throw new SphinxQLException('Unknown option');
335
        }
336
337
        if ($value != 0 && $value != 1) {
338
            throw new SphinxQLException('Option value can be only 1 or 0');
339
        }
340
341
        if ($key == self::OPTION_DOCS_JSON) {
342
            $this->throwExceptions = 1;
343
        }
344
345
        $this->options[$key] = $value;
346
        return $this;
347
    }
348
349
    /**
350
     * Set document parameter for CALL PQ
351
     *
352
     * @param array|string $documents
353
     * @return $this
354
     * @throws SphinxQLException
355
     */
356
    public function documents($documents)
357
    {
358
        if (empty($documents)) {
359
            throw new SphinxQLException('Document can\'t be empty');
360
        }
361
        $this->documents = $documents;
362
363
        return $this;
364
    }
365
366
    /**
367
     * Set options for CALL PQ
368
     *
369
     * @param array $options
370
     * @return $this
371
     * @throws SphinxQLException
372
     */
373
    public function options(array $options)
374
    {
375
        foreach ($options as $option => $value) {
376
            $this->setOption($option, $value);
377
        }
378
        return $this;
379
    }
380
381
382
    /**
383
     * Get and prepare options for CALL PQ
384
     *
385
     * @return string string
386
     */
387
    protected function getOptions()
388
    {
389
        $options = '';
390
        if (!empty($this->options)) {
391
            foreach ($this->options as $option => $value) {
392
                $options .= ', ' . $value . ' ' . $option;
393
            }
394
        }
395
396
        return $options;
397
    }
398
399
    /**
400
     * Check is array associative
401
     * @param array $arr
402
     * @return bool
403
     */
404
    private function isAssocArray(array $arr)
405
    {
406
        if (array() === $arr) {
407
            return false;
408
        }
409
        return array_keys($arr) !== range(0, count($arr) - 1);
410
    }
411
412
    /**
413
     * Get documents for CALL PQ. If option setted JSON - returns json_encoded
414
     *
415
     * Now selection of supported types work automatically. You don't need set
416
     * OPTION_DOCS_JSON to 1 or 0. But if you will set this option,
417
     * automatically enables exceptions on unsupported types
418
     *
419
     *
420
     * 1) If expect json = 0:
421
     *      a) doc can be 'catch me'
422
     *      b) doc can be multiple ['catch me if can','catch me']
423
     *
424
     * 2) If expect json = 1:
425
     *      a) doc can be jsonOBJECT {"subject": "document about orange"}
426
     *      b) docs can be jsonARRAY of jsonOBJECTS [{"subject": "document about orange"}, {"doc": "document about orange"}]
427
     *      c) docs can be phpArray of jsonObjects ['{"subject": "document about orange"}', '{"doc": "document about orange"}']
428
     *      d) doc can be associate array ['subject'=>'document about orange']
429
     *      e) docs can be array of associate arrays [['subject'=>'document about orange'], ['doc'=>'document about orange']]
430
     *
431
     *
432
     *
433
     *
434
     * @return string
435
     * @throws SphinxQLException
436
     */
437
    protected function getDocuments()
438
    {
439
        if (!empty($this->documents)) {
440
441
            if ($this->throwExceptions) {
442
443
                if ($this->options[self::OPTION_DOCS_JSON]) {
444
445
                    if (!is_array($this->documents)) {
446
                        $json = $this->checkJson($this->documents);
447
                        if (!$json) {
448
                            throw new SphinxQLException('Documents must be in json format');
449
                        }
450
                    } else {
451
                        if (!$this->isAssocArray($this->documents) && !is_array($this->documents[0])) {
452
                            throw new SphinxQLException('Documents array must be associate');
453
                        }
454
                    }
455
                }
456
            }
457
458
            if (is_array($this->documents)) {
459
460
                // If input is phpArray with json like
461
                // ->documents(['{"body": "body of doc 1", "title": "title of doc 1"}',
462
                //             '{"subject": "subject of doc 3"}'])
463
                //
464
                // Checking first symbol of first array value. If [ or { - call checkJson
465
466
                if (!empty($this->documents[0]) && !is_array($this->documents[0]) &&
467
                    ($this->documents[0][0] == '[' || $this->documents[0][0] == '{')) {
468
469
                    $json = $this->checkJson($this->documents);
0 ignored issues
show
Bug introduced by
$this->documents of type array is incompatible with the type string expected by parameter $json of Foolz\SphinxQL\Percolate::checkJson(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

469
                    $json = $this->checkJson(/** @scrutinizer ignore-type */ $this->documents);
Loading history...
470
                    if ($json) {
471
                        $this->options[self::OPTION_DOCS_JSON] = 1;
472
                        return $json;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $json returns the type true which is incompatible with the documented return type string.
Loading history...
473
                    }
474
475
                } else {
476
                    if (!$this->isAssocArray($this->documents)) {
477
478
                        // if incoming single array like ['catch me if can', 'catch me']
479
480
                        if (is_string($this->documents[0])) {
481
                            $this->options[self::OPTION_DOCS_JSON] = 0;
482
                            return '(' . $this->quoteString(implode('\', \'', $this->documents)) . ')';
483
                        }
484
485
                        // if doc is array of associate arrays [['foo'=>'bar'], ['foo1'=>'bar1']]
486
                        if (!empty($this->documents[0]) && $this->isAssocArray($this->documents[0])) {
487
                            $this->options[self::OPTION_DOCS_JSON] = 1;
488
                            return $this->convertArrayForQuery($this->documents);
489
                        }
490
491
                    } else {
492
                        if ($this->isAssocArray($this->documents)) {
493
                            // Id doc is associate array ['foo'=>'bar']
494
                            $this->options[self::OPTION_DOCS_JSON] = 1;
495
                            return $this->quoteString(json_encode($this->documents));
496
                        }
497
                    }
498
                }
499
500
            } else {
501
                if (is_string($this->documents)) {
0 ignored issues
show
introduced by
The condition is_string($this->documents) is always true.
Loading history...
502
503
                    $json = $this->checkJson($this->documents);
504
                    if ($json) {
505
                        $this->options[self::OPTION_DOCS_JSON] = 1;
506
                        return $json;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $json returns the type true which is incompatible with the documented return type string.
Loading history...
507
                    }
508
509
                    $this->options[self::OPTION_DOCS_JSON] = 0;
510
                    return $this->quoteString($this->documents);
511
                }
512
            }
513
        }
514
        throw new SphinxQLException('Documents can\'t be empty');
515
    }
516
517
518
    /**
519
     * Set type
520
     *
521
     * @return $this
522
     */
523
    public function callPQ()
524
    {
525
        $this->clear();
526
        $this->type = 'call';
527
        return $this;
528
    }
529
530
531
    /**
532
     * Check string is valid json
533
     *
534
     * @param string $json
535
     * @return bool
536
     */
537
    private function checkJson($json)
538
    {
539
        if (is_array($json)) {
0 ignored issues
show
introduced by
The condition is_array($json) is always false.
Loading history...
540
            if (is_array($json[0])) {
541
                return false;
542
            }
543
            $return = [];
544
            foreach ($json as $item) {
545
                $return[] = $this->checkJson($item);
546
            }
547
            return '(' . implode(', ', $return) . ')';
548
        }
549
        $array = json_decode($json, true);
550
551
        if (json_last_error() == JSON_ERROR_NONE) { // if json
552
            if (!empty($array[0])) { // If docs is jsonARRAY of jsonOBJECTS
553
                return $this->convertArrayForQuery($array);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->convertArrayForQuery($array) returns the type string which is incompatible with the documented return type boolean.
Loading history...
554
            }
555
            // If docs is jsonOBJECT
556
            return $this->quoteString($json);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->quoteString($json) returns the type string which is incompatible with the documented return type boolean.
Loading history...
557
        }
558
        return false;
559
    }
560
561
562
    /**
563
     * Converts array of associate arrays to valid for query statement
564
     * ('jsonOBJECT1', 'jsonOBJECT2' ...)
565
     *
566
     *
567
     * @param array $array
568
     * @return string
569
     */
570
    private function convertArrayForQuery(array $array)
571
    {
572
        $documents = [];
573
        foreach ($array as $document) {
574
            $documents[] = json_encode($document);
575
        }
576
577
        return '(' . $this->quoteString(implode('\', \'', $documents)) . ')';
578
    }
579
580
581
    /**
582
     * Adding single quotes to string
583
     *
584
     * @param string $string
585
     * @return string
586
     */
587
    private function quoteString($string)
588
    {
589
        return '\'' . $string . '\'';
590
    }
591
592
593
    /**
594
     * Get last compiled query
595
     *
596
     * @return string
597
     */
598
    public function getLastQuery()
599
    {
600
        return $this->sphinxQL->getCompiled();
601
    }
602
}