Skip to content

Commit c95426a

Browse files
abnegateclaude
andcommitted
fix(search): split search terms into words at every separator as 7.x did
The query builders strip only their engine's operator characters from a search term, where 7.x kept letters, numbers, underscores and whitespace and turned everything else into a word break. On PostgreSQL the leftover punctuation reaches websearch_to_tsquery(): `foo/bar` parses as a single file-path token that no document's tsvector (built from the text with non-word characters replaced) can contain, so the search matched nothing, and `foo,bar` (also `;`, `?`, `#`, `%`, `=`, `:`) becomes the phrase `foo <-> bar` instead of `foo | bar`. `foo_bar` is unchanged: 7.x kept the underscore as well. The database builders now prepare the term with the 7.x whitelist before the query builder formats it, so PostgreSQL ORs the words again. MariaDB and MySQL use the same preparation, which makes their bound term identical to 7.x instead of relying on InnoDB's tokenizer to discard the punctuation the builder leaves in (the case 7.x's whitelist was added for). SQLite already prepares terms with the 7.x whitelist. The term is still only ever bound, never written into the statement, and the fix does not wait for a query library release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a99400f commit c95426a

7 files changed

Lines changed: 252 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,10 @@ not change anything for an upgrade from 7.x.
606606
`Exception\Query`.
607607
- A vector search ordered by a joined attribute pages with a cursor on PostgreSQL.
608608
- On SQLite, `count()` and `sum()` next to a fulltext `search()` apply the search instead of throwing.
609+
- A fulltext `search()` or `notSearch()` term splits into words at every character other than a letter, a number,
610+
an underscore or whitespace, as in 7.x. On PostgreSQL `search('title', 'foo/bar')` matched nothing and
611+
`search('title', 'foo,bar')` (or `;`, `?`, `#`, `%`, `=`, `:`) matched only `foo` directly followed by `bar`;
612+
both again match either word. MariaDB and MySQL bind the same term as 7.x.
609613
- A `distinct()` read whose select names only joined columns returns each of them once, under its alias, and
610614
accepts a joined internal attribute (`alias.$id`).
611615
- A filter on a joined column is checked against the joined collection's attribute as a filter on the main

‎src/Database/Builder/MariaDB.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
use Utopia\Query\Builder\MariaDB as Base;
66

77
/**
8-
* The MariaDB builder, which also compiles filters on their own.
8+
* The MariaDB builder, which also compiles filters on their own and prepares search terms as 7.x did.
99
*/
1010
class MariaDB extends Base implements Filtering
1111
{
1212
use CompilesFilters;
13+
use PreparesSearchTerms;
1314
}

‎src/Database/Builder/MySQL.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
use Utopia\Query\Builder\MySQL as Base;
66

77
/**
8-
* The MySQL builder, which also compiles filters on their own.
8+
* The MySQL builder, which also compiles filters on their own and prepares search terms as 7.x did.
99
*/
1010
class MySQL extends Base implements Filtering
1111
{
1212
use CompilesFilters;
13+
use PreparesSearchTerms;
1314
}

‎src/Database/Builder/PostgreSQL.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
use Utopia\Query\Schema\ColumnType;
1010

1111
/**
12-
* The PostgreSQL builder, which also compiles filters on their own, and writes a filter on a path
13-
* into an object attribute only when every key of the path is a plain key.
12+
* The PostgreSQL builder, which also compiles filters on their own, prepares search terms as 7.x did, and writes a
13+
* filter on a path into an object attribute only when every key of the path is a plain key.
1414
*/
1515
class PostgreSQL extends Base implements Filtering
1616
{
1717
use CompilesFilters;
18+
use PreparesSearchTerms;
1819

1920
/**
2021
* @throws QueryException
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace Utopia\Database\Builder;
4+
5+
/**
6+
* Prepares a fulltext search term as 7.x did: every character other than a letter, a number, an underscore or
7+
* whitespace separates words, so `foo/bar` and `foo,bar` search the words `foo` and `bar`. A term wrapped in double
8+
* quotes stays an exact search.
9+
*/
10+
trait PreparesSearchTerms
11+
{
12+
private const string SEARCH_QUOTE = '"';
13+
14+
private const string SEARCH_SEPARATORS = '/[^\p{L}\p{N}_\s]/u';
15+
16+
private const string SEARCH_WHITESPACE = '/\s+/';
17+
18+
/**
19+
* @param array<mixed> $values
20+
*/
21+
#[\Override]
22+
protected function compileSearchExpr(string $attribute, array $values, bool $not): string
23+
{
24+
$term = $values[0] ?? '';
25+
26+
return parent::compileSearchExpr($attribute, [$this->prepareSearchTerm(\is_string($term) ? $term : '')], $not);
27+
}
28+
29+
private function prepareSearchTerm(string $term): string
30+
{
31+
$exact = \str_starts_with($term, self::SEARCH_QUOTE) && \str_ends_with($term, self::SEARCH_QUOTE);
32+
33+
$words = \preg_replace(self::SEARCH_SEPARATORS, ' ', $term) ?? '';
34+
$words = \trim(\preg_replace(self::SEARCH_WHITESPACE, ' ', $words) ?? '');
35+
36+
if ($exact && $words !== '') {
37+
return self::SEARCH_QUOTE.$words.self::SEARCH_QUOTE;
38+
}
39+
40+
return $words;
41+
}
42+
}

‎tests/e2e/Adapter/Scopes/DocumentTests.php‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1870,6 +1870,82 @@ public function testFindFulltextAccentedAndSpecialChars(): void
18701870
$this->assertGreaterThanOrEqual(1, count($documents));
18711871
}
18721872

