SearchBuilder.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl>
  4. *
  5. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  6. * @author Maxence Lange <maxence@artificial-owl.com>
  7. * @author Robin Appelman <robin@icewind.nl>
  8. * @author Roeland Jago Douma <roeland@famdouma.nl>
  9. * @author Tobias Kaminsky <tobias@kaminsky.me>
  10. *
  11. * @license GNU AGPL version 3 or any later version
  12. *
  13. * This program is free software: you can redistribute it and/or modify
  14. * it under the terms of the GNU Affero General Public License as
  15. * published by the Free Software Foundation, either version 3 of the
  16. * License, or (at your option) any later version.
  17. *
  18. * This program is distributed in the hope that it will be useful,
  19. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  20. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  21. * GNU Affero General Public License for more details.
  22. *
  23. * You should have received a copy of the GNU Affero General Public License
  24. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  25. *
  26. */
  27. namespace OC\Files\Cache;
  28. use OCP\DB\QueryBuilder\IQueryBuilder;
  29. use OCP\Files\IMimeTypeLoader;
  30. use OCP\Files\Search\ISearchBinaryOperator;
  31. use OCP\Files\Search\ISearchComparison;
  32. use OCP\Files\Search\ISearchOperator;
  33. use OCP\Files\Search\ISearchOrder;
  34. use OCP\FilesMetadata\IMetadataQuery;
  35. /**
  36. * Tools for transforming search queries into database queries
  37. *
  38. * @psalm-import-type ParamSingleValue from ISearchComparison
  39. * @psalm-import-type ParamValue from ISearchComparison
  40. */
  41. class SearchBuilder {
  42. /** @var array<string, string> */
  43. protected static $searchOperatorMap = [
  44. ISearchComparison::COMPARE_LIKE => 'iLike',
  45. ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'like',
  46. ISearchComparison::COMPARE_EQUAL => 'eq',
  47. ISearchComparison::COMPARE_GREATER_THAN => 'gt',
  48. ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'gte',
  49. ISearchComparison::COMPARE_LESS_THAN => 'lt',
  50. ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'lte',
  51. ISearchComparison::COMPARE_DEFINED => 'isNotNull',
  52. ISearchComparison::COMPARE_IN => 'in',
  53. ];
  54. /** @var array<string, string> */
  55. protected static $searchOperatorNegativeMap = [
  56. ISearchComparison::COMPARE_LIKE => 'notLike',
  57. ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'notLike',
  58. ISearchComparison::COMPARE_EQUAL => 'neq',
  59. ISearchComparison::COMPARE_GREATER_THAN => 'lte',
  60. ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'lt',
  61. ISearchComparison::COMPARE_LESS_THAN => 'gte',
  62. ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'gt',
  63. ISearchComparison::COMPARE_DEFINED => 'isNull',
  64. ISearchComparison::COMPARE_IN => 'notIn',
  65. ];
  66. /** @var array<string, string> */
  67. protected static $fieldTypes = [
  68. 'mimetype' => 'string',
  69. 'mtime' => 'integer',
  70. 'name' => 'string',
  71. 'path' => 'string',
  72. 'size' => 'integer',
  73. 'tagname' => 'string',
  74. 'systemtag' => 'string',
  75. 'favorite' => 'boolean',
  76. 'fileid' => 'integer',
  77. 'storage' => 'integer',
  78. 'share_with' => 'string',
  79. 'share_type' => 'integer',
  80. 'owner' => 'string',
  81. ];
  82. /** @var array<string, int> */
  83. protected static $paramTypeMap = [
  84. 'string' => IQueryBuilder::PARAM_STR,
  85. 'integer' => IQueryBuilder::PARAM_INT,
  86. 'boolean' => IQueryBuilder::PARAM_BOOL,
  87. ];
  88. /** @var array<string, int> */
  89. protected static $paramArrayTypeMap = [
  90. 'string' => IQueryBuilder::PARAM_STR_ARRAY,
  91. 'integer' => IQueryBuilder::PARAM_INT_ARRAY,
  92. 'boolean' => IQueryBuilder::PARAM_INT_ARRAY,
  93. ];
  94. public const TAG_FAVORITE = '_$!<Favorite>!$_';
  95. /** @var IMimeTypeLoader */
  96. private $mimetypeLoader;
  97. public function __construct(
  98. IMimeTypeLoader $mimetypeLoader
  99. ) {
  100. $this->mimetypeLoader = $mimetypeLoader;
  101. }
  102. /**
  103. * @return string[]
  104. */
  105. public function extractRequestedFields(ISearchOperator $operator): array {
  106. if ($operator instanceof ISearchBinaryOperator) {
  107. return array_reduce($operator->getArguments(), function (array $fields, ISearchOperator $operator) {
  108. return array_unique(array_merge($fields, $this->extractRequestedFields($operator)));
  109. }, []);
  110. } elseif ($operator instanceof ISearchComparison && !$operator->getExtra()) {
  111. return [$operator->getField()];
  112. }
  113. return [];
  114. }
  115. /**
  116. * @param IQueryBuilder $builder
  117. * @param ISearchOperator[] $operators
  118. */
  119. public function searchOperatorArrayToDBExprArray(
  120. IQueryBuilder $builder,
  121. array $operators,
  122. ?IMetadataQuery $metadataQuery = null
  123. ) {
  124. return array_filter(array_map(function ($operator) use ($builder, $metadataQuery) {
  125. return $this->searchOperatorToDBExpr($builder, $operator, $metadataQuery);
  126. }, $operators));
  127. }
  128. public function searchOperatorToDBExpr(
  129. IQueryBuilder $builder,
  130. ISearchOperator $operator,
  131. ?IMetadataQuery $metadataQuery = null
  132. ) {
  133. $expr = $builder->expr();
  134. if ($operator instanceof ISearchBinaryOperator) {
  135. if (count($operator->getArguments()) === 0) {
  136. return null;
  137. }
  138. switch ($operator->getType()) {
  139. case ISearchBinaryOperator::OPERATOR_NOT:
  140. $negativeOperator = $operator->getArguments()[0];
  141. if ($negativeOperator instanceof ISearchComparison) {
  142. return $this->searchComparisonToDBExpr($builder, $negativeOperator, self::$searchOperatorNegativeMap, $metadataQuery);
  143. } else {
  144. throw new \InvalidArgumentException('Binary operators inside "not" is not supported');
  145. }
  146. // no break
  147. case ISearchBinaryOperator::OPERATOR_AND:
  148. return call_user_func_array([$expr, 'andX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments(), $metadataQuery));
  149. case ISearchBinaryOperator::OPERATOR_OR:
  150. return call_user_func_array([$expr, 'orX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments(), $metadataQuery));
  151. default:
  152. throw new \InvalidArgumentException('Invalid operator type: ' . $operator->getType());
  153. }
  154. } elseif ($operator instanceof ISearchComparison) {
  155. return $this->searchComparisonToDBExpr($builder, $operator, self::$searchOperatorMap, $metadataQuery);
  156. } else {
  157. throw new \InvalidArgumentException('Invalid operator type: ' . get_class($operator));
  158. }
  159. }
  160. private function searchComparisonToDBExpr(
  161. IQueryBuilder $builder,
  162. ISearchComparison $comparison,
  163. array $operatorMap,
  164. ?IMetadataQuery $metadataQuery = null
  165. ) {
  166. if ($comparison->getExtra()) {
  167. [$field, $value, $type, $paramType] = $this->getExtraOperatorField($comparison, $metadataQuery);
  168. } else {
  169. [$field, $value, $type, $paramType] = $this->getOperatorFieldAndValue($comparison);
  170. }
  171. if (isset($operatorMap[$type])) {
  172. $queryOperator = $operatorMap[$type];
  173. return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value, $paramType));
  174. } else {
  175. throw new \InvalidArgumentException('Invalid operator type: ' . $comparison->getType());
  176. }
  177. }
  178. /**
  179. * @param ISearchComparison $operator
  180. * @return list{string, ParamValue, string, string}
  181. */
  182. private function getOperatorFieldAndValue(ISearchComparison $operator): array {
  183. $this->validateComparison($operator);
  184. $field = $operator->getField();
  185. $value = $operator->getValue();
  186. $type = $operator->getType();
  187. $pathEqHash = $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true);
  188. return $this->getOperatorFieldAndValueInner($field, $value, $type, $pathEqHash);
  189. }
  190. /**
  191. * @param string $field
  192. * @param ParamValue $value
  193. * @param string $type
  194. * @return list{string, ParamValue, string, string}
  195. */
  196. private function getOperatorFieldAndValueInner(string $field, mixed $value, string $type, bool $pathEqHash): array {
  197. $paramType = self::$fieldTypes[$field];
  198. if ($type === ISearchComparison::COMPARE_IN) {
  199. $resultField = $field;
  200. $values = [];
  201. foreach ($value as $arrayValue) {
  202. /** @var ParamSingleValue $arrayValue */
  203. [$arrayField, $arrayValue] = $this->getOperatorFieldAndValueInner($field, $arrayValue, ISearchComparison::COMPARE_EQUAL, $pathEqHash);
  204. $resultField = $arrayField;
  205. $values[] = $arrayValue;
  206. }
  207. return [$resultField, $values, ISearchComparison::COMPARE_IN, $paramType];
  208. }
  209. if ($field === 'mimetype') {
  210. $value = (string)$value;
  211. if ($type === ISearchComparison::COMPARE_EQUAL) {
  212. $value = $this->mimetypeLoader->getId($value);
  213. } elseif ($type === ISearchComparison::COMPARE_LIKE) {
  214. // transform "mimetype='foo/%'" to "mimepart='foo'"
  215. if (preg_match('|(.+)/%|', $value, $matches)) {
  216. $field = 'mimepart';
  217. $value = $this->mimetypeLoader->getId($matches[1]);
  218. $type = ISearchComparison::COMPARE_EQUAL;
  219. } elseif (str_contains($value, '%')) {
  220. throw new \InvalidArgumentException('Unsupported query value for mimetype: ' . $value . ', only values in the format "mime/type" or "mime/%" are supported');
  221. } else {
  222. $field = 'mimetype';
  223. $value = $this->mimetypeLoader->getId($value);
  224. $type = ISearchComparison::COMPARE_EQUAL;
  225. }
  226. }
  227. } elseif ($field === 'favorite') {
  228. $field = 'tag.category';
  229. $value = self::TAG_FAVORITE;
  230. $paramType = 'string';
  231. } elseif ($field === 'name') {
  232. $field = 'file.name';
  233. } elseif ($field === 'tagname') {
  234. $field = 'tag.category';
  235. } elseif ($field === 'systemtag') {
  236. $field = 'systemtag.name';
  237. } elseif ($field === 'fileid') {
  238. $field = 'file.fileid';
  239. } elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL && $pathEqHash) {
  240. $field = 'path_hash';
  241. $value = md5((string)$value);
  242. } elseif ($field === 'owner') {
  243. $field = 'uid_owner';
  244. }
  245. return [$field, $value, $type, $paramType];
  246. }
  247. private function validateComparison(ISearchComparison $operator) {
  248. $comparisons = [
  249. 'mimetype' => ['eq', 'like', 'in'],
  250. 'mtime' => ['eq', 'gt', 'lt', 'gte', 'lte'],
  251. 'name' => ['eq', 'like', 'clike', 'in'],
  252. 'path' => ['eq', 'like', 'clike', 'in'],
  253. 'size' => ['eq', 'gt', 'lt', 'gte', 'lte'],
  254. 'tagname' => ['eq', 'like'],
  255. 'systemtag' => ['eq', 'like'],
  256. 'favorite' => ['eq'],
  257. 'fileid' => ['eq', 'in'],
  258. 'storage' => ['eq', 'in'],
  259. 'share_with' => ['eq'],
  260. 'share_type' => ['eq'],
  261. 'owner' => ['eq'],
  262. ];
  263. if (!isset(self::$fieldTypes[$operator->getField()])) {
  264. throw new \InvalidArgumentException('Unsupported comparison field ' . $operator->getField());
  265. }
  266. $type = self::$fieldTypes[$operator->getField()];
  267. if ($operator->getType() === ISearchComparison::COMPARE_IN) {
  268. if (!is_array($operator->getValue())) {
  269. throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
  270. }
  271. foreach ($operator->getValue() as $arrayValue) {
  272. if (gettype($arrayValue) !== $type) {
  273. throw new \InvalidArgumentException('Invalid type in array for field ' . $operator->getField());
  274. }
  275. }
  276. } else {
  277. if (gettype($operator->getValue()) !== $type) {
  278. throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
  279. }
  280. }
  281. if (!in_array($operator->getType(), $comparisons[$operator->getField()])) {
  282. throw new \InvalidArgumentException('Unsupported comparison for field ' . $operator->getField() . ': ' . $operator->getType());
  283. }
  284. }
  285. private function getExtraOperatorField(ISearchComparison $operator, IMetadataQuery $metadataQuery): array {
  286. $paramType = self::$fieldTypes[$operator->getField()];
  287. $field = $operator->getField();
  288. $value = $operator->getValue();
  289. $type = $operator->getType();
  290. switch($operator->getExtra()) {
  291. case IMetadataQuery::EXTRA:
  292. $metadataQuery->joinIndex($field); // join index table if not joined yet
  293. $field = $metadataQuery->getMetadataValueField($field);
  294. break;
  295. default:
  296. throw new \InvalidArgumentException('Invalid extra type: ' . $operator->getExtra());
  297. }
  298. return [$field, $value, $type, $paramType];
  299. }
  300. private function getParameterForValue(IQueryBuilder $builder, $value, string $paramType) {
  301. if ($value instanceof \DateTime) {
  302. $value = $value->getTimestamp();
  303. }
  304. if (is_array($value)) {
  305. $type = self::$paramArrayTypeMap[$paramType];
  306. } else {
  307. $type = self::$paramTypeMap[$paramType];
  308. }
  309. return $builder->createNamedParameter($value, $type);
  310. }
  311. /**
  312. * @param IQueryBuilder $query
  313. * @param ISearchOrder[] $orders
  314. * @param IMetadataQuery|null $metadataQuery
  315. */
  316. public function addSearchOrdersToQuery(IQueryBuilder $query, array $orders, ?IMetadataQuery $metadataQuery = null): void {
  317. foreach ($orders as $order) {
  318. $field = $order->getField();
  319. switch ($order->getExtra()) {
  320. case IMetadataQuery::EXTRA:
  321. $metadataQuery->joinIndex($field); // join index table if not joined yet
  322. $field = $metadataQuery->getMetadataValueField($order->getField());
  323. break;
  324. default:
  325. if ($field === 'fileid') {
  326. $field = 'file.fileid';
  327. }
  328. // Mysql really likes to pick an index for sorting if it can't fully satisfy the where
  329. // filter with an index, since search queries pretty much never are fully filtered by index
  330. // mysql often picks an index for sorting instead of the much more useful index for filtering.
  331. //
  332. // By changing the order by to an expression, mysql isn't smart enough to see that it could still
  333. // use the index, so it instead picks an index for the filtering
  334. if ($field === 'mtime') {
  335. $field = $query->func()->add($field, $query->createNamedParameter(0));
  336. }
  337. }
  338. $query->addOrderBy($field, $order->getDirection());
  339. }
  340. }
  341. }