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

MatchBuilder::before()   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
		if ($token instanceof Expression) {
557
			return $token->value().' ';
558
		}
559
		if ($token instanceof self) {
560
			return '('.$token->compile()->getCompiled().') ';
561
		}
562
		if ($token instanceof Closure) {
563
			$sub = new static($this->sphinxql);
564
			$token($sub);
565
			return '('.$sub->compile()->getCompiled().') ';
566
		}
567
		if (strpos($token, ' ') === false) {
568
			return $this->sphinxql->escapeMatch($token).' ';
569
		}
570
		return '('.$this->sphinxql->escapeMatch($token).') ';
571
	}
572
573
	private function compileField($token,$fields,$limit): string{
574
		$query = $token;
575
576
		if (count($fields) === 1) {
577
			$query .= $fields[0];
578
		} else {
579
			$query .= '('.implode(',', $fields).')';
580
		}
581
		if ($limit) {
582
			$query .= '['.$limit.']';
583
		}
584
		$query .= ' ';
585
586
		return $query;
587
	}
588
589
	/**
590
	 * Returns the latest compiled match expression.
591
	 *
592
	 * @return string The last compiled match expression.
593
	 */
594
	public function getCompiled(): string
595
	{
596
		return $this->last_compiled;
597
	}
598
}
599