Skip to content

Commit 26bfb14

Browse files
driesvintstpetrytaylorotwellStyleCIBot
authored
[8.x] Implement Full-Text Search for MySQL & PostgreSQL (#40129)
* Implement Full-Text Searches for MySQL * Refactor to use generic whereFulltext * Simplify value argument * postgresql whereFulltext support (#40229) * wip * Update PostgresGrammar.php * add orWhereFulltext * Fix bindings * wip * formatting * Apply fixes from StyleCI * formatting and capitalization in backwards compatible way Co-authored-by: Tobias Petry <[email protected]> Co-authored-by: Taylor Otwell <[email protected]> Co-authored-by: StyleCI Bot <[email protected]>
1 parent e70c996 commit 26bfb14

File tree

13 files changed

+392
-18
lines changed

13 files changed

+392
-18
lines changed

src/Illuminate/Database/Query/Builder.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1821,6 +1821,39 @@ protected function addDynamic($segment, $connector, $parameters, $index)
18211821
$this->where(Str::snake($segment), '=', $parameters[$index], $bool);
18221822
}
18231823

1824+
/**
1825+
* Add a "where fulltext" clause to the query.
1826+
*
1827+
* @param string|string[] $columns
1828+
* @param string $value
1829+
* @param string $boolean
1830+
* @return $this
1831+
*/
1832+
public function whereFullText($columns, $value, array $options = [], $boolean = 'and')
1833+
{
1834+
$type = 'Fulltext';
1835+
1836+
$columns = (array) $columns;
1837+
1838+
$this->wheres[] = compact('type', 'columns', 'value', 'options', 'boolean');
1839+
1840+
$this->addBinding($value);
1841+
1842+
return $this;
1843+
}
1844+
1845+
/**
1846+
* Add a "or where fulltext" clause to the query.
1847+
*
1848+
* @param string|string[] $columns
1849+
* @param string $value
1850+
* @return $this
1851+
*/
1852+
public function orWhereFullText($columns, $value, array $options = [])
1853+
{
1854+
return $this->whereFulltext($columns, $value, $options, 'or');
1855+
}
1856+
18241857
/**
18251858
* Add a "group by" clause to the query.
18261859
*

src/Illuminate/Database/Query/Grammars/Grammar.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,18 @@ protected function compileJsonLength($column, $operator, $value)
629629
throw new RuntimeException('This database engine does not support JSON length operations.');
630630
}
631631

632+
/**
633+
* Compile a "where fulltext" clause.
634+
*
635+
* @param \Illuminate\Database\Query\Builder $query
636+
* @param array $where
637+
* @return string
638+
*/
639+
public function whereFullText(Builder $query, $where)
640+
{
641+
throw new RuntimeException('This database engine does not support fulltext search operations.');
642+
}
643+
632644
/**
633645
* Compile the "group by" portions of the query.
634646
*

src/Illuminate/Database/Query/Grammars/MySqlGrammar.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ protected function whereNotNull(Builder $query, $where)
5050
return parent::whereNotNull($query, $where);
5151
}
5252

53+
/**
54+
* Compile a "where fulltext" clause.
55+
*
56+
* @param \Illuminate\Database\Query\Builder $query
57+
* @param array $where
58+
* @return string
59+
*/
60+
public function whereFullText(Builder $query, $where)
61+
{
62+
$columns = $this->columnize($where['columns']);
63+
64+
$value = $this->parameter($where['value']);
65+
66+
$mode = ($where['options']['mode'] ?? []) === 'boolean'
67+
? ' in boolean mode'
68+
: ' in natural language mode';
69+
70+
$expanded = ($where['options']['expanded'] ?? []) && ($where['options']['mode'] ?? []) !== 'boolean'
71+
? ' with query expansion'
72+
: '';
73+
74+
return "match ({$columns}) against (".$value."{$mode}{$expanded})";
75+
}
76+
5377
/**
5478
* Compile an insert ignore statement into SQL.
5579
*

src/Illuminate/Database/Query/Grammars/PostgresGrammar.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,71 @@ protected function dateBasedWhere($type, Builder $query, $where)
8585
return 'extract('.$type.' from '.$this->wrap($where['column']).') '.$where['operator'].' '.$value;
8686
}
8787

88+
/**
89+
* Compile a "where fulltext" clause.
90+
*
91+
* @param \Illuminate\Database\Query\Builder $query
92+
* @param array $where
93+
* @return string
94+
*/
95+
public function whereFullText(Builder $query, $where)
96+
{
97+
$language = $where['options']['language'] ?? 'english';
98+
99+
if (! in_array($language, $this->validFullTextLanguages())) {
100+
$language = 'english';
101+
}
102+
103+
$columns = collect($where['columns'])->map(function ($column) use ($language) {
104+
return "to_tsvector('{$language}', {$this->wrap($column)})";
105+
})->implode(' || ');
106+
107+
$mode = 'plainto_tsquery';
108+
109+
if (($where['options']['mode'] ?? []) === 'phrase') {
110+
$mode = 'phraseto_tsquery';
111+
}
112+
113+
if (($where['options']['mode'] ?? []) === 'websearch') {
114+
$mode = 'websearch_to_tsquery';
115+
}
116+
117+
return "({$columns}) @@ {$mode}('{$language}', {$this->parameter($where['value'])})";
118+
}
119+
120+
/**
121+
* Get an array of valid full text languages.
122+
*
123+
* @return array
124+
*/
125+
protected function validFullTextLanguages()
126+
{
127+
return [
128+
'simple',
129+
'arabic',
130+
'danish',
131+
'dutch',
132+
'english',
133+
'finnish',
134+
'french',
135+
'german',
136+
'hungarian',
137+
'indonesian',
138+
'irish',
139+
'italian',
140+
'lithuanian',
141+
'nepali',
142+
'norwegian',
143+
'portuguese',
144+
'romanian',
145+
'russian',
146+
'spanish',
147+
'swedish',
148+
'tamil',
149+
'turkish',
150+
];
151+
}
152+
88153
/**
89154
* Compile the "select *" portion of the query.
90155
*

src/Illuminate/Database/Schema/Blueprint.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ protected function addImpliedCommands(Grammar $grammar)
208208
protected function addFluentIndexes()
209209
{
210210
foreach ($this->columns as $column) {
211-
foreach (['primary', 'unique', 'index', 'fulltext', 'spatialIndex'] as $index) {
211+
foreach (['primary', 'unique', 'index', 'fulltext', 'fullText', 'spatialIndex'] as $index) {
212212
// If the index has been specified on the given column, but is simply equal
213213
// to "true" (boolean), no name has been specified for this index so the
214214
// index method can be called without a name and it will generate one.
@@ -373,9 +373,9 @@ public function dropIndex($index)
373373
* @param string|array $index
374374
* @return \Illuminate\Support\Fluent
375375
*/
376-
public function dropFulltext($index)
376+
public function dropFullText($index)
377377
{
378-
return $this->dropIndexCommand('dropFulltext', 'fulltext', $index);
378+
return $this->dropIndexCommand('dropFullText', 'fulltext', $index);
379379
}
380380

381381
/**
@@ -549,7 +549,7 @@ public function index($columns, $name = null, $algorithm = null)
549549
* @param string|null $algorithm
550550
* @return \Illuminate\Support\Fluent
551551
*/
552-
public function fulltext($columns, $name = null, $algorithm = null)
552+
public function fullText($columns, $name = null, $algorithm = null)
553553
{
554554
return $this->indexCommand('fulltext', $columns, $name, $algorithm);
555555
}

src/Illuminate/Database/Schema/Grammars/Grammar.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public function compileFulltext(Blueprint $blueprint, Fluent $command)
104104
* @param \Illuminate\Support\Fluent $command
105105
* @return string
106106
*/
107-
public function compileDropFulltext(Blueprint $blueprint, Fluent $command)
107+
public function compileDropFullText(Blueprint $blueprint, Fluent $command)
108108
{
109109
throw new RuntimeException('This database driver does not support fulltext index creation.');
110110
}

src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ public function compileIndex(Blueprint $blueprint, Fluent $command)
248248
* @param \Illuminate\Support\Fluent $command
249249
* @return string
250250
*/
251-
public function compileFulltext(Blueprint $blueprint, Fluent $command)
251+
public function compileFullText(Blueprint $blueprint, Fluent $command)
252252
{
253253
return $this->compileKey($blueprint, $command, 'fulltext');
254254
}
@@ -369,7 +369,7 @@ public function compileDropIndex(Blueprint $blueprint, Fluent $command)
369369
* @param \Illuminate\Support\Fluent $command
370370
* @return string
371371
*/
372-
public function compileDropFulltext(Blueprint $blueprint, Fluent $command)
372+
public function compileDropFullText(Blueprint $blueprint, Fluent $command)
373373
{
374374
return $this->compileDropIndex($blueprint, $command);
375375
}

src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
use Illuminate\Database\Schema\Blueprint;
66
use Illuminate\Support\Fluent;
7-
use RuntimeException;
87

98
class PostgresGrammar extends Grammar
109
{
@@ -190,15 +189,14 @@ public function compileFulltext(Blueprint $blueprint, Fluent $command)
190189
{
191190
$language = $command->language ?: 'english';
192191

193-
if (count($command->columns) > 1) {
194-
throw new RuntimeException('The PostgreSQL driver does not support fulltext index creation using multiple columns.');
195-
}
192+
$columns = array_map(function ($column) use ($language) {
193+
return "to_tsvector({$this->quoteString($language)}, {$this->wrap($column)})";
194+
}, $command->columns);
196195

197-
return sprintf('create index %s on %s using gin (to_tsvector(%s, %s))',
196+
return sprintf('create index %s on %s using gin ((%s))',
198197
$this->wrap($command->index),
199198
$this->wrapTable($blueprint),
200-
$this->quoteString($language),
201-
$this->wrap($command->columns[0])
199+
implode(' || ', $columns)
202200
);
203201
}
204202

@@ -392,7 +390,7 @@ public function compileDropIndex(Blueprint $blueprint, Fluent $command)
392390
* @param \Illuminate\Support\Fluent $command
393391
* @return string
394392
*/
395-
public function compileDropFulltext(Blueprint $blueprint, Fluent $command)
393+
public function compileDropFullText(Blueprint $blueprint, Fluent $command)
396394
{
397395
return $this->compileDropIndex($blueprint, $command);
398396
}

tests/Database/DatabasePostgresSchemaGrammarTest.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,17 @@ public function testAddingFulltextIndex()
269269
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
270270

271271
$this->assertCount(1, $statements);
272-
$this->assertSame('create index "users_body_fulltext" on "users" using gin (to_tsvector(\'english\', "body"))', $statements[0]);
272+
$this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'english\', "body")))', $statements[0]);
273+
}
274+
275+
public function testAddingFulltextIndexMultipleColumns()
276+
{
277+
$blueprint = new Blueprint('users');
278+
$blueprint->fulltext(['body', 'title']);
279+
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
280+
281+
$this->assertCount(1, $statements);
282+
$this->assertSame('create index "users_body_title_fulltext" on "users" using gin ((to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")))', $statements[0]);
273283
}
274284

275285
public function testAddingFulltextIndexWithLanguage()
@@ -279,7 +289,7 @@ public function testAddingFulltextIndexWithLanguage()
279289
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
280290

281291
$this->assertCount(1, $statements);
282-
$this->assertSame('create index "users_body_fulltext" on "users" using gin (to_tsvector(\'spanish\', "body"))', $statements[0]);
292+
$this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'spanish\', "body")))', $statements[0]);
283293
}
284294

