Passed
Pull Request — master (#197)
by
unknown
01:23
created

MatchBuilder::exact()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 1
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Foolz\SphinxQL;
4
5
use Closure;
6
7
/**
8
 * Query Builder class for MatchBuilder statements.
9
 */
10
class MatchBuilder
11
{
12
    /**
13
     * The last compiled query.
14
     *
15
     * @var string
16
     */
17
    protected $last_compiled;
18
19
    /**
20
     * List of match operations.
21
     *
22
     * @var array
23
     */
24
    protected $tokens = array();
25
26
    /**
27
     * The owning SphinxQL object; used for escaping text.
28
     *
29
     * @var SphinxQL
30
     */
31
    protected $sphinxql;
32
33
    /**
34
     * @param SphinxQL $sphinxql
35
     */
36
    public function __construct(SphinxQL $sphinxql)
37
    {
38
        $this->sphinxql = $sphinxql;
39
    }
40
41
    /**
42
     * Match text or sub expression.
43
     *
44
     * Examples:
45
     *    $match->match('test');
46
     *    // test
47
     *
48
     *    $match->match('test case');
49
     *    // (test case)
50
     *
51
     *    $match->match(function ($m) {
52
     *        $m->match('a')->orMatch('b');
53
     *    });
54
     *    // (a | b)
55
     *
56
     *    $sub = new MatchBuilder($sphinxql);
57
     *    $sub->match('a')->orMatch('b');
58
     *    $match->match($sub);
59
     *    // (a | b)
60
     *
61
     * @param string|MatchBuilder|\Closure $keywords The text or expression to match.
62
     *
63
     * @return $this
64
     */
65
    public function match($keywords = null): self
66
    {
67
        if ($keywords !== null) {
68
            $this->tokens[] = array('MATCH' => $keywords);
69
        }
70
71
        return $this;
72
    }
73
74
    /**
75
     * Provide an alternation match.
76
     *
77
     * Examples:
78
     *    $match->match('test')->orMatch();
79
     *    // test |
80
     *
81
     *    $match->match('test')->orMatch('case');
82
     *    // test | case
83
     *
84
     * @param string|MatchBuilder|\Closure $keywords The text or expression to alternatively match.
85
     *
86
     * @return $this
87
     */
88
    public function orMatch($keywords = null): self
89
    {
90
        $this->tokens[] = array('OPERATOR' => '| ');
91
        $this->match($keywords);
92
93
        return $this;
94
    }
95
96
    /**
97
     * Provide an optional match.
98
     *
99
     * Examples:
100
     *    $match->match('test')->maybe();
101
     *    // test MAYBE
102
     *
103
     *    $match->match('test')->maybe('case');
104
     *    // test MAYBE case
105
     *
106
     * @param string|MatchBuilder|\Closure $keywords The text or expression to optionally match.
107
     *
108
     * @return $this
109
     */
110
    public function maybe($keywords = null): self
111
    {
112
        $this->tokens[] = array('OPERATOR' => 'MAYBE ');
113
        $this->match($keywords);
114
115
        return $this;
116
    }
117
118
    /**
119
     * Do not match a keyword.
120
     *
121
     * Examples:
122
     *    $match->not()->match('test');
123
     *    // -test
124
     *
125
     *    $match->not('test');
126
     *    // -test
127
     *
128
     * @param string|MatchBuilder|\Closure $keyword The word not to match.
129
     *
130
     * @return $this
131
     */
132
    public function not($keyword = null): self
133
    {
134
        $this->tokens[] = array('OPERATOR' => '-');
135
        $this->match($keyword);
136
137
        return $this;
138
    }
139
140
    /**
141
     * Specify which field(s) to search.
142
     *
143
     * Examples:
144
     *    $match->field('*')->match('test');
145
     *    // @* test
146
     *
147
     *    $match->field('title')->match('test');
148
     *    // @title test
149
     *
150
     *    $match->field('body', 50)->match('test');
151
     *    // @body[50] test
152
     *
153
     *    $match->field('title', 'body')->match('test');
154
     *    // @(title,body) test
155
     *
156
     *    $match->field(['title', 'body'])->match('test');
157
     *    // @(title,body) test
158
     *
159
     *    $match->field('@relaxed')->field('nosuchfield')->match('test');
160
     *    // @@relaxed @nosuchfield test
161
     *
162
     * @param string|array $fields Field or fields to search.
163
     * @param int          $limit  Maximum position limit in field a match is allowed at.
164
     *
165
     * @return $this
166
     */
167
    public function field($fields, $limit = null): self
168
    {
169
        if (is_string($fields)) {
170
            $fields = func_get_args();
171
            $limit = null;
172
        }
173
        if (!is_string(end($fields))) {
174
            $limit = array_pop($fields);
175
        }
176
        $this->tokens[] = array(
177
            'FIELD'  => '@',
178
            'fields' => $fields,
179
            'limit'  => $limit,
180
        );
181
182
        return $this;
183
    }
184
185
    /**
186
     * Specify which field(s) not to search.
187
     *
188
     * Examples:
189
     *    $match->ignoreField('title')->match('test');
190
     *    // @!title test
191
     *
192
     *    $match->ignoreField('title', 'body')->match('test');
193
     *    // @!(title,body) test
194
     *
195
     *    $match->ignoreField(['title', 'body'])->match('test');
196
     *    // @!(title,body) test
197
     *
198
     * @param string|array $fields Field or fields to ignore during search.
199
     *
200
     * @return $this
201
     */
202
    public function ignoreField($fields): self
203
    {
204
        if (is_string($fields)) {
205
            $fields = func_get_args();
206
        }
207
        $this->tokens[] = array(
208
            'FIELD'  => '@!',
209
            'fields' => $fields,
210
            'limit'  => null,
211
        );
212
213
        return $this;
214
    }
215
216
    /**
217
     * Match an exact phrase.
218
     *
219
     * Example:
220
     *    $match->phrase('test case');
221
     *    // "test case"
222
     *
223
     * @param string $keywords The phrase to match.
224
     *
225
     * @return $this
226
     */
227
    public function phrase($keywords): self
228
    {
229
        $this->tokens[] = array('PHRASE' => $keywords);
230
231
        return $this;
232
    }
233
234
    /**
235
     * Provide an optional phrase.
236
     *
237
     * Example:
238
     *    $match->phrase('test case')->orPhrase('another case');
239
     *    // "test case" | "another case"
240
     *
241
     * @param string $keywords The phrase to match.
242
     *
243
     * @return $this
244
     */
245
    public function orPhrase($keywords): self
246
    {
247
        $this->tokens[] = array('OPERATOR' => '| ');
248
        $this->phrase($keywords);
249
250
        return $this;
251
    }
252
253
    /**
254
     * Match if keywords are close enough.
255
     *
256
     * Example:
257
     *    $match->proximity('test case', 5);
258
     *    // "test case"~5
259
     *
260
     * @param string $keywords The words to match.
261
     * @param int    $distance The upper limit on separation between words.
262
     *
263
     * @return $this
264
     */
265
    public function proximity($keywords, $distance): self
266
    {
267
        $this->tokens[] = array(
268
            'PROXIMITY' => $distance,
269
            'keywords'  => $keywords,
270
        );
271
272
        return $this;
273
    }
274
275
    /**
276
     * Match if enough keywords are present.
277
     *
278
     * Examples:
279
     *    $match->quorum('this is a test case', 3);
280
     *    // "this is a test case"/3
281
     *
282
     *    $match->quorum('this is a test case', 0.5);
283
     *    // "this is a test case"/0.5
284
     *
285
     * @param string    $keywords  The words to match.
286
     * @param int|float $threshold The minimum number or percent of words that must match.
287
     *
288
     * @return $this
289
     */
290
    public function quorum($keywords, $threshold): self
291
    {
292
        $this->tokens[] = array(
293
            'QUORUM'   => $threshold,
294
            'keywords' => $keywords,
295
        );
296
297
        return $this;
298
    }
299
300
    /**
301
     * Assert keywords or expressions must be matched in order.
302
     *
303
     * Examples:
304
     *    $match->match('test')->before();
305
     *    // test <<
306
     *
307
     *    $match->match('test')->before('case');
308
     *    // test << case
309
     *
310
     * @param string|MatchBuilder|\Closure $keywords The text or expression that must come after.
311
     *
312
     * @return $this
313
     */
314
    public function before($keywords = null): self
315
    {
316
        $this->tokens[] = array('OPERATOR' => '<< ');
317
        $this->match($keywords);
318
319
        return $this;
320
    }
321
322
    /**
323
     * Assert a keyword must be matched exactly as written.
324
     *
325
     * Examples:
326
     *    $match->match('test')->exact('cases');
327
     *    // test =cases
328
     *
329
     *    $match->match('test')->exact()->phrase('specific cases');
330
     *    // test ="specific cases"
331
     *
332
     * @param string $keyword The word that must be matched exactly.
333
     *
334
     * @return $this
335
     */
336
    public function exact($keyword = null): self
337
    {
338
        $this->tokens[] = array('OPERATOR' => '=');
339
        $this->match($keyword);
340
341
        return $this;
342
    }
343
344
    /**
345
     * Boost the IDF score of a keyword.
346
     *
347
     * Examples:
348
     *    $match->match('test')->boost(1.2);
349
     *    // test^1.2
350
     *
351
     *    $match->match('test')->boost('case', 1.2);
352
     *    // test case^1.2
353
     *
354
     * @param string $keyword The word to modify the score of.
355
     * @param float  $amount  The amount to boost the score.
356
     *
357
     * @return $this
358
     */
359
    public function boost($keyword, $amount = null): self
360
    {
361
        if ($amount === null) {
362
            $amount = $keyword;
363
        } else {
364
            $this->match($keyword);
365
        }
366
        $this->tokens[] = array('BOOST' => $amount);
367
368
        return $this;
369
    }
370
371
    /**
372
     * Assert keywords or expressions must be matched close to each other.
373
     *
374
     * Examples:
375
     *    $match->match('test')->near(3);
376
     *    // test NEAR/3
377
     *
378
     *    $match->match('test')->near('case', 3);
379
     *    // test NEAR/3 case
380
     *
381
     * @param string|MatchBuilder|\Closure $keywords The text or expression to match nearby.
382
     * @param int                   $distance Maximum distance to the match.
383
     *
384
     * @return $this
385
     */
386
    public function near($keywords, $distance = null): self
387
    {
388
        $this->tokens[] = array('NEAR' => $distance ?: $keywords);
389
        if ($distance !== null) {
390
            $this->match($keywords);
391
        }
392
393
        return $this;
394
    }
395
396
    /**
397
     * Assert matches must be in the same sentence.
398
     *
399
     * Examples:
400
     *    $match->match('test')->sentence();
401
     *    // test SENTENCE
402
     *
403
     *    $match->match('test')->sentence('case');
404
     *    // test SENTENCE case
405
     *
406
     * @param string|MatchBuilder|\Closure $keywords The text or expression that must be in the sentence.
407
     *
408
     * @return $this
409
     */
410
    public function sentence($keywords = null): self
411
    {
412
        $this->tokens[] = array('OPERATOR' => 'SENTENCE ');
413
        $this->match($keywords);
414
415
        return $this;
416
    }
417
418
    /**
419
     * Assert matches must be in the same paragraph.
420
     *
421
     * Examples:
422
     *    $match->match('test')->paragraph();
423
     *    // test PARAGRAPH
424
     *
425
     *    $match->match('test')->paragraph('case');
426
     *    // test PARAGRAPH case
427
     *
428
     * @param string|MatchBuilder|\Closure $keywords The text or expression that must be in the paragraph.
429
     *
430
     * @return $this
431
     */
432
    public function paragraph($keywords = null): self
433
    {
434
        $this->tokens[] = array('OPERATOR' => 'PARAGRAPH ');
435
        $this->match($keywords);
436
437
        return $this;
438
    }
439
440
    /**
441
     * Assert matches must be in the specified zone(s).
442
     *
443
     * Examples:
444
     *    $match->zone('th');
445
     *    // ZONE:(th)
446
     *
447
     *    $match->zone(['h3', 'h4']);
448
     *    // ZONE:(h3,h4)
449
     *
450
     *    $match->zone('th', 'test');
451
     *    // ZONE:(th) test
452
     *
453
     * @param string|array          $zones    The zone or zones to search.
454
     * @param string|MatchBuilder|\Closure $keywords The text or expression that must be in these zones.
455
     *
456
     * @return $this
457
     */
458
    public function zone($zones, $keywords = null): self
459
    {
460
        if (is_string($zones)) {
461
            $zones = array($zones);
462
        }
463
        $this->tokens[] = array('ZONE' => $zones);
464
        $this->match($keywords);
465
466
        return $this;
467
    }
468
469
470
    /**
471
     * Assert matches must be in the same instance of the specified zone.
472
     *
473
     * Examples:
474
     *    $match->zonespan('th');
475
     *    // ZONESPAN:(th)
476
     *
477
     *    $match->zonespan('th', 'test');
478
     *    // ZONESPAN:(th) test
479
     *
480
     * @param string                $zone     The zone to search.
481
     * @param string|MatchBuilder|\Closure $keywords The text or expression that must be in this zone.
482
     *
483
     * @return $this
484
     */
485
    public function zonespan($zone, $keywords = null): self
486
    {
487
        $this->tokens[] = array('ZONESPAN' => $zone);
488
        $this->match($keywords);
489
490
        return $this;
491
    }
492
493
    /**
494
     * Build the match expression.
495
     * @return $this
496
     */
497
    public function compile(): self
498
    {
499
        $query = '';
500
501
        foreach ($this->tokens as $token) {
502
            $tokenKey = key($token);
503
504
            switch ($tokenKey) {
505
                case 'MATCH':{
506
                    $query .= $this->compileMatch($token['MATCH']);
507
                    break;
508
                }
509
                case 'OPERATOR':{
510
                    $query .= $token['OPERATOR'];
511
                    break;
512
                }
513
                case 'FIELD':{
514
                    $query .= $this->compileField($token['FIELD'], $token['fields'], $token['limit']);
515
                    break;
516
                }
517
                case 'PHRASE':{
518
                    $query .= '"'.$this->sphinxql->escapeMatch($token['PHRASE']).'" ';
519
                    break;
520
                }
521
                case 'PROXIMITY':{
522
                    $query .= '"'.$this->sphinxql->escapeMatch($token['keywords']).'"~';
523
                    $query .= $token['PROXIMITY'].' ';
524
                    break;
525
                }
526
                case 'QUORUM':{
527
                    $query .= '"'.$this->sphinxql->escapeMatch($token['keywords']).'"/';
528
                    $query .= $token['QUORUM'].' ';
529
                    break;
530
                }
531
                case 'BOOST':{
532
                    $query = rtrim($query).'^'.$token['BOOST'].' ';
533
                    break;
534
                }
535
                case 'NEAR':{
536
                    $query .= 'NEAR/'.$token['NEAR'].' ';
537
                    break;
538
                }
539
                case 'ZONE':{
540
                    $query .= 'ZONE:('.implode(',', $token['ZONE']).') ';
541
                    break;
542
                }
543
                case 'ZONESPAN':{
544
                    $query .= 'ZONESPAN:('.$token['ZONESPAN'].') ';
545
                    break;
546
                }
547
            }
548
        }
549
550
        $this->last_compiled = trim($query);
551
552
        return $this;
553
    }
554
555
    private function compileMatch($token): string
556
    {
557
        if ($token instanceof Expression) {
558
            return $token->value().' ';
559
        }
560
        if ($token instanceof self) {
561
            return '('.$token->compile()->getCompiled().') ';
562
        }
563
        if ($token instanceof Closure) {
564
            $sub = new static($this->sphinxql);
565
            $token($sub);
566
            return '('.$sub->compile()->getCompiled().') ';
567
        }
568
        if (strpos($token, ' ') === false) {
569
            return $this->sphinxql->escapeMatch($token).' ';
570
        }
571
        return '('.$this->sphinxql->escapeMatch($token).') ';
572
    }
573
574
    private function compileField($token, $fields, $limit): string
575
    {
576
        $query = $token;
577
578
        if (count($fields) === 1) {
579
            $query .= $fields[0];
580
        } else {
581
            $query .= '('.implode(',', $fields).')';
582
        }
583
        if ($limit) {
584
            $query .= '['.$limit.']';
585
        }
586
        $query .= ' ';
587
588
        return $query;
589
    }
590
591
    /**
592
     * Returns the latest compiled match expression.
593
     *
594
     * @return string The last compiled match expression.
595
     */
596
    public function getCompiled(): string
597
    {
598
        return $this->last_compiled;
599
    }
600
}
601