From e7f6acd3ade586b49f83dc5aba4b8882081136c0 Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Thu, 21 Sep 2023 16:10:48 +0200 Subject: [PATCH] feat(search): Allow multiple search terms in UnifiedController Signed-off-by: Benjamin Gaussorgues --- core/Controller/UnifiedSearchController.php | 9 +- lib/private/Search/Filter.php | 48 ++++++++ lib/private/Search/Filter/TypeBool.php | 42 +++++++ lib/private/Search/Filter/TypeDateTime.php | 44 +++++++ lib/private/Search/Filter/TypeFloat.php | 45 ++++++++ lib/private/Search/Filter/TypeInt.php | 45 ++++++++ lib/private/Search/Filter/TypeJson.php | 39 +++++++ lib/private/Search/Filter/TypeString.php | 43 +++++++ lib/private/Search/Filter/TypeUser.php | 55 +++++++++ lib/private/Search/Filters.php | 121 ++++++++++++++++++++ lib/private/Search/InvalidFilter.php | 42 +++++++ lib/private/Search/SearchQuery.php | 79 ++++--------- lib/public/Search/ISearchQuery.php | 33 ++++++ 13 files changed, 584 insertions(+), 61 deletions(-) create mode 100644 lib/private/Search/Filter.php create mode 100644 lib/private/Search/Filter/TypeBool.php create mode 100644 lib/private/Search/Filter/TypeDateTime.php create mode 100644 lib/private/Search/Filter/TypeFloat.php create mode 100644 lib/private/Search/Filter/TypeInt.php create mode 100644 lib/private/Search/Filter/TypeJson.php create mode 100644 lib/private/Search/Filter/TypeString.php create mode 100644 lib/private/Search/Filter/TypeUser.php create mode 100644 lib/private/Search/Filters.php create mode 100644 lib/private/Search/InvalidFilter.php diff --git a/core/Controller/UnifiedSearchController.php b/core/Controller/UnifiedSearchController.php index d0dfd1bf7da90..3dc6829fc5552 100644 --- a/core/Controller/UnifiedSearchController.php +++ b/core/Controller/UnifiedSearchController.php @@ -28,6 +28,7 @@ */ namespace OC\Core\Controller; +use OCP\IUserManager; use OC\Search\SearchComposer; use OC\Search\SearchQuery; use OCA\Core\ResponseDefinitions; @@ -39,6 +40,7 @@ use OCP\IUserSession; use OCP\Route\IRouter; use OCP\Search\ISearchQuery; +use OC\Search\Filters; use Symfony\Component\Routing\Exception\ResourceNotFoundException; /** @@ -52,6 +54,7 @@ public function __construct( private SearchComposer $composer, private IRouter $router, private IURLGenerator $urlGenerator, + private IUserManager $userManager, ) { parent::__construct('core', $request); } @@ -95,14 +98,10 @@ public function getProviders(string $from = ''): DataResponse { * 400: Searching is not possible */ public function search(string $providerId, - string $term = '', ?int $sortOrder = null, ?int $limit = null, $cursor = null, string $from = ''): DataResponse { - if (empty(trim($term))) { - return new DataResponse(null, Http::STATUS_BAD_REQUEST); - } [$route, $routeParameters] = $this->getRouteInformation($from); return new DataResponse( @@ -110,7 +109,7 @@ public function search(string $providerId, $this->userSession->getUser(), $providerId, new SearchQuery( - $term, + new Filters($this->request, $this->userManager), $sortOrder ?? ISearchQuery::SORT_DATE_DESC, $limit ?? SearchQuery::LIMIT_DEFAULT, $cursor, diff --git a/lib/private/Search/Filter.php b/lib/private/Search/Filter.php new file mode 100644 index 0000000000000..c38ec33a18e06 --- /dev/null +++ b/lib/private/Search/Filter.php @@ -0,0 +1,48 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search; + +use Throwable; + +abstract class Filter { + public readonly mixed $value; + + public function __construct( + public readonly string $name, + string $value + ) { + try { + $this->value = $this->parse($value); + } catch (InvalidFilter $e) { + throw $e; + } catch (Throwable $e) { + throw new InvalidFilter($name, $value, $e); + } + } + + abstract protected function parse(string $value): mixed; +} diff --git a/lib/private/Search/Filter/TypeBool.php b/lib/private/Search/Filter/TypeBool.php new file mode 100644 index 0000000000000..37d565e27066b --- /dev/null +++ b/lib/private/Search/Filter/TypeBool.php @@ -0,0 +1,42 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search\Filter; + +use OC\Search\Filter; + +class TypeBool extends Filter { + protected function parse(string $value): bool { + return match ($value) { + 'true', 'yes', 'y', '1' => true, + 'false', 'no', 'n', '0', '' => false, + }; + } + + public function get(): bool { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/TypeDateTime.php b/lib/private/Search/Filter/TypeDateTime.php new file mode 100644 index 0000000000000..2be4926fafe21 --- /dev/null +++ b/lib/private/Search/Filter/TypeDateTime.php @@ -0,0 +1,44 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search\Filter; + +use DateTimeImmutable; +use OC\Search\Filter; + +class TypeDateTime extends Filter { + protected function parse(string $value): DateTimeImmutable { + if (filter_var($value, FILTER_VALIDATE_INT)) { + $value = '@'.$value; + } + + return new DateTimeImmutable($value); + } + + public function get(): DateTimeImmutable { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/TypeFloat.php b/lib/private/Search/Filter/TypeFloat.php new file mode 100644 index 0000000000000..ac999aadcde70 --- /dev/null +++ b/lib/private/Search/Filter/TypeFloat.php @@ -0,0 +1,45 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search\Filter; + +use OC\Search\Filter; +use OC\Search\InvalidFilter; + +class TypeFloat extends Filter { + protected function parse(string $value): float { + $value = filter_var($value, FILTER_VALIDATE_FLOAT); + if ($value === false) { + throw new InvalidFilter($this->name, $value); + } + + return $value; + } + + public function get(): float { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/TypeInt.php b/lib/private/Search/Filter/TypeInt.php new file mode 100644 index 0000000000000..bd9d856a516c3 --- /dev/null +++ b/lib/private/Search/Filter/TypeInt.php @@ -0,0 +1,45 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search\Filter; + +use OC\Search\Filter; +use OC\Search\InvalidFilter; + +class TypeInt extends Filter { + protected function parse(string $value): int { + $value = filter_var($value, FILTER_VALIDATE_INT); + if ($value === false) { + throw new InvalidFilter($this->name, $value); + } + + return $value; + } + + public function get(): int { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/TypeJson.php b/lib/private/Search/Filter/TypeJson.php new file mode 100644 index 0000000000000..aead8f99af1a2 --- /dev/null +++ b/lib/private/Search/Filter/TypeJson.php @@ -0,0 +1,39 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search\Filter; + +use OC\Search\Filter; + +class TypeJson extends Filter { + protected function parse(string $value): mixed { + return json_decode(json: $value, associative: true, flags: JSON_THROW_ON_ERROR); + } + + public function get(): mixed { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/TypeString.php b/lib/private/Search/Filter/TypeString.php new file mode 100644 index 0000000000000..d790a8efc6d3e --- /dev/null +++ b/lib/private/Search/Filter/TypeString.php @@ -0,0 +1,43 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search\Filter; + +use OC\Search\Filter; +use OC\Search\InvalidFilter; + +class TypeString extends Filter { + protected function parse(string $value): string { + if ($value === '') { + throw new InvalidFilter($this->name, $value); + } + return $value; + } + + public function get(): string { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/TypeUser.php b/lib/private/Search/Filter/TypeUser.php new file mode 100644 index 0000000000000..ef8e408106c10 --- /dev/null +++ b/lib/private/Search/Filter/TypeUser.php @@ -0,0 +1,55 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search\Filter; + +use OCP\IUser; +use OCP\IUserManager; +use OC\Search\Filter; +use OC\Search\InvalidFilter; + +class TypeUser extends Filter { + public function __construct( + string $name, + string $value, + private IUserManager $userManager, + ) { + parent::__construct($name, $value); + } + + protected function parse(string $value): IUser { + $user = $this->userManager->get($value); + if ($user === null) { + throw new InvalidFilter($this->name, $value); + } + + return $user; + } + + public function get(): IUser { + return $this->value; + } +} diff --git a/lib/private/Search/Filters.php b/lib/private/Search/Filters.php new file mode 100644 index 0000000000000..1777b9f2e3e6f --- /dev/null +++ b/lib/private/Search/Filters.php @@ -0,0 +1,121 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Search; + +use ArrayIterator; +use IteratorAggregate; +use OCP\IRequest; +use OCP\IUserManager; +use OC\Search\Filter\TypeBool; +use OC\Search\Filter\TypeDateTime; +use OC\Search\Filter\TypeFloat; +use OC\Search\Filter\TypeInt; +use OC\Search\Filter\TypeJson; +use OC\Search\Filter\TypeString; +use OC\Search\Filter\TypeUser; +use Traversable; + +/** + * Parse query string into filters + * + * Filters are prefixed with a type: + * b: boolean + * f: float + * i: integer + * j: json data + * s: string + * t: timestamp + * u: user ID + * This type can be combined with l to declare a list + * + * Examples: + * il_myintegers = an array of integers + * ul_users = a list of users + * f_float = a single float + * t_start = a single timestamp + * + * @template-implements IteratorAggregate + */ +final class Filters implements IteratorAggregate { + private array $filters = []; + + public function __construct( + IRequest $request, + private IUserManager $userManager, + ) { + $this->parseFilters($request); + } + + public function get(string $filter): ?Filter { + return $this->filters[$filter] ?? null; + } + + public function getIterator(): Traversable { + return new ArrayIterator($this->filters); + } + + private function parseFilters(IRequest $request): void { + foreach ($request->getParams() as $filter => $value) { + // Compatibility with previous search + if ($filter === 'term') { + $this->filters[$filter] = $this->castValue('s', $filter, $value); + continue; + } + + if (!preg_match('/([bfijstu])(l?)_[-_a-z]/A', $filter, $matches)) { + continue; + } + $type = $matches[1]; + $isList = $matches[2] === 'l'; + + if (!$isList) { + $this->filters[$filter] = $this->castValue($type, $filter, $value); + continue; + } + + $this->filters[$filter] = []; + if (!is_array($value)) { + $value = [$value]; + } + array_map(function ($value) use ($type, $filter) { + $this->filters[$filter][] = $this->castValue($type, $filter, $value); + }, $value); + } + } + + private function castValue(string $type, string $filter, string $value): mixed { + return match ($type) { + 'b' => new TypeBool($filter, $value), + 'f' => new TypeFloat($filter, $value), + 'i' => new TypeInt($filter, $value), + 'j' => new TypeJson($filter, $value), + 's' => new TypeString($filter, $value), + 't' => new TypeDateTime($filter, $value), + 'u' => new TypeUser($filter, $value, $this->userManager), + }; + } +} diff --git a/lib/private/Search/InvalidFilter.php b/lib/private/Search/InvalidFilter.php new file mode 100644 index 0000000000000..860473d02daba --- /dev/null +++ b/lib/private/Search/InvalidFilter.php @@ -0,0 +1,42 @@ + + * + * @author Benjamin Gaussorgues + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OC\Search; + +use InvalidArgumentException; +use Throwable; + +final class InvalidFilter extends InvalidArgumentException { + public function __construct( + string $name, + string $value, + Throwable $previous = null, + ) { + parent::__construct( + message: sprintf('Invalid value "%s" for filter %s.', $value, $name), + previous: $previous + ); + } +} diff --git a/lib/private/Search/SearchQuery.php b/lib/private/Search/SearchQuery.php index c89446d59703b..d1b4b6d52513d 100644 --- a/lib/private/Search/SearchQuery.php +++ b/lib/private/Search/SearchQuery.php @@ -32,84 +32,51 @@ class SearchQuery implements ISearchQuery { public const LIMIT_DEFAULT = 5; - /** @var string */ - private $term; - - /** @var int */ - private $sortOrder; - - /** @var int */ - private $limit; - - /** @var int|string|null */ - private $cursor; - - /** @var string */ - private $route; - - /** @var array */ - private $routeParameters; - /** - * @param string $term - * @param int $sortOrder - * @param int $limit - * @param int|string|null $cursor - * @param string $route - * @param array $routeParameters + * @param string[] $routeParameters */ - public function __construct(string $term, - int $sortOrder = ISearchQuery::SORT_DATE_DESC, - int $limit = self::LIMIT_DEFAULT, - $cursor = null, - string $route = '', - array $routeParameters = []) { - $this->term = $term; - $this->sortOrder = $sortOrder; - $this->limit = $limit; - $this->cursor = $cursor; - $this->route = $route; - $this->routeParameters = $routeParameters; + public function __construct( + private Filters $filters, + private int $sortOrder = ISearchQuery::SORT_DATE_DESC, + private int $limit = self::LIMIT_DEFAULT, + private int|string|null $cursor = null, + private string $route = '', + private array $routeParameters = [], + ) { } - /** - * @inheritDoc - */ public function getTerm(): string { - return $this->term; + return $this->getFilterValue('term', ''); + } + + public function getFilterValue(string $name, $default = null): mixed { + return $this->filters->get($name)?->value ?? $default; + } + + public function getFilter(string $name): ?Filter { + return $this->filters->get($name); + } + + public function getFilters(): Filters { + return $this->filters; } - /** - * @inheritDoc - */ public function getSortOrder(): int { return $this->sortOrder; } - /** - * @inheritDoc - */ public function getLimit(): int { return $this->limit; } - /** - * @inheritDoc - */ - public function getCursor() { + public function getCursor(): int|string|null { return $this->cursor; } - /** - * @inheritDoc - */ public function getRoute(): string { return $this->route; } - /** - * @inheritDoc - */ public function getRouteParameters(): array { return $this->routeParameters; } diff --git a/lib/public/Search/ISearchQuery.php b/lib/public/Search/ISearchQuery.php index a545d1dbccbac..4e42c02772c86 100644 --- a/lib/public/Search/ISearchQuery.php +++ b/lib/public/Search/ISearchQuery.php @@ -26,6 +26,8 @@ */ namespace OCP\Search; +use OC\Search\Filter; + /** * The query objected passed into \OCP\Search\IProvider::search * @@ -46,11 +48,42 @@ interface ISearchQuery { /** * Get the user-entered search term to find matches for * + * Alias for getFilter('term', '') + * * @return string the search term * @since 20.0.0 + * @deprecated 28.0.0 */ public function getTerm(): string; + /** + * Get a specific user-entered filter + * + * @param string $name Filter name + * @param mixed $default Filter default value if not exists + * @return mixed Filter value or $default + * @since 28.0.0 + */ + public function getFilterValue(string $name, $default = null); + + /** + * Get a specific user-entered filter + * + * @param string $name Filter name + * @param mixed $default Filter default value if not exists + * @return ?Filter Filter value or null if not exists + * @since 28.0.0 + */ + public function getFilter(string $name): ?Filter; + + /** + * Get user-entered filters + * + * @return mixed[] List of defined filters + * @since 28.0.0 + */ + public function getFilters(); + /** * Get the sort order of results as defined as SORT_* constants on this interface *