Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8.x] Implement Full-Text Search for MySQL & PostgreSQL #40129

Merged
merged 12 commits into from
Jan 6, 2022
19 changes: 19 additions & 0 deletions src/Illuminate/Database/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,25 @@ protected function addDynamic($segment, $connector, $parameters, $index)
$this->where(Str::snake($segment), '=', $parameters[$index], $bool);
}

/**
* Add a "where fulltext" clause to the query.
*
* @param string|string[] $columns
* @param string $value
* @param string $boolean
* @return $this
*/
public function whereFulltext($columns, $value, array $options = [], $boolean = 'and')
{
$type = 'Fulltext';

$columns = (array) $columns;

$this->wheres[] = compact('type', 'columns', 'value', 'options', 'boolean');

return $this;
driesvints marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Add a "group by" clause to the query.
*
Expand Down
12 changes: 12 additions & 0 deletions src/Illuminate/Database/Query/Grammars/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,18 @@ protected function compileJsonLength($column, $operator, $value)
throw new RuntimeException('This database engine does not support JSON length operations.');
}

/**
* Compile a "where fulltext" clause.
*
* @param \Illuminate\Database\Query\Builder $query
* @param array $where
* @return string
*/
public function whereFulltext(Builder $query, $where)
{
throw new RuntimeException('This database engine does not support fulltext search operations.');
driesvints marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Compile the "group by" portions of the query.
*
Expand Down
25 changes: 25 additions & 0 deletions src/Illuminate/Database/Query/Grammars/MySqlGrammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ protected function whereNotNull(Builder $query, $where)
return parent::whereNotNull($query, $where);
}

/**
* Compile a "where fulltext" clause.
*
* @param \Illuminate\Database\Query\Builder $query
* @param array $where
* @return string
*/
public function whereFulltext(Builder $query, $where)
{
$columns = $this->columnize($where['columns']);

$query->addBinding($where['value']);
driesvints marked this conversation as resolved.
Show resolved Hide resolved
$value = $this->parameter($where['value']);

$mode = ($where['options']['mode'] ?? []) === 'boolean'
? ' in boolean mode'
: ' in natural language mode';

$expanded = ($where['options']['expanded'] ?? []) && ($where['options']['mode'] ?? []) !== 'boolean'
? ' with query expansion'
: '';

return "match ({$columns}) against (".$value."{$mode}{$expanded})";
}

/**
* Compile an insert ignore statement into SQL.
*
Expand Down
47 changes: 47 additions & 0 deletions src/Illuminate/Database/Query/Grammars/PostgresGrammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,35 @@ protected function whereTime(Builder $query, $where)
return $this->wrap($where['column']).'::time '.$where['operator'].' '.$value;
}

/**
* Compile a "where fulltext" clause.
*
* @param \Illuminate\Database\Query\Builder $query
* @param array $where
* @return string
*/
public function whereFulltext(Builder $query, $where)
{
$language = $where['options']['language'] ?? 'english';

$columns = array_map(function ($column) use ($language, $query) {
return "to_tsvector({$this->bindParameter($query, $language)}, {$this->wrap($column)})";
}, $where['columns']);
$columns = implode(' || ', $columns);

$mode = 'plainto_tsquery';

if (($where['options']['mode'] ?? []) === 'phrase') {
$mode = 'phraseto_tsquery';
}

if (($where['options']['mode'] ?? []) === 'websearch') {
$mode = 'websearch_to_tsquery';
}

return "({$columns}) @@ {$mode}({$this->bindParameter($query, $language)}, {$this->bindParameter($query, $where['value'])})";
}

/**
* Compile a date based where clause.
*
Expand Down Expand Up @@ -517,4 +546,22 @@ protected function wrapJsonPathAttributes($path)
: "'$attribute'";
}, $path);
}

/**
* Binds parameter to query.
*
* @param Builder $query
* @param mixed $value
* @return mixed
*/
protected function bindParameter(Builder $query, $value)
{
if ($this->isExpression($value)) {
return $this->getValue($value);
}

$query->addBinding($value);
driesvints marked this conversation as resolved.
Show resolved Hide resolved

return '?';
}
}
12 changes: 5 additions & 7 deletions src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Fluent;
use RuntimeException;

