You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

339 lines
12 KiB

  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl>
  4. *
  5. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  6. * @author Robin Appelman <robin@icewind.nl>
  7. * @author Roeland Jago Douma <roeland@famdouma.nl>
  8. * @author Tobias Kaminsky <tobias@kaminsky.me>
  9. *
  10. * @license GNU AGPL version 3 or any later version
  11. *
  12. * This program is free software: you can redistribute it and/or modify
  13. * it under the terms of the GNU Affero General Public License as
  14. * published by the Free Software Foundation, either version 3 of the
  15. * License, or (at your option) any later version.
  16. *
  17. * This program is distributed in the hope that it will be useful,
  18. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  19. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  20. * GNU Affero General Public License for more details.
  21. *
  22. * You should have received a copy of the GNU Affero General Public License
  23. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  24. *
  25. */
  26. namespace OC\Files\Cache;
  27. use OC\Files\Search\SearchBinaryOperator;
  28. use OC\SystemConfig;
  29. use OCP\DB\QueryBuilder\IQueryBuilder;
  30. use OCP\Files\Cache\ICache;
  31. use OCP\Files\IMimeTypeLoader;
  32. use OCP\Files\Search\ISearchBinaryOperator;
  33. use OCP\Files\Search\ISearchComparison;
  34. use OCP\Files\Search\ISearchOperator;
  35. use OCP\Files\Search\ISearchOrder;
  36. use OCP\Files\Search\ISearchQuery;
  37. use OCP\IDBConnection;
  38. use OCP\ILogger;
  39. /**
  40. * Tools for transforming search queries into database queries
  41. */
  42. class QuerySearchHelper {
  43. protected static $searchOperatorMap = [
  44. ISearchComparison::COMPARE_LIKE => 'iLike',
  45. ISearchComparison::COMPARE_EQUAL => 'eq',
  46. ISearchComparison::COMPARE_GREATER_THAN => 'gt',
  47. ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'gte',
  48. ISearchComparison::COMPARE_LESS_THAN => 'lt',
  49. ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'lte',
  50. ];
  51. protected static $searchOperatorNegativeMap = [
  52. ISearchComparison::COMPARE_LIKE => 'notLike',
  53. ISearchComparison::COMPARE_EQUAL => 'neq',
  54. ISearchComparison::COMPARE_GREATER_THAN => 'lte',
  55. ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'lt',
  56. ISearchComparison::COMPARE_LESS_THAN => 'gte',
  57. ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'lt',
  58. ];
  59. public const TAG_FAVORITE = '_$!<Favorite>!$_';
  60. /** @var IMimeTypeLoader */
  61. private $mimetypeLoader;
  62. /** @var IDBConnection */
  63. private $connection;
  64. /** @var SystemConfig */
  65. private $systemConfig;
  66. /** @var ILogger */
  67. private $logger;
  68. public function __construct(
  69. IMimeTypeLoader $mimetypeLoader,
  70. IDBConnection $connection,
  71. SystemConfig $systemConfig,
  72. ILogger $logger
  73. ) {
  74. $this->mimetypeLoader = $mimetypeLoader;
  75. $this->connection = $connection;
  76. $this->systemConfig = $systemConfig;
  77. $this->logger = $logger;
  78. }
  79. /**
  80. * Whether or not the tag tables should be joined to complete the search
  81. *
  82. * @param ISearchOperator $operator
  83. * @return boolean
  84. */
  85. public function shouldJoinTags(ISearchOperator $operator) {
  86. if ($operator instanceof ISearchBinaryOperator) {
  87. return array_reduce($operator->getArguments(), function ($shouldJoin, ISearchOperator $operator) {
  88. return $shouldJoin || $this->shouldJoinTags($operator);
  89. }, false);
  90. } elseif ($operator instanceof ISearchComparison) {
  91. return $operator->getField() === 'tagname' || $operator->getField() === 'favorite';
  92. }
  93. return false;
  94. }
  95. /**
  96. * @param IQueryBuilder $builder
  97. * @param ISearchOperator $operator
  98. */
  99. public function searchOperatorArrayToDBExprArray(IQueryBuilder $builder, array $operators) {
  100. return array_filter(array_map(function ($operator) use ($builder) {
  101. return $this->searchOperatorToDBExpr($builder, $operator);
  102. }, $operators));
  103. }
  104. public function searchOperatorToDBExpr(IQueryBuilder $builder, ISearchOperator $operator) {
  105. $expr = $builder->expr();
  106. if ($operator instanceof ISearchBinaryOperator) {
  107. if (count($operator->getArguments()) === 0) {
  108. return null;
  109. }
  110. switch ($operator->getType()) {
  111. case ISearchBinaryOperator::OPERATOR_NOT:
  112. $negativeOperator = $operator->getArguments()[0];
  113. if ($negativeOperator instanceof ISearchComparison) {
  114. return $this->searchComparisonToDBExpr($builder, $negativeOperator, self::$searchOperatorNegativeMap);
  115. } else {
  116. throw new \InvalidArgumentException('Binary operators inside "not" is not supported');
  117. }
  118. // no break
  119. case ISearchBinaryOperator::OPERATOR_AND:
  120. return call_user_func_array([$expr, 'andX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments()));
  121. case ISearchBinaryOperator::OPERATOR_OR:
  122. return call_user_func_array([$expr, 'orX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments()));
  123. default:
  124. throw new \InvalidArgumentException('Invalid operator type: ' . $operator->getType());
  125. }
  126. } elseif ($operator instanceof ISearchComparison) {
  127. return $this->searchComparisonToDBExpr($builder, $operator, self::$searchOperatorMap);
  128. } else {
  129. throw new \InvalidArgumentException('Invalid operator type: ' . get_class($operator));
  130. }
  131. }
  132. private function searchComparisonToDBExpr(IQueryBuilder $builder, ISearchComparison $comparison, array $operatorMap) {
  133. $this->validateComparison($comparison);
  134. [$field, $value, $type] = $this->getOperatorFieldAndValue($comparison);
  135. if (isset($operatorMap[$type])) {
  136. $queryOperator = $operatorMap[$type];
  137. return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value));
  138. } else {
  139. throw new \InvalidArgumentException('Invalid operator type: ' . $comparison->getType());
  140. }
  141. }
  142. private function getOperatorFieldAndValue(ISearchComparison $operator) {
  143. $field = $operator->getField();
  144. $value = $operator->getValue();
  145. $type = $operator->getType();
  146. if ($field === 'mimetype') {
  147. if ($operator->getType() === ISearchComparison::COMPARE_EQUAL) {
  148. $value = (int)$this->mimetypeLoader->getId($value);
  149. } elseif ($operator->getType() === ISearchComparison::COMPARE_LIKE) {
  150. // transform "mimetype='foo/%'" to "mimepart='foo'"
  151. if (preg_match('|(.+)/%|', $value, $matches)) {
  152. $field = 'mimepart';
  153. $value = (int)$this->mimetypeLoader->getId($matches[1]);
  154. $type = ISearchComparison::COMPARE_EQUAL;
  155. } elseif (strpos($value, '%') !== false) {
  156. throw new \InvalidArgumentException('Unsupported query value for mimetype: ' . $value . ', only values in the format "mime/type" or "mime/%" are supported');
  157. } else {
  158. $field = 'mimetype';
  159. $value = (int)$this->mimetypeLoader->getId($value);
  160. $type = ISearchComparison::COMPARE_EQUAL;
  161. }
  162. }
  163. } elseif ($field === 'favorite') {
  164. $field = 'tag.category';
  165. $value = self::TAG_FAVORITE;
  166. } elseif ($field === 'tagname') {
  167. $field = 'tag.category';
  168. } elseif ($field === 'fileid') {
  169. $field = 'file.fileid';
  170. } elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL) {
  171. $field = 'path_hash';
  172. $value = md5((string)$value);
  173. }
  174. return [$field, $value, $type];
  175. }
  176. private function validateComparison(ISearchComparison $operator) {
  177. $types = [
  178. 'mimetype' => 'string',
  179. 'mtime' => 'integer',
  180. 'name' => 'string',
  181. 'path' => 'string',
  182. 'size' => 'integer',
  183. 'tagname' => 'string',
  184. 'favorite' => 'boolean',
  185. 'fileid' => 'integer',
  186. 'storage' => 'integer',
  187. ];
  188. $comparisons = [
  189. 'mimetype' => ['eq', 'like'],
  190. 'mtime' => ['eq', 'gt', 'lt', 'gte', 'lte'],
  191. 'name' => ['eq', 'like'],
  192. 'path' => ['eq', 'like'],
  193. 'size' => ['eq', 'gt', 'lt', 'gte', 'lte'],
  194. 'tagname' => ['eq', 'like'],
  195. 'favorite' => ['eq'],
  196. 'fileid' => ['eq'],
  197. 'storage' => ['eq'],
  198. ];
  199. if (!isset($types[$operator->getField()])) {
  200. throw new \InvalidArgumentException('Unsupported comparison field ' . $operator->getField());
  201. }
  202. $type = $types[$operator->getField()];
  203. if (gettype($operator->getValue()) !== $type) {
  204. throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
  205. }
  206. if (!in_array($operator->getType(), $comparisons[$operator->getField()])) {
  207. throw new \InvalidArgumentException('Unsupported comparison for field ' . $operator->getField() . ': ' . $operator->getType());
  208. }
  209. }
  210. private function getParameterForValue(IQueryBuilder $builder, $value) {
  211. if ($value instanceof \DateTime) {
  212. $value = $value->getTimestamp();
  213. }
  214. if (is_numeric($value)) {
  215. $type = IQueryBuilder::PARAM_INT;
  216. } else {
  217. $type = IQueryBuilder::PARAM_STR;
  218. }
  219. return $builder->createNamedParameter($value, $type);
  220. }
  221. /**
  222. * @param IQueryBuilder $query
  223. * @param ISearchOrder[] $orders
  224. */
  225. public function addSearchOrdersToQuery(IQueryBuilder $query, array $orders) {
  226. foreach ($orders as $order) {
  227. $query->addOrderBy($order->getField(), $order->getDirection());
  228. }
  229. }
  230. protected function getQueryBuilder() {
  231. return new CacheQueryBuilder(
  232. $this->connection,
  233. $this->systemConfig,
  234. $this->logger
  235. );
  236. }
  237. /**
  238. * @param ISearchQuery $searchQuery
  239. * @param ICache[] $caches
  240. * @return CacheEntry[]
  241. */
  242. public function searchInCaches(ISearchQuery $searchQuery, array $caches): array {
  243. // search in multiple caches at once by creating one query in the following format
  244. // SELECT ... FROM oc_filecache WHERE
  245. // <filter expressions from the search query>
  246. // AND (
  247. // <filter expression for storage1> OR
  248. // <filter expression for storage2> OR
  249. // ...
  250. // );
  251. //
  252. // This gives us all the files matching the search query from all caches
  253. //
  254. // while the resulting rows don't have a way to tell what storage they came from (multiple storages/caches can share storage_id)
  255. // we can just ask every cache if the row belongs to them and give them the cache to do any post processing on the result.
  256. $builder = $this->getQueryBuilder();
  257. $query = $builder->selectFileCache('file');
  258. if ($this->shouldJoinTags($searchQuery->getSearchOperation())) {
  259. $user = $searchQuery->getUser();
  260. if ($user === null) {
  261. throw new \InvalidArgumentException("Searching by tag requires the user to be set in the query");
  262. }
  263. $query
  264. ->innerJoin('file', 'vcategory_to_object', 'tagmap', $builder->expr()->eq('file.fileid', 'tagmap.objid'))
  265. ->innerJoin('tagmap', 'vcategory', 'tag', $builder->expr()->andX(
  266. $builder->expr()->eq('tagmap.type', 'tag.type'),
  267. $builder->expr()->eq('tagmap.categoryid', 'tag.id')
  268. ))
  269. ->andWhere($builder->expr()->eq('tag.type', $builder->createNamedParameter('files')))
  270. ->andWhere($builder->expr()->eq('tag.uid', $builder->createNamedParameter($user->getUID())));
  271. }
  272. $searchExpr = $this->searchOperatorToDBExpr($builder, $searchQuery->getSearchOperation());
  273. if ($searchExpr) {
  274. $query->andWhere($searchExpr);
  275. }
  276. $storageFilters = array_map(function (ICache $cache) {
  277. return $cache->getQueryFilterForStorage();
  278. }, $caches);
  279. $query->andWhere($this->searchOperatorToDBExpr($builder, new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $storageFilters)));
  280. if ($searchQuery->limitToHome() && ($this instanceof HomeCache)) {
  281. $query->andWhere($builder->expr()->like('path', $query->expr()->literal('files/%')));
  282. }
  283. $this->addSearchOrdersToQuery($query, $searchQuery->getOrder());
  284. if ($searchQuery->getLimit()) {
  285. $query->setMaxResults($searchQuery->getLimit());
  286. }
  287. if ($searchQuery->getOffset()) {
  288. $query->setFirstResult($searchQuery->getOffset());
  289. }
  290. $result = $query->execute();
  291. $files = $result->fetchAll();
  292. $rawEntries = array_map(function (array $data) {
  293. return Cache::cacheEntryFromData($data, $this->mimetypeLoader);
  294. }, $files);
  295. $result->closeCursor();
  296. // loop trough all caches for each result to see if the result matches that storage
  297. $results = [];
  298. foreach ($rawEntries as $rawEntry) {
  299. foreach ($caches as $cache) {
  300. $entry = $cache->getCacheEntryFromSearchResult($rawEntry);
  301. if ($entry) {
  302. $results[] = $entry;
  303. }
  304. }
  305. }
  306. return $results;
  307. }
  308. }