From 0b50a247526f19dc25e05ce354a17a0535671fea Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 9 Sep 2026 00:01:31 +0200 Subject: [PATCH 1/3] feat: add `regexSubstring` to query function builder Signed-off-by: Robin Appelman --- .../FunctionBuilder/FunctionBuilder.php | 4 +++ .../FunctionBuilder/PgSqlFunctionBuilder.php | 4 +++ lib/private/DB/SQLiteSessionInit.php | 20 +++++++++++++ .../DB/QueryBuilder/IFunctionBuilder.php | 11 +++++++ .../DB/QueryBuilder/FunctionBuilderTest.php | 29 +++++++++++++++++++ 5 files changed, 68 insertions(+) diff --git a/lib/private/DB/QueryBuilder/FunctionBuilder/FunctionBuilder.php b/lib/private/DB/QueryBuilder/FunctionBuilder/FunctionBuilder.php index 4653ee0c489fe..4a0f8f1ebeb51 100644 --- a/lib/private/DB/QueryBuilder/FunctionBuilder/FunctionBuilder.php +++ b/lib/private/DB/QueryBuilder/FunctionBuilder/FunctionBuilder.php @@ -53,6 +53,10 @@ public function substring($input, $start, $length = null): IQueryFunction { } } + public function regexSubstring($input, $pattern): IQueryFunction { + return new QueryFunction('REGEXP_SUBSTR(' . $this->helper->quoteColumnName($input) . ', ' . $this->helper->quoteColumnName($pattern) . ')'); + } + #[\Override] public function sum($field): IQueryFunction { return new QueryFunction('SUM(' . $this->helper->quoteColumnName($field) . ')'); diff --git a/lib/private/DB/QueryBuilder/FunctionBuilder/PgSqlFunctionBuilder.php b/lib/private/DB/QueryBuilder/FunctionBuilder/PgSqlFunctionBuilder.php index 7bc3433b4be6c..70cda085ea91b 100644 --- a/lib/private/DB/QueryBuilder/FunctionBuilder/PgSqlFunctionBuilder.php +++ b/lib/private/DB/QueryBuilder/FunctionBuilder/PgSqlFunctionBuilder.php @@ -33,4 +33,8 @@ public function groupConcat($expr, ?string $separator = ','): IQueryFunction { $separator = $this->connection->quote($separator); return new QueryFunction('string_agg(' . $castedExpression . ', ' . $separator . ')'); } + + public function regexSubstring($input, $pattern): IQueryFunction { + return new QueryFunction('substring(' . $this->helper->quoteColumnName($input) . ' from ' . $this->helper->quoteColumnName($pattern) . ')'); + } } diff --git a/lib/private/DB/SQLiteSessionInit.php b/lib/private/DB/SQLiteSessionInit.php index 8fd15cd90c375..0497df79e1afa 100644 --- a/lib/private/DB/SQLiteSessionInit.php +++ b/lib/private/DB/SQLiteSessionInit.php @@ -30,10 +30,30 @@ public function postConnect(ConnectionEventArgs $args): void { /** @var \Doctrine\DBAL\Driver\PDO\Connection $connection */ $connection = $args->getConnection()->getWrappedConnection(); $pdo = $connection->getWrappedConnection(); + + $regexSubstr = function ($string, $pattern): ?string { + if (is_null($string) || is_null($pattern)) { + return null; + } else { + $string = (string)$string; + $pattern = str_replace('#', '\#', (string)$pattern); + } + + $matches = []; + $result = preg_match("#$pattern#", $string, $matches); + if ($result === 0 || $result === false) { + return null; + } else { + return $matches[0]; + } + }; + if (PHP_VERSION_ID >= 80500 && method_exists($pdo, 'createFunction')) { $pdo->createFunction('md5', 'md5', 1); + $pdo->createFunction('regexp_substr', $regexSubstr, 2); } else { $pdo->sqliteCreateFunction('md5', 'md5', 1); + $pdo->sqliteCreateFunction('regexp_substr', $regexSubstr, 2); } } diff --git a/lib/public/DB/QueryBuilder/IFunctionBuilder.php b/lib/public/DB/QueryBuilder/IFunctionBuilder.php index f792e05cf6046..1c96068721a69 100644 --- a/lib/public/DB/QueryBuilder/IFunctionBuilder.php +++ b/lib/public/DB/QueryBuilder/IFunctionBuilder.php @@ -63,6 +63,17 @@ public function groupConcat($expr, ?string $separator = ','): IQueryFunction; */ public function substring($input, $start, $length = null): IQueryFunction; + /** + * Takes a substring from the input string using a regex pattern + * + * @param string|ILiteral|IParameter|IQueryFunction $input The input string + * @param string|ILiteral|IParameter|IQueryFunction $pattern The pattern to match and return + * + * @return IQueryFunction + * @since 35.0.0 + */ + public function regexSubstring($input, $pattern): IQueryFunction; + /** * Takes the sum of all rows in a column * diff --git a/tests/lib/DB/QueryBuilder/FunctionBuilderTest.php b/tests/lib/DB/QueryBuilder/FunctionBuilderTest.php index f52b4f3bda1ff..0026558e625f1 100644 --- a/tests/lib/DB/QueryBuilder/FunctionBuilderTest.php +++ b/tests/lib/DB/QueryBuilder/FunctionBuilderTest.php @@ -11,6 +11,7 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\Server; +use PHPUnit\Framework\Attributes\DataProvider; use Test\TestCase; /** @@ -487,4 +488,32 @@ public function testLeast(): void { $result->closeCursor(); $this->assertEquals(1, $row); } + + public static function regexSubstringData(): array { + return [ + ['foobar', 'foo', 'foo'], + ['foobar', 'b.+$', 'bar'], + ['foo#bar', 'ba.+r$', null], + ['foo#bar', 'o#.', 'o#b'], + ['a/file/path', '[^/]+$', 'path'], + ]; + } + + #[DataProvider('regexSubstringData')] + public function testRegexSubstring(string $input, string $pattern, ?string $expected): void { + error_reporting(E_ALL); + $query = $this->connection->getQueryBuilder(); + + $query->select($query->func()->regexSubstring( + $query->createNamedParameter($input), + $query->createNamedParameter($pattern), + )); + $query->from('appconfig') + ->setMaxResults(1); + + $result = $query->executeQuery(); + $row = $result->fetchOne(); + $result->closeCursor(); + $this->assertEquals($expected, $row); + } } From fdb046f06850d3abf0f3865fbf0171d69612ea6d Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 9 Sep 2026 00:02:22 +0200 Subject: [PATCH 2/3] fix: also include mountpoint name in search filter Signed-off-by: Robin Appelman --- apps/dav/lib/Files/FileSearchBackend.php | 17 ++++++++++++++++ .../unit/Files/FileSearchBackendTest.php | 18 ++++++++++++----- lib/private/DB/SQLiteSessionInit.php | 6 +++--- lib/private/Files/Cache/QuerySearchHelper.php | 11 ++++++++++ lib/private/Files/Cache/SearchBuilder.php | 20 ++++++++++++------- lib/private/Files/Node/Folder.php | 20 ++++++++++++++++++- tests/lib/Files/Node/FolderTest.php | 10 ++++++++++ 7 files changed, 86 insertions(+), 16 deletions(-) diff --git a/apps/dav/lib/Files/FileSearchBackend.php b/apps/dav/lib/Files/FileSearchBackend.php index 6b6e0e50a20ed..7c6a50fcdc32c 100644 --- a/apps/dav/lib/Files/FileSearchBackend.php +++ b/apps/dav/lib/Files/FileSearchBackend.php @@ -458,6 +458,23 @@ private function transformSearchOperation(Operator $operator) { throw new \InvalidArgumentException('Invalid property value for ' . $property->name, previous: $e); } + if ($field === 'name') { + return new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, [ + new SearchComparison( + $trimmedType, + $field, + $castedValue, + $extra ?? '' + ), + new SearchComparison( + $trimmedType, + 'mount_point_name', + $castedValue, + $extra ?? '' + ) + ]); + } + return new SearchComparison( $trimmedType, $field, diff --git a/apps/dav/tests/unit/Files/FileSearchBackendTest.php b/apps/dav/tests/unit/Files/FileSearchBackendTest.php index 5d57ca4bd035e..19d97e4179295 100644 --- a/apps/dav/tests/unit/Files/FileSearchBackendTest.php +++ b/apps/dav/tests/unit/Files/FileSearchBackendTest.php @@ -8,6 +8,7 @@ namespace OCA\DAV\Tests\unit\Files; +use OC\Files\Search\SearchBinaryOperator; use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchQuery; use OC\Files\View; @@ -92,11 +93,18 @@ public function testSearchFilename(): void { $this->searchFolder->expects($this->once()) ->method('search') ->with(new SearchQuery( - new SearchComparison( - ISearchComparison::COMPARE_EQUAL, - 'name', - 'foo' - ), + new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, [ + new SearchComparison( + ISearchComparison::COMPARE_EQUAL, + 'name', + 'foo' + ), + new SearchComparison( + ISearchComparison::COMPARE_EQUAL, + 'mount_point_name', + 'foo' + ), + ]), 100, 0, [], diff --git a/lib/private/DB/SQLiteSessionInit.php b/lib/private/DB/SQLiteSessionInit.php index 0497df79e1afa..9a2e0ed4633be 100644 --- a/lib/private/DB/SQLiteSessionInit.php +++ b/lib/private/DB/SQLiteSessionInit.php @@ -31,9 +31,9 @@ public function postConnect(ConnectionEventArgs $args): void { $connection = $args->getConnection()->getWrappedConnection(); $pdo = $connection->getWrappedConnection(); - $regexSubstr = function ($string, $pattern): ?string { + $regexSubstr = function ($string, $pattern): string { if (is_null($string) || is_null($pattern)) { - return null; + return ''; } else { $string = (string)$string; $pattern = str_replace('#', '\#', (string)$pattern); @@ -42,7 +42,7 @@ public function postConnect(ConnectionEventArgs $args): void { $matches = []; $result = preg_match("#$pattern#", $string, $matches); if ($result === 0 || $result === false) { - return null; + return ''; } else { return $matches[0]; } diff --git a/lib/private/Files/Cache/QuerySearchHelper.php b/lib/private/Files/Cache/QuerySearchHelper.php index f8bc40661d5e4..bc06c548c1b54 100644 --- a/lib/private/Files/Cache/QuerySearchHelper.php +++ b/lib/private/Files/Cache/QuerySearchHelper.php @@ -114,6 +114,14 @@ protected function equipQueryForDavTags(CacheQueryBuilder $query, IUser $user): )); } + protected function equipQueryForMounts(CacheQueryBuilder $query, IUser $user): void { + $query + ->leftJoin('file', 'mounts', 'm', $query->expr()->andX( + $query->expr()->eq('m.root_id', 'file.fileid'), + $query->expr()->eq('m.user_id', $query->createNamedParameter($user->getUID())) + )); + } + protected function equipQueryForShares(CacheQueryBuilder $query): void { $query->join('file', 'share', 's', $query->expr()->eq('file.fileid', 's.file_source')); } @@ -168,6 +176,9 @@ public function searchInCaches(ISearchQuery $searchQuery, array $caches): array if (in_array('owner', $requestedFields, true) || in_array('share_with', $requestedFields, true) || in_array('share_type', $requestedFields, true)) { $this->equipQueryForShares($query); } + if (in_array('mount_point_name', $requestedFields)) { + $this->equipQueryForMounts($query, $this->requireUser($searchQuery)); + } $metadataQuery = $query->selectMetadata(); diff --git a/lib/private/Files/Cache/SearchBuilder.php b/lib/private/Files/Cache/SearchBuilder.php index ec08d5855181f..d3ec85bc49763 100644 --- a/lib/private/Files/Cache/SearchBuilder.php +++ b/lib/private/Files/Cache/SearchBuilder.php @@ -9,6 +9,7 @@ use OCP\DB\QueryBuilder\IParameter; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\QueryBuilder\IQueryFunction; use OCP\Files\IMimeTypeLoader; use OCP\Files\Search\ISearchBinaryOperator; use OCP\Files\Search\ISearchComparison; @@ -67,6 +68,7 @@ class SearchBuilder { 'owner' => 'string', 'creation_time' => 'integer', 'upload_time' => 'integer', + 'mount_point_name' => 'string', ]; /** @var array */ @@ -162,7 +164,7 @@ private function searchComparisonToDBExpr( if ($comparison->getExtra()) { [$field, $value, $type, $paramType] = $this->getExtraOperatorField($comparison, $metadataQuery); } else { - [$field, $value, $type, $paramType] = $this->getOperatorFieldAndValue($comparison); + [$field, $value, $type, $paramType] = $this->getOperatorFieldAndValue($builder, $comparison); } if (isset($operatorMap[$type])) { @@ -175,29 +177,29 @@ private function searchComparisonToDBExpr( /** * @param ISearchComparison $operator - * @return array{string, ParamValue, string, string} + * @return array{string|IQueryFunction, ParamValue, string, string} */ - private function getOperatorFieldAndValue(ISearchComparison $operator): array { + private function getOperatorFieldAndValue(IQueryBuilder $builder, ISearchComparison $operator): array { $this->validateComparison($operator); $field = $operator->getField(); $value = $operator->getValue(); $type = $operator->getType(); $pathEqHash = $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true); - return $this->getOperatorFieldAndValueInner($field, $value, $type, $pathEqHash); + return $this->getOperatorFieldAndValueInner($builder, $field, $value, $type, $pathEqHash); } /** * @param ParamValue $value - * @return array{string, ParamValue, string, string} + * @return array{string|IQueryFunction, ParamValue, string, string} */ - private function getOperatorFieldAndValueInner(string $field, mixed $value, string $type, bool $pathEqHash): array { + private function getOperatorFieldAndValueInner(IQueryBuilder $builder, string $field, mixed $value, string $type, bool $pathEqHash): array { $paramType = self::FIELD_TYPES[$field]; if ($type === ISearchComparison::COMPARE_IN) { $resultField = $field; $values = []; foreach ($value as $arrayValue) { /** @var ParamSingleValue $arrayValue */ - [$arrayField, $arrayValue] = $this->getOperatorFieldAndValueInner($field, $arrayValue, ISearchComparison::COMPARE_EQUAL, $pathEqHash); + [$arrayField, $arrayValue] = $this->getOperatorFieldAndValueInner($builder, $field, $arrayValue, ISearchComparison::COMPARE_EQUAL, $pathEqHash); $resultField = $arrayField; $values[] = $arrayValue; } @@ -237,6 +239,9 @@ private function getOperatorFieldAndValueInner(string $field, mixed $value, stri $value = md5((string)$value); } elseif ($field === 'owner') { $field = 'uid_owner'; + } elseif ($field === 'mount_point_name') { + $field = $builder->func()->regexSubstring('mount_point', $builder->createNamedParameter('[^/]+/$')); + $value = $value . '/'; } return [$field, $value, $type, $paramType]; } @@ -258,6 +263,7 @@ private function validateComparison(ISearchComparison $operator): void { 'owner' => ['eq'], 'creation_time' => ['eq', 'gt', 'lt', 'gte', 'lte'], 'upload_time' => ['eq', 'gt', 'lt', 'gte', 'lte'], + 'mount_point_name' => ['eq', 'like', 'clike', 'in'], ]; if (!isset(self::FIELD_TYPES[$operator->getField()])) { diff --git a/lib/private/Files/Node/Folder.php b/lib/private/Files/Node/Folder.php index 1699f5ae618b1..7a58c41f60d1f 100644 --- a/lib/private/Files/Node/Folder.php +++ b/lib/private/Files/Node/Folder.php @@ -214,7 +214,25 @@ private function queryFromOperator(ISearchOperator $operator, ?string $uid = nul #[\Override] public function search($query) { if (is_string($query)) { - $query = $this->queryFromOperator(new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', '%' . $query . '%')); + $operator = new SearchComparison( + ISearchComparison::COMPARE_LIKE, + 'name', + '%' . $query . '%', + ); + $parts = explode('/', $this->path); + $uid = null; + if (count($parts) > 2) { + [, $uid] = $parts; + $operator = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, [ + $operator, + new SearchComparison( + ISearchComparison::COMPARE_LIKE, + 'mount_point_name', + '%' . $query . '%', + ) + ]); + } + $query = $this->queryFromOperator($operator, $uid); } // search is handled by a single query covering all caches that this folder contains diff --git a/tests/lib/Files/Node/FolderTest.php b/tests/lib/Files/Node/FolderTest.php index d67f25f622b7e..6bc32dc97102b 100644 --- a/tests/lib/Files/Node/FolderTest.php +++ b/tests/lib/Files/Node/FolderTest.php @@ -39,8 +39,10 @@ use OCP\Files\Search\ISearchComparison; use OCP\Files\Search\ISearchOrder; use OCP\Files\Storage\IStorage; +use OCP\IUser; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; +use Test\Traits\UserTrait; /** * Class FolderTest @@ -50,6 +52,14 @@ */ #[\PHPUnit\Framework\Attributes\Group('DB')] class FolderTest extends NodeTestCase { + use UserTrait; + + protected function setUp(): void { + parent::setUp(); + + $this->createUser('bar', 'bar'); + } + #[\Override] protected function createTestNode(IRootFolder $root, View&MockObject $view, string $path, array $data = [], string $internalPath = '', ?IStorage $storage = null): Folder { $view->expects($this->any()) From 625a721cc0c257b948f17297fe2becdab157b923 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 9 Sep 2026 18:55:11 +0200 Subject: [PATCH 3/3] test: add test for searching for mount name Signed-off-by: Robin Appelman --- .../Files/Search/SearchIntegrationTest.php | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/tests/lib/Files/Search/SearchIntegrationTest.php b/tests/lib/Files/Search/SearchIntegrationTest.php index 80397c4272dda..2118edc615131 100644 --- a/tests/lib/Files/Search/SearchIntegrationTest.php +++ b/tests/lib/Files/Search/SearchIntegrationTest.php @@ -11,22 +11,45 @@ use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchQuery; use OC\Files\Storage\Temporary; +use OCP\Files\Cache\ICache; +use OCP\Files\Config\IUserMountCache; use OCP\Files\Search\ISearchBinaryOperator; use OCP\Files\Search\ISearchComparison; +use OCP\Files\Search\ISearchOperator; +use OCP\Files\Storage\IStorage; +use OCP\IUser; +use OCP\Server; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Test\TestCase; -#[\PHPUnit\Framework\Attributes\Group('DB')] +#[Group('DB')] class SearchIntegrationTest extends TestCase { - private $cache; - private $storage; + private ICache $cache; + private IStorage $storage; + private string $mountPoint; + private IUserMountCache $mountCache; + private IUser $user; #[\Override] protected function setUp(): void { parent::setUp(); + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID') + ->willReturn('user'); $this->storage = new Temporary([]); $this->cache = $this->storage->getCache(); $this->storage->getScanner()->scan(''); + $this->mountCache = Server::get(IUserMountCache::class); + $this->mountPoint = '/user/files/search_test/'; + $this->mountCache->addMount($this->user, $this->mountPoint, $this->cache->get(''), 'dummy'); + } + + protected function tearDown(): void { + $this->mountCache->removeMount($this->mountPoint); + + parent::tearDown(); } public function testThousandAndOneFilters(): void { @@ -44,4 +67,27 @@ public function testThousandAndOneFilters(): void { $this->assertCount(1, $results); $this->assertEquals($id, $results[0]->getId()); } + + public static function searchMountNameProvider(): array { + return [ + [new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mount_point_name', '%search%'), ''], + [new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mount_point_name', 'search_test'), ''], + [new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mount_point_name', '%search_test%'), ''], + [new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mount_point_name', '%files%'), null], + ]; + } + + #[DataProvider('searchMountNameProvider')] + public function testSearchMountName(ISearchOperator $operator, ?string $resultPath): void { + $query = new SearchQuery($operator, 10, 0, [], $this->user); + + $results = $this->cache->searchQuery($query); + + if (is_null($resultPath)) { + $this->assertCount(0, $results); + } else { + $this->assertCount(1, $results); + $this->assertEquals($this->cache->getId($resultPath), $results[0]->getId()); + } + } }