class PostgresGrammar extends Grammar
{
Expand Down Expand Up @@ -190,15 +189,14 @@ public function compileFulltext(Blueprint $blueprint, Fluent $command)
{
$language = $command->language ?: 'english';

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

return sprintf('create index %s on %s using gin (to_tsvector(%s, %s))',
return sprintf('create index %s on %s using gin ((%s))',
$this->wrap($command->index),
$this->wrapTable($blueprint),
$this->quoteString($language),
$this->wrap($command->columns[0])
implode(' || ', $columns)
);
}

Expand Down
16 changes: 13 additions & 3 deletions tests/Database/DatabasePostgresSchemaGrammarTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,17 @@ public function testAddingFulltextIndex()
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());

$this->assertCount(1, $statements);
$this->assertSame('create index "users_body_fulltext" on "users" using gin (to_tsvector(\'english\', "body"))', $statements[0]);
$this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'english\', "body")))', $statements[0]);
}

public function testAddingFulltextIndexMultipleColumns()
{
$blueprint = new Blueprint('users');
$blueprint->fulltext(['body', 'title']);
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());

$this->assertCount(1, $statements);
$this->assertSame('create index "users_body_title_fulltext" on "users" using gin ((to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")))', $statements[0]);
}

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

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

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

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

public function testAddingSpatialIndex()
Expand Down
75 changes: 75 additions & 0 deletions tests/Database/DatabaseQueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Illuminate\Database\Query\Grammars\SQLiteGrammar;
use Illuminate\Database\Query\Grammars\SqlServerGrammar;
use Illuminate\Database\Query\Processors\MySqlProcessor;
use Illuminate\Database\Query\Processors\PostgresProcessor;
use Illuminate\Database\Query\Processors\Processor;
use Illuminate\Pagination\AbstractPaginator as Paginator;
use Illuminate\Pagination\Cursor;
Expand Down Expand Up @@ -858,6 +859,72 @@ public function testArrayWhereColumn()
$this->assertEquals([], $builder->getBindings());
}

public function testWhereFulltextMySql()
{
$builder = $this->getMySqlBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World');
$this->assertSame('select * from `users` where match (`body`) against (? in natural language mode)', $builder->toSql());
$this->assertEquals(['Hello World'], $builder->getBindings());

$builder = $this->getMySqlBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['expanded' => true]);
$this->assertSame('select * from `users` where match (`body`) against (? in natural language mode with query expansion)', $builder->toSql());
$this->assertEquals(['Hello World'], $builder->getBindings());

$builder = $this->getMySqlBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'boolean']);
$this->assertSame('select * from `users` where match (`body`) against (? in boolean mode)', $builder->toSql());
$this->assertEquals(['+Hello -World'], $builder->getBindings());

$builder = $this->getMySqlBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'boolean', 'expanded' => true]);
$this->assertSame('select * from `users` where match (`body`) against (? in boolean mode)', $builder->toSql());
$this->assertEquals(['+Hello -World'], $builder->getBindings());

$builder = $this->getMySqlBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext(['body', 'title'], 'Car,Plane');
$this->assertSame('select * from `users` where match (`body`, `title`) against (? in natural language mode)', $builder->toSql());
$this->assertEquals(['Car,Plane'], $builder->getBindings());
}

public function testWhereFulltextPostgres()
{
$builder = $this->getPostgresBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World');
$this->assertSame('select * from "users" where (to_tsvector(?, "body")) @@ plainto_tsquery(?, ?)', $builder->toSql());
$this->assertEquals(['english', 'english', 'Hello World'], $builder->getBindings());

$builder = $this->getPostgresBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['language' => 'simple']);
$this->assertSame('select * from "users" where (to_tsvector(?, "body")) @@ plainto_tsquery(?, ?)', $builder->toSql());
$this->assertEquals(['simple', 'simple', 'Hello World'], $builder->getBindings());