1873+
public function testFindFulltextSeparatorsSplitWords(): void
1874+
{
1875+
/** @var Database $database */
1876+
$database = $this->getDatabase();
1877+
1878+
if (! $database->getAdapter()->supports(Capability::Fulltext)) {
1879+
$this->expectNotToPerformAssertions();
1880+
1881+
return;
1882+
}
1883+
1884+
$collection = 'full_text_separators';
1885+
$database->createCollection(new Collection(id: $collection, permissions: [
1886+
Permission::create(Role::any()),
1887+
Permission::read(Role::any()),
1888+
]));
1889+
1890+
$this->assertTrue($database->createAttribute($collection, Attribute::string(key: 'text', size: 128, required: true)));
1891+
$this->assertTrue($database->createIndex($collection, Index::fullText(key: 'text-ft', attributes: ['text'])));
1892+
1893+
$texts = [
1894+
'lunar' => 'lunar',
1895+
'solar' => 'solar',
1896+
'space' => 'lunar solar',
1897+
'slash' => 'lunar/solar',
1898+
'comma' => 'lunar,solar',
1899+
'underscore' => 'lunar_solar',
1900+
'comet' => 'comet',
1901+
'orbit' => 'orbit',
1902+
];
1903+
foreach ($texts as $id => $text) {
1904+
$database->createDocument($collection, new Document([
1905+
'$id' => $id,
1906+
'$permissions' => [Permission::read(Role::any())],
1907+
'text' => $text,
1908+
]));
1909+
}
1910+
1911+
$eitherWord = ['comma', 'lunar', 'slash', 'solar', 'space'];
1912+
1913+
foreach (['lunar/solar', 'lunar,solar'] as $term) {
1914+
$found = $this->searchedIds($database, $collection, $term);
1915+
1916+
foreach ($eitherWord as $id) {
1917+
$this->assertContains($id, $found, "search('{$term}') finds the document '{$texts[$id]}'");
1918+
}
1919+
$this->assertNotContains('comet', $found, "search('{$term}')");
1920+
$this->assertSame($this->searchedIds($database, $collection, 'lunar solar'), $found, "search('{$term}') searches the words as 'lunar solar' does");
1921+
}
1922+
1923+
$found = $this->searchedIds($database, $collection, 'comet, lunar/solar');
1924+
foreach (['comet', ...$eitherWord] as $id) {
1925+
$this->assertContains($id, $found, "search('comet, lunar/solar') finds the document '{$texts[$id]}'");
1926+
}
1927+
$this->assertNotContains('orbit', $found);
1928+
$this->assertSame($this->searchedIds($database, $collection, 'comet lunar solar'), $found);
1929+
1930+
$found = $this->searchedIds($database, $collection, 'lunar_solar');
1931+
$this->assertContains('underscore', $found);
1932+
$this->assertNotContains('comet', $found);
1933+
}
1934+
1935+
/**
1936+
* @return list<string>
1937+
*/
1938+
private function searchedIds(Database $database, string $collection, string $term): array
1939+
{
1940+
$ids = \array_map(
1941+
fn (Document $document): string => $document->getId(),
1942+
$database->find($collection, [Query::search('text', $term)]),
1943+
);
1944+
\sort($ids);
1945+
1946+
return $ids;
1947+
}
1948+
18731949
public function testFindByID(): void
18741950
{
18751951
$this->initMoviesFixture();
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
namespace Tests\Unit\Builder;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use PHPUnit\Framework\TestCase;
7+
use Utopia\Database\Builder\Filtering;
8+
use Utopia\Database\Builder\MariaDB;
9+
use Utopia\Database\Builder\MySQL;
10+
use Utopia\Database\Builder\PostgreSQL;
11+
use Utopia\Database\Query;
12+
13+
final class SearchTermTest extends TestCase
14+
{
15+
/**
16+
* @return array<string, array{string, string}>
17+
*/
18+
public static function postgreSQLTerms(): array
19+
{
20+
return [
21+
'slash separates words' => ['foo/bar', 'foo or bar'],
22+
'comma separates words' => ['foo,bar', 'foo or bar'],
23+
'semicolon separates words' => ['foo;bar', 'foo or bar'],
24+
'percent separates words' => ['foo%bar', 'foo or bar'],
25+
'equals separates words' => ['foo=bar', 'foo or bar'],
26+
'question mark separates words' => ['foo?bar', 'foo or bar'],
27+
'hash separates words' => ['foo#bar', 'foo or bar'],
28+
'colon separates words' => ['foo:bar', 'foo or bar'],
29+
'pipe separates words' => ['foo|bar', 'foo or bar'],
30+
'ampersand separates words' => ['foo&bar', 'foo or bar'],
31+
'exclamation mark separates words' => ['foo!bar', 'foo or bar'],
32+
'underscore stays inside a word' => ['foo_bar', 'foo_bar'],
33+
'mixed separators and spaces' => ['baz, foo/bar; qux', 'baz or foo or bar or qux'],
34+
'operators are dropped' => ['+foo -bar* @3 <baz> ~qux (quux)', 'foo or bar or 3 or baz or qux or quux'],
35+
'accented words are kept' => ['@García!', 'García'],
36+
'exact phrase keeps its quotes' => ['"foo/bar baz"', '"foo bar baz"'],
37+
'unbalanced quote is not exact' => ['"foo/bar', 'foo or bar'],
38+
];
39+
}
40+
41+
/**
42+
* @return array<string, array{string, string}>
43+
*/
44+
public static function mySQLTerms(): array
45+
{
46+
return [
47+
'slash separates words' => ['foo/bar', 'foo bar*'],
48+
'comma separates words' => ['foo,bar', 'foo bar*'],
49+
'period separates words' => ['foo.bar', 'foo bar*'],
50+
'apostrophe separates words' => ["foo'bar", 'foo bar*'],
51+
'trailing punctuation keeps the prefix match on the word' => ['foo.', 'foo*'],
52+
'underscore stays inside a word' => ['foo_bar', 'foo_bar*'],
53+
'mixed separators and spaces' => ['baz, foo/bar; qux', 'baz foo bar qux*'],
54+
'non-operator punctuation is dropped' => ['!!!foo...###', 'foo*'],
55+
'exact phrase keeps its quotes' => ['"foo/bar baz"', '"foo bar baz"'],
56+
];
57+
}
58+
59+
/**
60+
* @return array<string, array{string}>
61+
*/
62+
public static function termsWithoutWords(): array
63+
{
64+
return [
65+
'separators only' => ['/,;%=?#'],
66+
'operators only' => ['+-*@<>~()'],
67+
'punctuation only' => ['!!!...###'],
68+
'quoted punctuation' => ['"/"'],
69+
'whitespace only' => [" \t "],
70+
];
71+
}
72+
73+
#[DataProvider('postgreSQLTerms')]
74+
public function testPostgreSQLSearchesEachWordOfATerm(string $term, string $bound): void
75+
{
76+
$this->assertSame([$bound], $this->bindings(new PostgreSQL(), Query::search('title', $term)));
77+
$this->assertSame([$bound], $this->bindings(new PostgreSQL(), Query::notSearch('title', $term)));
78+
}
79+
80+
#[DataProvider('mySQLTerms')]
81+
public function testMySQLSearchesEachWordOfATerm(string $term, string $bound): void
82+
{
83+
$this->assertSame([$bound], $this->bindings(new MySQL(), Query::search('title', $term)));
84+
$this->assertSame([$bound], $this->bindings(new MySQL(), Query::notSearch('title', $term)));
85+
}
86+
87+
#[DataProvider('mySQLTerms')]
88+
public function testMariaDBSearchesEachWordOfATerm(string $term, string $bound): void
89+
{
90+
$this->assertSame([$bound], $this->bindings(new MariaDB(), Query::search('title', $term)));
91+
$this->assertSame([$bound], $this->bindings(new MariaDB(), Query::notSearch('title', $term)));
92+
}
93+
94+
#[DataProvider('termsWithoutWords')]
95+
public function testATermWithoutWordsBindsNothing(string $term): void
96+
{
97+
foreach ([new PostgreSQL(), new MySQL(), new MariaDB()] as $builder) {
98+
$this->assertSame([], $this->bindings($builder, Query::search('title', $term)), $builder::class);
99+
$this->assertSame([], $this->bindings($builder, Query::notSearch('title', $term)), $builder::class);
100+
}
101+
}
102+
103+
public function testATermIsAlwaysBoundAndNeverWrittenIntoTheCondition(): void
104+
{
105+
$term = "foo'); DROP TABLE docs; --";
106+
107+
foreach ([new PostgreSQL(), new MySQL(), new MariaDB()] as $builder) {
108+
$condition = $builder->compileFilters([Query::search('title', $term)]);
109+
110+
$this->assertStringNotContainsString('DROP', $condition->expression, $builder::class);
111+
$this->assertStringNotContainsString('foo', $condition->expression, $builder::class);
112+
$this->assertCount(1, $condition->bindings, $builder::class);
113+
}
114+
}
115+
116+
/**
117+
* @return array<mixed>
118+
*/
119+
private function bindings(Filtering $builder, Query $query): array
120+
{
121+
return $builder->compileFilters([$query])->bindings;
122+
}
123+
}

0 commit comments

Comments
 (0)