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 27namespace OC\Files\Cache; 28 29use OCP\DB\QueryBuilder\IQueryBuilder; 30use OCP\Files\IMimeTypeLoader; 31use OCP\Files\Search\ISearchBinaryOperator; 32use OCP\Files\Search\ISearchComparison; 33use OCP\Files\Search\ISearchOperator; 34use OCP\Files\Search\ISearchOrder; 35 36/** 37 * Tools for transforming search queries into database queries 38 */ 39class SearchBuilder { 40 protected static $searchOperatorMap = [ 41 ISearchComparison::COMPARE_LIKE => 'iLike', 42 ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'like', 43 ISearchComparison::COMPARE_EQUAL => 'eq', 44 ISearchComparison::COMPARE_GREATER_THAN => 'gt', 45 ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'gte', 46 ISearchComparison::COMPARE_LESS_THAN => 'lt', 47 ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'lte', 48 ]; 49 50 protected static $searchOperatorNegativeMap = [ 51 ISearchComparison::COMPARE_LIKE => 'notLike', 52 ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => '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 60 public const TAG_FAVORITE = '_$!<Favorite>!$_'; 61 62 /** @var IMimeTypeLoader */ 63 private $mimetypeLoader; 64 65 public function __construct( 66 IMimeTypeLoader $mimetypeLoader 67 ) { 68 $this->mimetypeLoader = $mimetypeLoader; 69 } 70 71 /** 72 * Whether or not the tag tables should be joined to complete the search 73 * 74 * @param ISearchOperator $operator 75 * @return boolean 76 */ 77 public function shouldJoinTags(ISearchOperator $operator) { 78 if ($operator instanceof ISearchBinaryOperator) { 79 return array_reduce($operator->getArguments(), function ($shouldJoin, ISearchOperator $operator) { 80 return $shouldJoin || $this->shouldJoinTags($operator); 81 }, false); 82 } elseif ($operator instanceof ISearchComparison) { 83 return $operator->getField() === 'tagname' || $operator->getField() === 'favorite'; 84 } 85 return false; 86 } 87 88 /** 89 * @param IQueryBuilder $builder 90 * @param ISearchOperator[] $operators 91 */ 92 public function searchOperatorArrayToDBExprArray(IQueryBuilder $builder, array $operators) { 93 return array_filter(array_map(function ($operator) use ($builder) { 94 return $this->searchOperatorToDBExpr($builder, $operator); 95 }, $operators)); 96 } 97 98 public function searchOperatorToDBExpr(IQueryBuilder $builder, ISearchOperator $operator) { 99 $expr = $builder->expr(); 100 101 if ($operator instanceof ISearchBinaryOperator) { 102 if (count($operator->getArguments()) === 0) { 103 return null; 104 } 105 106 switch ($operator->getType()) { 107 case ISearchBinaryOperator::OPERATOR_NOT: 108 $negativeOperator = $operator->getArguments()[0]; 109 if ($negativeOperator instanceof ISearchComparison) { 110 return $this->searchComparisonToDBExpr($builder, $negativeOperator, self::$searchOperatorNegativeMap); 111 } else { 112 throw new \InvalidArgumentException('Binary operators inside "not" is not supported'); 113 } 114 // no break 115 case ISearchBinaryOperator::OPERATOR_AND: 116 return call_user_func_array([$expr, 'andX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments())); 117 case ISearchBinaryOperator::OPERATOR_OR: 118 return call_user_func_array([$expr, 'orX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments())); 119 default: 120 throw new \InvalidArgumentException('Invalid operator type: ' . $operator->getType()); 121 } 122 } elseif ($operator instanceof ISearchComparison) { 123 return $this->searchComparisonToDBExpr($builder, $operator, self::$searchOperatorMap); 124 } else { 125 throw new \InvalidArgumentException('Invalid operator type: ' . get_class($operator)); 126 } 127 } 128 129 private function searchComparisonToDBExpr(IQueryBuilder $builder, ISearchComparison $comparison, array $operatorMap) { 130 $this->validateComparison($comparison); 131 132 [$field, $value, $type] = $this->getOperatorFieldAndValue($comparison); 133 if (isset($operatorMap[$type])) { 134 $queryOperator = $operatorMap[$type]; 135 return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value)); 136 } else { 137 throw new \InvalidArgumentException('Invalid operator type: ' . $comparison->getType()); 138 } 139 } 140 141 private function getOperatorFieldAndValue(ISearchComparison $operator) { 142 $field = $operator->getField(); 143 $value = $operator->getValue(); 144 $type = $operator->getType(); 145 if ($field === 'mimetype') { 146 $value = (string)$value; 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 && $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true)) { 171 $field = 'path_hash'; 172 $value = md5((string)$value); 173 } 174 return [$field, $value, $type]; 175 } 176 177 private function validateComparison(ISearchComparison $operator) { 178 $types = [ 179 'mimetype' => 'string', 180 'mtime' => 'integer', 181 'name' => 'string', 182 'path' => 'string', 183 'size' => 'integer', 184 'tagname' => 'string', 185 'favorite' => 'boolean', 186 'fileid' => 'integer', 187 'storage' => 'integer', 188 ]; 189 $comparisons = [ 190 'mimetype' => ['eq', 'like'], 191 'mtime' => ['eq', 'gt', 'lt', 'gte', 'lte'], 192 'name' => ['eq', 'like', 'clike'], 193 'path' => ['eq', 'like', 'clike'], 194 'size' => ['eq', 'gt', 'lt', 'gte', 'lte'], 195 'tagname' => ['eq', 'like'], 196 'favorite' => ['eq'], 197 'fileid' => ['eq'], 198 'storage' => ['eq'], 199 ]; 200 201 if (!isset($types[$operator->getField()])) { 202 throw new \InvalidArgumentException('Unsupported comparison field ' . $operator->getField()); 203 } 204 $type = $types[$operator->getField()]; 205 if (gettype($operator->getValue()) !== $type) { 206 throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField()); 207 } 208 if (!in_array($operator->getType(), $comparisons[$operator->getField()])) { 209 throw new \InvalidArgumentException('Unsupported comparison for field ' . $operator->getField() . ': ' . $operator->getType()); 210 } 211 } 212 213 private function getParameterForValue(IQueryBuilder $builder, $value) { 214 if ($value instanceof \DateTime) { 215 $value = $value->getTimestamp(); 216 } 217 if (is_numeric($value)) { 218 $type = IQueryBuilder::PARAM_INT; 219 } else { 220 $type = IQueryBuilder::PARAM_STR; 221 } 222 return $builder->createNamedParameter($value, $type); 223 } 224 225 /** 226 * @param IQueryBuilder $query 227 * @param ISearchOrder[] $orders 228 */ 229 public function addSearchOrdersToQuery(IQueryBuilder $query, array $orders) { 230 foreach ($orders as $order) { 231 $field = $order->getField(); 232 if ($field === 'fileid') { 233 $field = 'file.fileid'; 234 } 235 $query->addOrderBy($field, $order->getDirection()); 236 } 237 } 238} 239