Skip to content

Commit d84e4c6

Browse files
authored
Support Full-Text Search for database. (#5765)
1 parent 9a7b876 commit d84e4c6

File tree

6 files changed

+237
-40
lines changed

6 files changed

+237
-40
lines changed

src/Query/Grammars/PostgresGrammar.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,34 @@ protected function whereTime(Builder $query, $where): string
218218
return $this->wrap($where['column']) . '::time ' . $where['operator'] . ' ' . $value;
219219
}
220220

221+
/**
222+
* Compile a "where fulltext" clause.
223+
*/
224+
protected function whereFullText(Builder $query, array $where): string
225+
{
226+
$language = $where['options']['language'] ?? 'english';
227+
228+
if (! in_array($language, $this->validFullTextLanguages())) {
229+
$language = 'english';
230+
}
231+
232+
$columns = collect($where['columns'])->map(function ($column) use ($language) {
233+
return "to_tsvector('{$language}', {$this->wrap($column)})";
234+
})->implode(' || ');
235+
236+
$mode = 'plainto_tsquery';
237+
238+
if (($where['options']['mode'] ?? []) === 'phrase') {
239+
$mode = 'phraseto_tsquery';
240+
}
241+
242+
if (($where['options']['mode'] ?? []) === 'websearch') {
243+
$mode = 'websearch_to_tsquery';
244+
}
245+
246+
return "({$columns}) @@ {$mode}('{$language}', {$this->parameter($where['value'])})";
247+
}
248+
221249
/**
222250
* Compile a date based where clause.
223251
*
@@ -479,4 +507,35 @@ protected function wrapJsonPathAttributes($path)
479507
: "'{$attribute}'";
480508
}, $path);
481509
}
510+
511+
/**
512+
* Get an array of valid full text languages.
513+
*/
514+
protected function validFullTextLanguages(): array
515+
{
516+
return [
517+
'simple',
518+
'arabic',
519+
'danish',
520+
'dutch',
521+
'english',
522+
'finnish',
523+
'french',
524+
'german',
525+
'hungarian',
526+
'indonesian',
527+
'irish',
528+
'italian',
529+
'lithuanian',
530+
'nepali',
531+
'norwegian',
532+
'portuguese',
533+
'romanian',
534+
'russian',
535+
'spanish',
536+
'swedish',
537+
'tamil',
538+
'turkish',
539+
];
540+
}
482541
}

src/Schema/Grammars/PostgresGrammar.php

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,16 +188,15 @@ public function compileFullText(Blueprint $blueprint, Fluent $command): string
188188
{
189189
$language = $command->language ?: 'english';
190190

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

195195
return sprintf(
196-
'create index %s on %s using gin (to_tsvector(%s, %s))',
196+
'create index %s on %s using gin ((%s))',
197197
$this->wrap($command->index),
198198
$this->wrapTable($blueprint),
199-
$this->quoteString($language),
200-
$this->wrap($command->columns[0])
199+
implode(' || ', $columns)
201200
);
202201
}
203202

tests/Cases/DatabasePostgresBuilderTest.php

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@
1212
namespace HyperfTest\Database\PgSQL\Cases;
1313

1414
use Hyperf\Database\Connection;
15+
use Hyperf\Database\ConnectionInterface;
16+
use Hyperf\Database\PgSQL\Query\Grammars\PostgresGrammar as PostgresQueryGrammar;
17+
use Hyperf\Database\PgSQL\Query\Processors\PostgresProcessor;
1518
use Hyperf\Database\PgSQL\Schema\Grammars\PostgresGrammar;
1619
use Hyperf\Database\PgSQL\Schema\PostgresBuilder;
20+
use Hyperf\Database\Query\Builder;
21+
use Hyperf\Database\Schema\Blueprint;
22+
use Hyperf\Database\Schema\Schema;
23+
use Hyperf\DbConnection\Db;
24+
use HyperfTest\Database\PgSQL\Stubs\ContainerStub;
1725
use Mockery as m;
1826
use PHPUnit\Framework\TestCase;
1927