$builder = $this->getPostgresBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['mode' => 'plain']);
$this->assertSame('select * from "users" where (to_tsvector(?, "body")) @@ plainto_tsquery(?, ?)', $builder->toSql());
$this->assertEquals(['english', 'english', 'Hello World'], $builder->getBindings());

$builder = $this->getPostgresBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['mode' => 'phrase']);
$this->assertSame('select * from "users" where (to_tsvector(?, "body")) @@ phraseto_tsquery(?, ?)', $builder->toSql());
$this->assertEquals(['english', 'english', 'Hello World'], $builder->getBindings());

$builder = $this->getPostgresBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'websearch']);
$this->assertSame('select * from "users" where (to_tsvector(?, "body")) @@ websearch_to_tsquery(?, ?)', $builder->toSql());
$this->assertEquals(['english', 'english', '+Hello -World'], $builder->getBindings());

$builder = $this->getPostgresBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['language' => 'simple', 'mode' => 'plain']);
$this->assertSame('select * from "users" where (to_tsvector(?, "body")) @@ plainto_tsquery(?, ?)', $builder->toSql());
$this->assertEquals(['simple', 'simple', 'Hello World'], $builder->getBindings());

$builder = $this->getPostgresBuilderWithProcessor();
$builder->select('*')->from('users')->whereFulltext(['body', 'title'], 'Car Plane');
$this->assertSame('select * from "users" where (to_tsvector(?, "body") || to_tsvector(?, "title")) @@ plainto_tsquery(?, ?)', $builder->toSql());
$this->assertEquals(['english', 'english', 'english', 'Car Plane'], $builder->getBindings());
}

public function testUnions()
{
$builder = $this->getBuilder();
Expand Down Expand Up @@ -4312,6 +4379,14 @@ protected function getMySqlBuilderWithProcessor()
return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor);
}

protected function getPostgresBuilderWithProcessor()
{
$grammar = new PostgresGrammar;
$processor = new PostgresProcessor;

return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor);
}

/**
* @return \Mockery\MockInterface|\Illuminate\Database\Query\Builder
*/
Expand Down
69 changes: 69 additions & 0 deletions tests/Integration/Database/MySql/FulltextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace Illuminate\Tests\Integration\Database\MySql;

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
* @requires extension pdo_mysql
* @requires OS Linux|Darwin
*/
class FulltextTest extends MySqlTestCase
{
protected function defineDatabaseMigrationsAfterDatabaseRefreshed()
{
Schema::create('articles', function (Blueprint $table) {
$table->id('id');
$table->string('title', 200);
$table->text('body');
$table->fulltext(['title', 'body']);
});
}

protected function destroyDatabaseMigrations()
{
Schema::drop('articles');
}

protected function setUp(): void
{
parent::setUp();

DB::table('articles')->insert([
['title' => 'MySQL Tutorial', 'body' => 'DBMS stands for DataBase ...'],
['title' => 'How To Use MySQL Well', 'body' => 'After you went through a ...'],
['title' => 'Optimizing MySQL', 'body' => 'In this tutorial, we show ...'],
['title' => '1001 MySQL Tricks', 'body' => '1. Never run mysqld as root. 2. ...'],
['title' => 'MySQL vs. YourSQL', 'body' => 'In the following database comparison ...'],
['title' => 'MySQL Security', 'body' => 'When configured properly, MySQL ...'],
]);
}

/** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-natural-language.html */
public function testWhereFulltext()
{
$articles = DB::table('articles')->whereFulltext(['title', 'body'], 'database')->get();

$this->assertCount(2, $articles);
$this->assertSame('MySQL Tutorial', $articles[0]->title);
$this->assertSame('MySQL vs. YourSQL', $articles[1]->title);
}

/** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html */
public function testWhereFulltextWithBooleanMode()
{
$articles = DB::table('articles')->whereFulltext(['title', 'body'], '+MySQL -YourSQL', ['mode' => 'boolean'])->get();

$this->assertCount(5, $articles);
}

/** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-query-expansion.html */
public function testWhereFulltextWithExpandedQuery()
{
$articles = DB::table('articles')->whereFulltext(['title', 'body'], 'database', ['expanded' => true])->get();

$this->assertCount(6, $articles);
}
}
Loading