285295
public function testAddingFulltextIndexWithFluency()
@@ -289,7 +299,7 @@ public function testAddingFulltextIndexWithFluency()
289299
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
290300

291301
$this->assertCount(2, $statements);
292-
$this->assertSame('create index "users_body_fulltext" on "users" using gin (to_tsvector(\'english\', "body"))', $statements[1]);
302+
$this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'english\', "body")))', $statements[1]);
293303
}
294304

295305
public function testAddingSpatialIndex()

tests/Database/DatabaseQueryBuilderTest.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Illuminate\Database\Query\Grammars\SQLiteGrammar;
1616
use Illuminate\Database\Query\Grammars\SqlServerGrammar;
1717
use Illuminate\Database\Query\Processors\MySqlProcessor;
18+
use Illuminate\Database\Query\Processors\PostgresProcessor;
1819
use Illuminate\Database\Query\Processors\Processor;
1920
use Illuminate\Pagination\AbstractPaginator as Paginator;
2021
use Illuminate\Pagination\Cursor;
@@ -858,6 +859,72 @@ public function testArrayWhereColumn()
858859
$this->assertEquals([], $builder->getBindings());
859860
}
860861

862+
public function testWhereFulltextMySql()
863+
{
864+
$builder = $this->getMySqlBuilderWithProcessor();
865+
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World');
866+
$this->assertSame('select * from `users` where match (`body`) against (? in natural language mode)', $builder->toSql());
867+
$this->assertEquals(['Hello World'], $builder->getBindings());
868+
869+
$builder = $this->getMySqlBuilderWithProcessor();
870+
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['expanded' => true]);
871+
$this->assertSame('select * from `users` where match (`body`) against (? in natural language mode with query expansion)', $builder->toSql());
872+
$this->assertEquals(['Hello World'], $builder->getBindings());
873+
874+
$builder = $this->getMySqlBuilderWithProcessor();
875+
$builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'boolean']);
876+
$this->assertSame('select * from `users` where match (`body`) against (? in boolean mode)', $builder->toSql());
877+
$this->assertEquals(['+Hello -World'], $builder->getBindings());
878+
879+
$builder = $this->getMySqlBuilderWithProcessor();
880+
$builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'boolean', 'expanded' => true]);
881+
$this->assertSame('select * from `users` where match (`body`) against (? in boolean mode)', $builder->toSql());
882+
$this->assertEquals(['+Hello -World'], $builder->getBindings());
883+
884+
$builder = $this->getMySqlBuilderWithProcessor();
885+
$builder->select('*')->from('users')->whereFulltext(['body', 'title'], 'Car,Plane');
886+
$this->assertSame('select * from `users` where match (`body`, `title`) against (? in natural language mode)', $builder->toSql());
887+
$this->assertEquals(['Car,Plane'], $builder->getBindings());
888+
}
889+
890+
public function testWhereFulltextPostgres()
891+
{
892+
$builder = $this->getPostgresBuilderWithProcessor();
893+
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World');
894+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql());
895+
$this->assertEquals(['Hello World'], $builder->getBindings());
896+
897+
$builder = $this->getPostgresBuilderWithProcessor();
898+
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['language' => 'simple']);
899+
$this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql());
900+
$this->assertEquals(['Hello World'], $builder->getBindings());
901+
902+
$builder = $this->getPostgresBuilderWithProcessor();
903+
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['mode' => 'plain']);
904+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql());
905+
$this->assertEquals(['Hello World'], $builder->getBindings());
906+
907+
$builder = $this->getPostgresBuilderWithProcessor();
908+
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['mode' => 'phrase']);
909+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ phraseto_tsquery(\'english\', ?)', $builder->toSql());
910+
$this->assertEquals(['Hello World'], $builder->getBindings());
911+
912+
$builder = $this->getPostgresBuilderWithProcessor();
913+
$builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'websearch']);
914+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ websearch_to_tsquery(\'english\', ?)', $builder->toSql());
915+
$this->assertEquals(['+Hello -World'], $builder->getBindings());
916+
917+
$builder = $this->getPostgresBuilderWithProcessor();
918+
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['language' => 'simple', 'mode' => 'plain']);
919+
$this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql());
920+
$this->assertEquals(['Hello World'], $builder->getBindings());
921+
922+
$builder = $this->getPostgresBuilderWithProcessor();
923+
$builder->select('*')->from('users')->whereFulltext(['body', 'title'], 'Car Plane');
924+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql());
925+
$this->assertEquals(['Car Plane'], $builder->getBindings());
926+
}
927+
861928
public function testUnions()
862929
{
863930
$builder = $this->getBuilder();
@@ -4312,6 +4379,14 @@ protected function getMySqlBuilderWithProcessor()
43124379
return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor);
43134380
}
43144381

4382+
protected function getPostgresBuilderWithProcessor()
4383+
{
4384+
$grammar = new PostgresGrammar;
4385+
$processor = new PostgresProcessor;
4386+
4387+
return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor);
4388+
}
4389+
43154390
/**
43164391
* @return \Mockery\MockInterface|\Illuminate\Database\Query\Builder
43174392
*/

0 commit comments

Comments
 (0)