@@ -25,6 +33,10 @@ class DatabasePostgresBuilderTest extends TestCase
2533
{
2634
protected function tearDown(): void
2735
{
36+
if (SWOOLE_MAJOR_VERSION >= 5) {
37+
ContainerStub::getContainer();
38+
Schema::dropIfExists('test_full_text_index');
39+
}
2840
m::close();
2941
}
3042

@@ -57,8 +69,94 @@ public function testDropDatabaseIfExists()
5769
$this->assertEquals(true, $builder->dropDatabaseIfExists('my_database_a'));
5870
}
5971

60-
protected function getBuilder($connection)
72+
public function testWhereFullText()
73+
{
74+
$builder = $this->getPostgresBuilderWithProcessor();
75+
$builder->select('*')->from('users')->whereFullText('body', 'Hello World');
76+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql());
77+
$this->assertEquals(['Hello World'], $builder->getBindings());
78+
79+
$builder = $this->getPostgresBuilderWithProcessor();
80+
$builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['language' => 'simple']);
81+
$this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql());
82+
$this->assertEquals(['Hello World'], $builder->getBindings());
83+
84+
$builder = $this->getPostgresBuilderWithProcessor();
85+
$builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['mode' => 'plain']);
86+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql());
87+
$this->assertEquals(['Hello World'], $builder->getBindings());
88+
89+
$builder = $this->getPostgresBuilderWithProcessor();
90+
$builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['mode' => 'phrase']);
91+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ phraseto_tsquery(\'english\', ?)', $builder->toSql());
92+
$this->assertEquals(['Hello World'], $builder->getBindings());
93+
94+
$builder = $this->getPostgresBuilderWithProcessor();
95+
$builder->select('*')->from('users')->whereFullText('body', '+Hello -World', ['mode' => 'websearch']);
96+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ websearch_to_tsquery(\'english\', ?)', $builder->toSql());
97+
$this->assertEquals(['+Hello -World'], $builder->getBindings());
98+
99+
$builder = $this->getPostgresBuilderWithProcessor();
100+
$builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['language' => 'simple', 'mode' => 'plain']);
101+
$this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql());
102+
$this->assertEquals(['Hello World'], $builder->getBindings());
103+
104+
$builder = $this->getPostgresBuilderWithProcessor();
105+
$builder->select('*')->from('users')->whereFullText(['body', 'title'], 'Car Plane');
106+
$this->assertSame('select * from "users" where (to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql());
107+
$this->assertEquals(['Car Plane'], $builder->getBindings());
108+
}
109+
110+
public function testWhereFullTextForReal()
111+
{
112+
if (SWOOLE_MAJOR_VERSION < 5) {
113+
$this->markTestSkipped('PostgreSql requires Swoole version >= 5.0.0');
114+
}
115+
116+
$container = ContainerStub::getContainer();
117+
$container->shouldReceive('get')->with(Db::class)->andReturn(new Db($container));
118+
119+
Schema::create('test_full_text_index', function (Blueprint $table) {
120+
$table->id('id');
121+
$table->string('title', 200);
122+
$table->text('body');
123+
$table->fullText(['title', 'body']);
124+
});
125+
126+
Db::table('test_full_text_index')->insert([
127+
['title' => 'PostgreSQL Tutorial', 'body' => 'DBMS stands for DataBase ...'],
128+
['title' => 'How To Use PostgreSQL Well', 'body' => 'After you went through a ...'],
129+
['title' => 'Optimizing PostgreSQL', 'body' => 'In this tutorial, we show ...'],
130+
['title' => '1001 PostgreSQL Tricks', 'body' => '1. Never run mysqld as root. 2. ...'],
131+
['title' => 'PostgreSQL vs. YourSQL', 'body' => 'In the following database comparison ...'],
132+
['title' => 'PostgreSQL Security', 'body' => 'When configured properly, PostgreSQL ...'],
133+
]);
134+
135+
$result = Db::table('test_full_text_index')->whereFulltext(['title', 'body'], 'database')->orderBy('id')->get();
136+
$this->assertCount(2, $result);
137+
$this->assertSame('PostgreSQL Tutorial', $result[0]['title']);
138+
$this->assertSame('PostgreSQL vs. YourSQL', $result[1]['title']);
139+
140+
$result = Db::table('test_full_text_index')->whereFulltext(['title', 'body'], '+PostgreSQL -YourSQL', ['mode' => 'websearch'])->get();
141+
$this->assertCount(5, $result);
142+
143+
$result = Db::table('test_full_text_index')->whereFulltext(['title', 'body'], 'PostgreSQL tutorial', ['mode' => 'plain'])->get();
144+
$this->assertCount(2, $result);
145+
146+
$result = Db::table('test_full_text_index')->whereFulltext(['title', 'body'], 'PostgreSQL tutorial', ['mode' => 'phrase'])->get();
147+
$this->assertCount(1, $result);
148+
}
149+
150+
protected function getBuilder($connection): PostgresBuilder
61151
{
62152
return new PostgresBuilder($connection);
63153
}
154+
155+
protected function getPostgresBuilderWithProcessor(): Builder
156+
{
157+
$grammar = new PostgresQueryGrammar();
158+
$processor = new PostgresProcessor();
159+
160+
return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor);
161+
}
64162
}

