SearchBuilder.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  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. class SearchBuilder {
  39. protected static $searchOperatorMap = [
  40. ISearchComparison::COMPARE_LIKE => 'iLike',
  41. ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'like',
  42. ISearchComparison::COMPARE_EQUAL => 'eq',
  43. ISearchComparison::COMPARE_GREATER_THAN => 'gt',
  44. ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'gte',
  45. ISearchComparison::COMPARE_LESS_THAN => 'lt',
  46. ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'lte',
  47. ISearchComparison::COMPARE_DEFINED => 'isNotNull',
  48. ];
  49. protected static $searchOperatorNegativeMap = [
  50. ISearchComparison::COMPARE_LIKE => 'notLike',
  51. ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'notLike',
  52. ISearchComparison::COMPARE_EQUAL => 'neq',
  53. ISearchComparison::COMPARE_GREATER_THAN => 'lte',
  54. ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'lt',
  55. ISearchComparison::COMPARE_LESS_THAN => 'gte',
  56. ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'gt',
  57. ISearchComparison::COMPARE_DEFINED => 'isNull',
  58. ];
  59. public const TAG_FAVORITE = '_$!<Favorite>!$_';
  60. /** @var IMimeTypeLoader */
  61. private $mimetypeLoader;
  62. public function __construct(
  63. IMimeTypeLoader $mimetypeLoader
  64. ) {
  65. $this->mimetypeLoader = $mimetypeLoader;
  66. }
  67. /**
  68. * @return string[]
  69. */
  70. public function extractRequestedFields(ISearchOperator $operator): array {
  71. if ($operator instanceof ISearchBinaryOperator) {
  72. return array_reduce($operator->getArguments(), function (array $fields, ISearchOperator $operator) {
  73. return array_unique(array_merge($fields, $this->extractRequestedFields($operator)));
  74. }, []);
  75. } elseif ($operator instanceof ISearchComparison && !$operator->getExtra()) {
  76. return [$operator->getField()];
  77. }
  78. return [];
  79. }
  80. /**
  81. * @param IQueryBuilder $builder
  82. * @param ISearchOperator[] $operators
  83. */
  84. public function searchOperatorArrayToDBExprArray(
  85. IQueryBuilder $builder,
  86. array $operators,
  87. ?IMetadataQuery $metadataQuery = null
  88. ) {
  89. return array_filter(array_map(function ($operator) use ($builder, $metadataQuery) {
  90. return $this->searchOperatorToDBExpr($builder, $operator, $metadataQuery);
  91. }, $operators));
  92. }
  93. public function searchOperatorToDBExpr(
  94. IQueryBuilder $builder,
  95. ISearchOperator $operator,
  96. ?IMetadataQuery $metadataQuery = null
  97. ) {
  98. $expr = $builder->expr();
  99. if ($operator instanceof ISearchBinaryOperator) {
  100. if (count($operator->getArguments()) === 0) {
  101. return null;
  102. }
  103. switch ($operator->getType()) {
  104. case ISearchBinaryOperator::OPERATOR_NOT:
  105. $negativeOperator = $operator->getArguments()[0];
  106. if ($negativeOperator instanceof ISearchComparison) {
  107. return $this->searchComparisonToDBExpr($builder, $negativeOperator, self::$searchOperatorNegativeMap, $metadataQuery);
  108. } else {
  109. throw new \InvalidArgumentException('Binary operators inside "not" is not supported');
  110. }
  111. // no break
  112. case ISearchBinaryOperator::OPERATOR_AND:
  113. return call_user_func_array([$expr, 'andX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments(), $metadataQuery));
  114. case ISearchBinaryOperator::OPERATOR_OR:
  115. return call_user_func_array([$expr, 'orX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments(), $metadataQuery));
  116. default:
  117. throw new \InvalidArgumentException('Invalid operator type: ' . $operator->getType());
  118. }
  119. } elseif ($operator instanceof ISearchComparison) {
  120. return $this->searchComparisonToDBExpr($builder, $operator, self::$searchOperatorMap, $metadataQuery);
  121. } else {
  122. throw new \InvalidArgumentException('Invalid operator type: ' . get_class($operator));
  123. }
  124. }
  125. private function searchComparisonToDBExpr(
  126. IQueryBuilder $builder,
  127. ISearchComparison $comparison,
  128. array $operatorMap,
  129. ?IMetadataQuery $metadataQuery = null
  130. ) {
  131. if ($comparison->getExtra()) {
  132. [$field, $value, $type] = $this->getExtraOperatorField($comparison, $metadataQuery);
  133. } else {
  134. [$field, $value, $type] = $this->getOperatorFieldAndValue($comparison);
  135. }
  136. if (isset($operatorMap[$type])) {
  137. $queryOperator = $operatorMap[$type];
  138. return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value));
  139. } else {
  140. throw new \InvalidArgumentException('Invalid operator type: ' . $comparison->getType());
  141. }
  142. }
  143. private function getOperatorFieldAndValue(ISearchComparison $operator) {
  144. $this->validateComparison($operator);
  145. $field = $operator->getField();
  146. $value = $operator->getValue();
  147. $type = $operator->getType();
  148. if ($field === 'mimetype') {
  149. $value = (string)$value;
  150. if ($operator->getType() === ISearchComparison::COMPARE_EQUAL) {
  151. $value = (int)$this->mimetypeLoader->getId($value);
  152. } elseif ($operator->getType() === ISearchComparison::COMPARE_LIKE) {
  153. // transform "mimetype='foo/%'" to "mimepart='foo'"
  154. if (preg_match('|(.+)/%|', $value, $matches)) {
  155. $field = 'mimepart';
  156. $value = (int)$this->mimetypeLoader->getId($matches[1]);
  157. $type = ISearchComparison::COMPARE_EQUAL;
  158. } elseif (str_contains($value, '%')) {
  159. throw new \InvalidArgumentException('Unsupported query value for mimetype: ' . $value . ', only values in the format "mime/type" or "mime/%" are supported');
  160. } else {
  161. $field = 'mimetype';
  162. $value = (int)$this->mimetypeLoader->getId($value);
  163. $type = ISearchComparison::COMPARE_EQUAL;
  164. }
  165. }
  166. } elseif ($field === 'favorite') {
  167. $field = 'tag.category';
  168. $value = self::TAG_FAVORITE;
  169. } elseif ($field === 'name') {
  170. $field = 'file.name';
  171. } elseif ($field === 'tagname') {
  172. $field = 'tag.category';
  173. } elseif ($field === 'systemtag') {
  174. $field = 'systemtag.name';
  175. } elseif ($field === 'fileid') {
  176. $field = 'file.fileid';
  177. } elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL && $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true)) {
  178. $field = 'path_hash';
  179. $value = md5((string)$value);
  180. } elseif ($field === 'owner') {
  181. $field = 'uid_owner';
  182. }
  183. return [$field, $value, $type];
  184. }
  185. private function validateComparison(ISearchComparison $operator) {
  186. $types = [
  187. 'mimetype' => 'string',
  188. 'mtime' => 'integer',
  189. 'name' => 'string',
  190. 'path' => 'string',
  191. 'size' => 'integer',
  192. 'tagname' => 'string',
  193. 'systemtag' => 'string',
  194. 'favorite' => 'boolean',
  195. 'fileid' => 'integer',
  196. 'storage' => 'integer',
  197. 'share_with' => 'string',
  198. 'share_type' => 'integer',
  199. 'owner' => 'string',
  200. ];
  201. $comparisons = [
  202. 'mimetype' => ['eq', 'like'],
  203. 'mtime' => ['eq', 'gt', 'lt', 'gte', 'lte'],
  204. 'name' => ['eq', 'like', 'clike'],
  205. 'path' => ['eq', 'like', 'clike'],
  206. 'size' => ['eq', 'gt', 'lt', 'gte', 'lte'],
  207. 'tagname' => ['eq', 'like'],
  208. 'systemtag' => ['eq', 'like'],
  209. 'favorite' => ['eq'],
  210. 'fileid' => ['eq'],
  211. 'storage' => ['eq'],
  212. 'share_with' => ['eq'],
  213. 'share_type' => ['eq'],
  214. 'owner' => ['eq'],
  215. ];
  216. if (!isset($types[$operator->getField()])) {
  217. throw new \InvalidArgumentException('Unsupported comparison field ' . $operator->getField());
  218. }
  219. $type = $types[$operator->getField()];
  220. if (gettype($operator->getValue()) !== $type) {
  221. throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
  222. }
  223. if (!in_array($operator->getType(), $comparisons[$operator->getField()])) {
  224. throw new \InvalidArgumentException('Unsupported comparison for field ' . $operator->getField() . ': ' . $operator->getType());
  225. }
  226. }
  227. private function getExtraOperatorField(ISearchComparison $operator, IMetadataQuery $metadataQuery): array {
  228. $field = $operator->getField();
  229. $value = $operator->getValue();
  230. $type = $operator->getType();
  231. switch($operator->getExtra()) {
  232. case IMetadataQuery::EXTRA:
  233. $metadataQuery->joinIndex($field); // join index table if not joined yet
  234. $field = $metadataQuery->getMetadataValueField($field);
  235. break;
  236. default:
  237. throw new \InvalidArgumentException('Invalid extra type: ' . $operator->getExtra());
  238. }
  239. return [$field, $value, $type];
  240. }
  241. private function getParameterForValue(IQueryBuilder $builder, $value) {
  242. if ($value instanceof \DateTime) {
  243. $value = $value->getTimestamp();
  244. }
  245. if (is_numeric($value)) {
  246. $type = IQueryBuilder::PARAM_INT;
  247. } else {
  248. $type = IQueryBuilder::PARAM_STR;
  249. }
  250. return $builder->createNamedParameter($value, $type);
  251. }
  252. /**
  253. * @param IQueryBuilder $query
  254. * @param ISearchOrder[] $orders
  255. * @param IMetadataQuery|null $metadataQuery
  256. */
  257. public function addSearchOrdersToQuery(IQueryBuilder $query, array $orders, ?IMetadataQuery $metadataQuery = null): void {
  258. foreach ($orders as $order) {
  259. $field = $order->getField();
  260. switch ($order->getExtra()) {
  261. case IMetadataQuery::EXTRA:
  262. $metadataQuery->joinIndex($field); // join index table if not joined yet
  263. $field = $metadataQuery->getMetadataValueField($order->getField());
  264. break;
  265. default:
  266. if ($field === 'fileid') {
  267. $field = 'file.fileid';
  268. }
  269. // Mysql really likes to pick an index for sorting if it can't fully satisfy the where
  270. // filter with an index, since search queries pretty much never are fully filtered by index
  271. // mysql often picks an index for sorting instead of the much more useful index for filtering.
  272. //
  273. // By changing the order by to an expression, mysql isn't smart enough to see that it could still
  274. // use the index, so it instead picks an index for the filtering
  275. if ($field === 'mtime') {
  276. $field = $query->func()->add($field, $query->createNamedParameter(0));
  277. }
  278. }
  279. $query->addOrderBy($field, $order->getDirection());
  280. }
  281. }
  282. }