123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- <?php
- /**
- * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Maxence Lange <maxence@artificial-owl.com>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Tobias Kaminsky <tobias@kaminsky.me>
- *
- * @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 <http://www.gnu.org/licenses/>.
- *
- */
- namespace OC\Files\Cache;
- use OCP\DB\QueryBuilder\IQueryBuilder;
- use OCP\Files\IMimeTypeLoader;
- use OCP\Files\Search\ISearchBinaryOperator;
- use OCP\Files\Search\ISearchComparison;
- use OCP\Files\Search\ISearchOperator;
- use OCP\Files\Search\ISearchOrder;
- use OCP\FilesMetadata\IMetadataQuery;
- /**
- * Tools for transforming search queries into database queries
- *
- * @psalm-import-type ParamSingleValue from ISearchComparison
- * @psalm-import-type ParamValue from ISearchComparison
- */
- class SearchBuilder {
- /** @var array<string, string> */
- protected static $searchOperatorMap = [
- ISearchComparison::COMPARE_LIKE => 'iLike',
- ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'like',
- ISearchComparison::COMPARE_EQUAL => 'eq',
- ISearchComparison::COMPARE_GREATER_THAN => 'gt',
- ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'gte',
- ISearchComparison::COMPARE_LESS_THAN => 'lt',
- ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'lte',
- ISearchComparison::COMPARE_DEFINED => 'isNotNull',
- ISearchComparison::COMPARE_IN => 'in',
- ];
- /** @var array<string, string> */
- protected static $searchOperatorNegativeMap = [
- ISearchComparison::COMPARE_LIKE => 'notLike',
- ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'notLike',
- ISearchComparison::COMPARE_EQUAL => 'neq',
- ISearchComparison::COMPARE_GREATER_THAN => 'lte',
- ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'lt',
- ISearchComparison::COMPARE_LESS_THAN => 'gte',
- ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'gt',
- ISearchComparison::COMPARE_DEFINED => 'isNull',
- ISearchComparison::COMPARE_IN => 'notIn',
- ];
- /** @var array<string, string> */
- protected static $fieldTypes = [
- 'mimetype' => 'string',
- 'mtime' => 'integer',
- 'name' => 'string',
- 'path' => 'string',
- 'size' => 'integer',
- 'tagname' => 'string',
- 'systemtag' => 'string',
- 'favorite' => 'boolean',
- 'fileid' => 'integer',
- 'storage' => 'integer',
- 'share_with' => 'string',
- 'share_type' => 'integer',
- 'owner' => 'string',
- ];
- /** @var array<string, int> */
- protected static $paramTypeMap = [
- 'string' => IQueryBuilder::PARAM_STR,
- 'integer' => IQueryBuilder::PARAM_INT,
- 'boolean' => IQueryBuilder::PARAM_BOOL,
- ];
- /** @var array<string, int> */
- protected static $paramArrayTypeMap = [
- 'string' => IQueryBuilder::PARAM_STR_ARRAY,
- 'integer' => IQueryBuilder::PARAM_INT_ARRAY,
- 'boolean' => IQueryBuilder::PARAM_INT_ARRAY,
- ];
- public const TAG_FAVORITE = '_$!<Favorite>!$_';
- /** @var IMimeTypeLoader */
- private $mimetypeLoader;
- public function __construct(
- IMimeTypeLoader $mimetypeLoader
- ) {
- $this->mimetypeLoader = $mimetypeLoader;
- }
- /**
- * @return string[]
- */
- public function extractRequestedFields(ISearchOperator $operator): array {
- if ($operator instanceof ISearchBinaryOperator) {
- return array_reduce($operator->getArguments(), function (array $fields, ISearchOperator $operator) {
- return array_unique(array_merge($fields, $this->extractRequestedFields($operator)));
- }, []);
- } elseif ($operator instanceof ISearchComparison && !$operator->getExtra()) {
- return [$operator->getField()];
- }
- return [];
- }
- /**
- * @param IQueryBuilder $builder
- * @param ISearchOperator[] $operators
- */
- public function searchOperatorArrayToDBExprArray(
- IQueryBuilder $builder,
- array $operators,
- ?IMetadataQuery $metadataQuery = null
- ) {
- return array_filter(array_map(function ($operator) use ($builder, $metadataQuery) {
- return $this->searchOperatorToDBExpr($builder, $operator, $metadataQuery);
- }, $operators));
- }
- public function searchOperatorToDBExpr(
- IQueryBuilder $builder,
- ISearchOperator $operator,
- ?IMetadataQuery $metadataQuery = null
- ) {
- $expr = $builder->expr();
- if ($operator instanceof ISearchBinaryOperator) {
- if (count($operator->getArguments()) === 0) {
- return null;
- }
- switch ($operator->getType()) {
- case ISearchBinaryOperator::OPERATOR_NOT:
- $negativeOperator = $operator->getArguments()[0];
- if ($negativeOperator instanceof ISearchComparison) {
- return $this->searchComparisonToDBExpr($builder, $negativeOperator, self::$searchOperatorNegativeMap, $metadataQuery);
- } else {
- throw new \InvalidArgumentException('Binary operators inside "not" is not supported');
- }
- // no break
- case ISearchBinaryOperator::OPERATOR_AND:
- return call_user_func_array([$expr, 'andX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments(), $metadataQuery));
- case ISearchBinaryOperator::OPERATOR_OR:
- return call_user_func_array([$expr, 'orX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments(), $metadataQuery));
- default:
- throw new \InvalidArgumentException('Invalid operator type: ' . $operator->getType());
- }
- } elseif ($operator instanceof ISearchComparison) {
- return $this->searchComparisonToDBExpr($builder, $operator, self::$searchOperatorMap, $metadataQuery);
- } else {
- throw new \InvalidArgumentException('Invalid operator type: ' . get_class($operator));
- }
- }
- private function searchComparisonToDBExpr(
- IQueryBuilder $builder,
- ISearchComparison $comparison,
- array $operatorMap,
- ?IMetadataQuery $metadataQuery = null
- ) {
- if ($comparison->getExtra()) {
- [$field, $value, $type, $paramType] = $this->getExtraOperatorField($comparison, $metadataQuery);
- } else {
- [$field, $value, $type, $paramType] = $this->getOperatorFieldAndValue($comparison);
- }
- if (isset($operatorMap[$type])) {
- $queryOperator = $operatorMap[$type];
- return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value, $paramType));
- } else {
- throw new \InvalidArgumentException('Invalid operator type: ' . $comparison->getType());
- }
- }
- /**
- * @param ISearchComparison $operator
- * @return list{string, ParamValue, string, string}
- */
- private function getOperatorFieldAndValue(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);
- }
- /**
- * @param string $field
- * @param ParamValue $value
- * @param string $type
- * @return list{string, ParamValue, string, string}
- */
- private function getOperatorFieldAndValueInner(string $field, mixed $value, string $type, bool $pathEqHash): array {
- $paramType = self::$fieldTypes[$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);
- $resultField = $arrayField;
- $values[] = $arrayValue;
- }
- return [$resultField, $values, ISearchComparison::COMPARE_IN, $paramType];
- }
- if ($field === 'mimetype') {
- $value = (string)$value;
- if ($type === ISearchComparison::COMPARE_EQUAL) {
- $value = $this->mimetypeLoader->getId($value);
- } elseif ($type === ISearchComparison::COMPARE_LIKE) {
- // transform "mimetype='foo/%'" to "mimepart='foo'"
- if (preg_match('|(.+)/%|', $value, $matches)) {
- $field = 'mimepart';
- $value = $this->mimetypeLoader->getId($matches[1]);
- $type = ISearchComparison::COMPARE_EQUAL;
- } elseif (str_contains($value, '%')) {
- throw new \InvalidArgumentException('Unsupported query value for mimetype: ' . $value . ', only values in the format "mime/type" or "mime/%" are supported');
- } else {
- $field = 'mimetype';
- $value = $this->mimetypeLoader->getId($value);
- $type = ISearchComparison::COMPARE_EQUAL;
- }
- }
- } elseif ($field === 'favorite') {
- $field = 'tag.category';
- $value = self::TAG_FAVORITE;
- $paramType = 'string';
- } elseif ($field === 'name') {
- $field = 'file.name';
- } elseif ($field === 'tagname') {
- $field = 'tag.category';
- } elseif ($field === 'systemtag') {
- $field = 'systemtag.name';
- } elseif ($field === 'fileid') {
- $field = 'file.fileid';
- } elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL && $pathEqHash) {
- $field = 'path_hash';
- $value = md5((string)$value);
- } elseif ($field === 'owner') {
- $field = 'uid_owner';
- }
- return [$field, $value, $type, $paramType];
- }
- private function validateComparison(ISearchComparison $operator) {
- $comparisons = [
- 'mimetype' => ['eq', 'like', 'in'],
- 'mtime' => ['eq', 'gt', 'lt', 'gte', 'lte'],
- 'name' => ['eq', 'like', 'clike', 'in'],
- 'path' => ['eq', 'like', 'clike', 'in'],
- 'size' => ['eq', 'gt', 'lt', 'gte', 'lte'],
- 'tagname' => ['eq', 'like'],
- 'systemtag' => ['eq', 'like'],
- 'favorite' => ['eq'],
- 'fileid' => ['eq', 'in'],
- 'storage' => ['eq', 'in'],
- 'share_with' => ['eq'],
- 'share_type' => ['eq'],
- 'owner' => ['eq'],
- ];
- if (!isset(self::$fieldTypes[$operator->getField()])) {
- throw new \InvalidArgumentException('Unsupported comparison field ' . $operator->getField());
- }
- $type = self::$fieldTypes[$operator->getField()];
- if ($operator->getType() === ISearchComparison::COMPARE_IN) {
- if (!is_array($operator->getValue())) {
- throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
- }
- foreach ($operator->getValue() as $arrayValue) {
- if (gettype($arrayValue) !== $type) {
- throw new \InvalidArgumentException('Invalid type in array for field ' . $operator->getField());
- }
- }
- } else {
- if (gettype($operator->getValue()) !== $type) {
- throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
- }
- }
- if (!in_array($operator->getType(), $comparisons[$operator->getField()])) {
- throw new \InvalidArgumentException('Unsupported comparison for field ' . $operator->getField() . ': ' . $operator->getType());
- }
- }
- private function getExtraOperatorField(ISearchComparison $operator, IMetadataQuery $metadataQuery): array {
- $paramType = self::$fieldTypes[$operator->getField()];
- $field = $operator->getField();
- $value = $operator->getValue();
- $type = $operator->getType();
- switch($operator->getExtra()) {
- case IMetadataQuery::EXTRA:
- $metadataQuery->joinIndex($field); // join index table if not joined yet
- $field = $metadataQuery->getMetadataValueField($field);
- break;
- default:
- throw new \InvalidArgumentException('Invalid extra type: ' . $operator->getExtra());
- }
- return [$field, $value, $type, $paramType];
- }
- private function getParameterForValue(IQueryBuilder $builder, $value, string $paramType) {
- if ($value instanceof \DateTime) {
- $value = $value->getTimestamp();
- }
- if (is_array($value)) {
- $type = self::$paramArrayTypeMap[$paramType];
- } else {
- $type = self::$paramTypeMap[$paramType];
- }
- return $builder->createNamedParameter($value, $type);
- }
- /**
- * @param IQueryBuilder $query
- * @param ISearchOrder[] $orders
- * @param IMetadataQuery|null $metadataQuery
- */
- public function addSearchOrdersToQuery(IQueryBuilder $query, array $orders, ?IMetadataQuery $metadataQuery = null): void {
- foreach ($orders as $order) {
- $field = $order->getField();
- switch ($order->getExtra()) {
- case IMetadataQuery::EXTRA:
- $metadataQuery->joinIndex($field); // join index table if not joined yet
- $field = $metadataQuery->getMetadataValueField($order->getField());
- break;
- default:
- if ($field === 'fileid') {
- $field = 'file.fileid';
- }
- // Mysql really likes to pick an index for sorting if it can't fully satisfy the where
- // filter with an index, since search queries pretty much never are fully filtered by index
- // mysql often picks an index for sorting instead of the much more useful index for filtering.
- //
- // By changing the order by to an expression, mysql isn't smart enough to see that it could still
- // use the index, so it instead picks an index for the filtering
- if ($field === 'mtime') {
- $field = $query->func()->add($field, $query->createNamedParameter(0));
- }
- }
- $query->addOrderBy($field, $order->getDirection());
- }
- }
- }
|