tests/Cases/DatabasePostgresSchemaGrammarTest.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ public function testAddingFullTextIndex()
281281
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
282282

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

287287
public function testAddingFullTextIndexWithLanguage()
@@ -291,7 +291,7 @@ public function testAddingFullTextIndexWithLanguage()
291291
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
292292

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

297297
public function testAddingSpatialIndex()
@@ -1043,6 +1043,16 @@ public function testGrammarsAreMacroable()
10431043
$this->assertTrue($c);
10441044
}
10451045

1046+
public function testAddingFulltextIndexMultipleColumns()
1047+
{
1048+
$blueprint = new Blueprint('users');
1049+
$blueprint->fulltext(['body', 'title']);
1050+
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
1051+
1052+
$this->assertCount(1, $statements);
1053+
$this->assertSame('create index "users_body_title_fulltext" on "users" using gin ((to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")))', $statements[0]);
1054+
}
1055+
10461056
protected function getConnection()
10471057
{
10481058
return m::mock(Connection::class);

tests/Cases/PostgreSqlSwooleExtConnectionTest.php

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,17 @@
1313

1414
use Exception;
1515
use Hyperf\Context\ApplicationContext;
16-
use Hyperf\Database\Connection;
17-
use Hyperf\Database\ConnectionResolver;
1816
use Hyperf\Database\ConnectionResolverInterface;
1917
use Hyperf\Database\Connectors\ConnectionFactory;
2018
use Hyperf\Database\Exception\QueryException;
2119
use Hyperf\Database\Migrations\DatabaseMigrationRepository;
2220
use Hyperf\Database\Migrations\Migrator;
23-
use Hyperf\Database\PgSQL\Connectors\PostgresSqlSwooleExtConnector;
24-
use Hyperf\Database\PgSQL\PostgreSqlSwooleExtConnection;
2521
use Hyperf\Database\Query\Builder;
2622
use Hyperf\Database\Schema\Schema;
2723
use Hyperf\Support\Filesystem\Filesystem;
24+
use HyperfTest\Database\PgSQL\Stubs\ContainerStub;
2825
use Mockery;
2926
use PHPUnit\Framework\TestCase;
30-
use Psr\Container\ContainerInterface;
3127
use Symfony\Component\Console\Style\OutputStyle;
3228

3329
/**
@@ -44,29 +40,7 @@ public function setUp(): void
4440
$this->markTestSkipped('PostgreSql requires Swoole version >= 5.0.0');
4541
}
4642

47-
$container = Mockery::mock(ContainerInterface::class);
48-
$container->shouldReceive('has')->andReturn(true);
49-
$container->shouldReceive('get')->with('db.connector.pgsql-swoole')->andReturn(new PostgresSqlSwooleExtConnector());
50-
$connector = new ConnectionFactory($container);
51-
52-
Connection::resolverFor('pgsql-swoole', static function ($connection, $database, $prefix, $config) {
53-
return new PostgreSqlSwooleExtConnection($connection, $database, $prefix, $config);
54-
});
55-
56-
$connection = $connector->make([
57-
'driver' => 'pgsql-swoole',
58-
'host' => '127.0.0.1',
59-
'port' => 5432,
60-
'database' => 'postgres',
61-
'username' => 'postgres',
62-
'password' => 'postgres',
63-
]);
64-
65-
$resolver = new ConnectionResolver(['default' => $connection]);
66-
67-
$container->shouldReceive('get')->with(ConnectionResolverInterface::class)->andReturn($resolver);
68-
69-
ApplicationContext::setContainer($container);
43+
$resolver = ContainerStub::getContainer()->get(ConnectionResolverInterface::class);
7044

7145
$this->migrator = new Migrator(
7246
$repository = new DatabaseMigrationRepository($resolver, 'migrations'),
@@ -84,6 +58,12 @@ public function setUp(): void
8458
}
8559
}
8660

61+
public function tearDown(): void
62+
{
63+
Schema::dropIfExists('password_resets_for_pgsql');
64+
Schema::dropIfExists('migrations');
65+
}
66+
8767
public function testSelectMethodDuplicateKeyValueException()
8868
{
8969
$connection = ApplicationContext::getContainer()->get(ConnectionResolverInterface::class)->connection();
@@ -163,9 +143,6 @@ public function testCreateTableForMigration()
163143

164144
$schema = new Schema();
165145

166-
$this->migrator->rollback([__DIR__ . '/../migrations/two']);
167-
$this->migrator->rollback([__DIR__ . '/../migrations/one']);
168-
169146
$this->migrator->run([__DIR__ . '/../migrations/one']);
170147
$this->assertTrue($schema->hasTable('password_resets_for_pgsql'));
171148
$this->assertSame('', $schema->connection()->selectOne($queryCommentSQL)['description'] ?? '');

tests/Stubs/ContainerStub.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* This file is part of Hyperf.
6+
*
7+
* @link https://www.hyperf.io
8+
* @document https://hyperf.wiki
9+
* @contact [email protected]
10+
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
11+
*/
12+
namespace HyperfTest\Database\PgSQL\Stubs;
13+
14+
use Hyperf\Context\ApplicationContext;
15+
use Hyperf\Database\Connection;
16+
use Hyperf\Database\ConnectionResolver;
17+
use Hyperf\Database\ConnectionResolverInterface;
18+
use Hyperf\Database\Connectors\ConnectionFactory;
19+
use Hyperf\Database\PgSQL\Connectors\PostgresSqlSwooleExtConnector;
20+
use Hyperf\Database\PgSQL\PostgreSqlSwooleExtConnection;
21+
use Mockery;
22+
use Psr\Container\ContainerInterface;
23+
24+
class ContainerStub
25+
{
26+
public static function getContainer()
27+
{
28+
$container = Mockery::mock(ContainerInterface::class);
29+
$container->shouldReceive('has')->andReturn(true);
30+
$container->shouldReceive('get')->with('db.connector.pgsql-swoole')->andReturn(new PostgresSqlSwooleExtConnector());
31+
$connector = new ConnectionFactory($container);
32+
33+
Connection::resolverFor('pgsql-swoole', static function ($connection, $database, $prefix, $config) {
34+
return new PostgreSqlSwooleExtConnection($connection, $database, $prefix, $config);
35+
});
36+
37+
$connection = $connector->make([
38+
'driver' => 'pgsql-swoole',
39+
'host' => '127.0.0.1',
40+
'port' => 5432,
41+
'database' => 'postgres',
42+
'username' => 'postgres',
43+
'password' => 'postgres',
44+
]);
45+
46+
$resolver = new ConnectionResolver(['default' => $connection]);
47+
48+
$container->shouldReceive('get')->with(ConnectionResolverInterface::class)->andReturn($resolver);
49+
50+
ApplicationContext::setContainer($container);
51+
52+
return $container;
53+
}
54+
}

0 commit comments

Comments
 (0)