PageRenderTime 60ms CodeModel.GetById 33ms RepoModel.GetById 0ms app.codeStats 0ms

/applications/vanilla/Search/DiscussionSearchType.php

https://github.com/vanilla/vanilla
PHP | 483 lines | 312 code | 54 blank | 117 comment | 31 complexity | fe17619774d6eef101bfb863e989949f MD5 | raw file
Possible License(s): GPL-2.0, MIT, AGPL-1.0
  1. <?php
  2. /**
  3. * @author Adam Charron <adam.c@vanillaforums.com>
  4. * @copyright 2009-2020 Vanilla Forums Inc.
  5. * @license GPL-2.0-only
  6. */
  7. namespace Vanilla\Forum\Search;
  8. use Garden\Schema\Schema;
  9. use Garden\Schema\Validation;
  10. use Garden\Schema\ValidationException;
  11. use Garden\Web\Exception\HttpException;
  12. use Vanilla\Cloud\ElasticSearch\Driver\ElasticSearchQuery;
  13. use Vanilla\DateFilterSchema;
  14. use Vanilla\Exception\PermissionException;
  15. use Vanilla\Forum\Navigation\ForumCategoryRecordType;
  16. use Vanilla\Navigation\BreadcrumbModel;
  17. use Vanilla\Search\BoostableSearchQueryInterface;
  18. use Vanilla\Search\CollapsableSerachQueryInterface;
  19. use Vanilla\Search\MysqlSearchQuery;
  20. use Vanilla\Search\SearchQuery;
  21. use Vanilla\Search\AbstractSearchType;
  22. use Vanilla\Search\SearchResultItem;
  23. use Vanilla\Search\SearchTypeQueryExtenderInterface;
  24. use Vanilla\Utility\ArrayUtils;
  25. use Vanilla\Utility\ModelUtils;
  26. use Vanilla\Models\CrawlableRecordSchema;
  27. /**
  28. * Search record type for a discussion.
  29. */
  30. class DiscussionSearchType extends AbstractSearchType {
  31. /** @var \DiscussionsApiController */
  32. protected $discussionsApi;
  33. /** @var \CategoryModel */
  34. protected $categoryModel;
  35. /** @var \UserModel $userModel */
  36. protected $userModel;
  37. /** @var \TagModel */
  38. protected $tagModel;
  39. /** @var BreadcrumbModel */
  40. protected $breadcrumbModel;
  41. /** @var array extenders */
  42. protected $extenders = [];
  43. /**
  44. * DI.
  45. *
  46. * @param \DiscussionsApiController $discussionsApi
  47. * @param \CategoryModel $categoryModel
  48. * @param \UserModel $userModel
  49. * @param \TagModel $tagModel
  50. * @param BreadcrumbModel $breadcrumbModel
  51. */
  52. public function __construct(
  53. \DiscussionsApiController $discussionsApi,
  54. \CategoryModel $categoryModel,
  55. \UserModel $userModel,
  56. \TagModel $tagModel,
  57. BreadcrumbModel $breadcrumbModel
  58. ) {
  59. $this->discussionsApi = $discussionsApi;
  60. $this->categoryModel = $categoryModel;
  61. $this->userModel = $userModel;
  62. $this->tagModel = $tagModel;
  63. $this->breadcrumbModel = $breadcrumbModel;
  64. }
  65. /**
  66. * @inheritdoc
  67. */
  68. public function getKey(): string {
  69. return 'discussion';
  70. }
  71. /**
  72. * Register search query extender
  73. *
  74. * @param SearchTypeQueryExtenderInterface $extender
  75. */
  76. public function registerQueryExtender(SearchTypeQueryExtenderInterface $extender) {
  77. $this->extenders[] = $extender;
  78. }
  79. /**
  80. * @inheritdoc
  81. */
  82. public function getSearchGroup(): string {
  83. return 'discussion';
  84. }
  85. /**
  86. * @inheritdoc
  87. */
  88. public function getType(): string {
  89. return 'discussion';
  90. }
  91. /**
  92. * @return bool
  93. */
  94. public function supportsCollapsing(): bool {
  95. return true;
  96. }
  97. /**
  98. * @inheritdoc
  99. */
  100. public function getResultItems(array $recordIDs, SearchQuery $query): array {
  101. if ($query->supportsExtenders()) {
  102. foreach ($this->extenders as $extender) {
  103. $extender->extendPermissions();
  104. }
  105. }
  106. try {
  107. $results = $this->discussionsApi->index([
  108. 'discussionID' => implode(",", $recordIDs),
  109. 'limit' => 100,
  110. 'expand' => [ModelUtils::EXPAND_CRAWL, 'tagIDs'],
  111. ]);
  112. $results = $results->getData();
  113. if (!$results) {
  114. return [];
  115. }
  116. $resultItems = array_map(function ($result) {
  117. $mapped = ArrayUtils::remapProperties($result, [
  118. 'recordID' => 'discussionID',
  119. ]);
  120. $mapped['recordType'] = $this->getSearchGroup();
  121. $mapped['type'] = $this->getType();
  122. $mapped['legacyType'] = $this->getSingularLabel();
  123. $mapped['breadcrumbs'] = $this->breadcrumbModel->getForRecord(new ForumCategoryRecordType($mapped['categoryID']));
  124. return new DiscussionSearchResultItem($mapped);
  125. }, $results);
  126. return $resultItems;
  127. } catch (HttpException $exception) {
  128. trigger_error($exception->getMessage(), E_USER_WARNING);
  129. return [];
  130. }
  131. }
  132. /**
  133. * @inheritdoc
  134. */
  135. public function applyToQuery(SearchQuery $query) {
  136. if ($query instanceof MysqlSearchQuery) {
  137. $query->addSql($this->generateSql($query));
  138. } else {
  139. $query->addIndex($this->getIndex());
  140. $locale = $query->getQueryParameter('locale');
  141. $name = $query->getQueryParameter('name');
  142. if ($name) {
  143. $query->whereText($name, ['name'], $query::MATCH_FULLTEXT_EXTENDED, $locale);
  144. }
  145. $allTextQuery = $query->getQueryParameter('query');
  146. if ($allTextQuery) {
  147. $fields = $query->setQueryMatchFields(['name', 'body']);
  148. $query->whereText($allTextQuery, $fields, $query::MATCH_FULLTEXT_EXTENDED, $locale);
  149. }
  150. if ($discussionID = $query->getQueryParameter('discussionID', false)) {
  151. $query->setFilter('DiscussionID', [$discussionID]);
  152. };
  153. $categoryIDs = $this->getCategoryIDs($query);
  154. if (!empty($categoryIDs)) {
  155. $query->setFilter('CategoryID', $categoryIDs);
  156. }
  157. if ($query->supportsExtenders()) {
  158. /** @var SearchTypeQueryExtenderInterface $extender */
  159. foreach ($this->extenders as $extender) {
  160. $extender->extendQuery($query);
  161. }
  162. }
  163. if ($query instanceof BoostableSearchQueryInterface && $query->getBoostParameter('discussionRecency')) {
  164. $query->startBoostQuery();
  165. $query->boostFieldRecency('dateInserted');
  166. $query->boostType($this, $this->getBoostValue());
  167. $query->endBoostQuery();
  168. }
  169. // tags
  170. // Notably includes 0 to still allow other normalized records if set.
  171. $tagNames = $query->getQueryParameter('tags', []);
  172. $tagIDs = $this->tagModel->getTagIDsByName($tagNames);
  173. $tagOp = $query->getQueryParameter('tagOperator', 'or');
  174. if (!empty($tagIDs)) {
  175. if ($query instanceof ElasticSearchQuery) {
  176. $query->setFilter('tagIDs', $tagIDs, false, $tagOp);
  177. } else {
  178. $query->setFilter('Tags', $tagIDs, false, $tagOp);
  179. }
  180. }
  181. }
  182. }
  183. /**
  184. * @return float|null
  185. */
  186. protected function getBoostValue(): ?float {
  187. return 0.5;
  188. }
  189. /**
  190. * @inheritdoc
  191. */
  192. public function getSorts(): array {
  193. return [];
  194. }
  195. /**
  196. * @inheritdoc
  197. */
  198. public function getQuerySchema(): Schema {
  199. return Schema::parse([
  200. 'discussionID:i?' => [
  201. 'x-search-scope' => true,
  202. ],
  203. 'categoryID:i?' => [
  204. 'x-search-scope' => true,
  205. ],
  206. 'categoryIDs:a?' => [
  207. 'items' => [
  208. 'type' => 'integer',
  209. ],
  210. 'x-search-scope' => true,
  211. ],
  212. 'followedCategories:b?' => [
  213. 'x-search-filter' => true,
  214. ],
  215. 'includeChildCategories:b?' => [
  216. 'x-search-filter' => true,
  217. ],
  218. 'includeArchivedCategories:b?' => [
  219. 'x-search-filter' => true,
  220. ],
  221. 'tags:a?' => [
  222. 'items' => [
  223. 'type' => 'string',
  224. ],
  225. 'x-search-filter' => true,
  226. ],
  227. 'tagOperator:s?' => [
  228. 'items' => [
  229. 'type' => 'string',
  230. 'enum' => [SearchQuery::FILTER_OP_OR, SearchQuery::FILTER_OP_AND],
  231. ],
  232. ],
  233. ]);
  234. }
  235. /**
  236. * @inheritdoc
  237. */
  238. public function getQuerySchemaExtension(): Schema {
  239. return Schema::parse([
  240. "sort:s?" => [
  241. "enum" => [
  242. "score",
  243. "-score",
  244. "hot",
  245. "-hot"
  246. ],
  247. ]
  248. ]);
  249. }
  250. /**
  251. * Get article boost types.
  252. *
  253. * @return Schema|null
  254. */
  255. public function getBoostSchema(): ?Schema {
  256. return Schema::parse([
  257. 'discussionRecency:b' => [
  258. 'default' => true,
  259. ],
  260. ]);
  261. }
  262. /**
  263. * @inheritdoc
  264. */
  265. public function validateQuery(SearchQuery $query): void {
  266. // Validate category IDs.
  267. $categoryID = $query->getQueryParameter('categoryID', null);
  268. if ($categoryID !== null && !$this->categoryModel::checkPermission($categoryID, 'Vanilla.Discussions.View')) {
  269. throw new PermissionException('Vanilla.Discussions.View');
  270. }
  271. $categoryIDs = $query->getQueryParameter('categoryIDs', null);
  272. if ($categoryID !== null && $categoryIDs !== null) {
  273. $validation = new Validation();
  274. $validation->addError('categoryID', 'Only one of categoryID, categoryIDs are allowed.');
  275. throw new ValidationException($validation);
  276. }
  277. }
  278. /**
  279. * Generates prepares sql query string
  280. *
  281. * @param MysqlSearchQuery $query
  282. * @return string
  283. */
  284. public function generateSql(MysqlSearchQuery $query): string {
  285. /** @var \Gdn_SQLDriver $db */
  286. $db = clone $query->getDB();
  287. $categoryIDs = $this->getCategoryIDs($query);
  288. if ($categoryIDs === []) {
  289. return '';
  290. }
  291. // Build base query
  292. $db->from('Discussion d')
  293. ->select('d.DiscussionID as recordID, d.Name as Title, d.Format, d.CategoryID, d.Score')
  294. ->select('d.DiscussionID', "concat('/discussion/', %s)", 'Url')
  295. ->select('d.DateInserted')
  296. ->select('d.Type as recordType')
  297. ->select('d.InsertUserID as UserID')
  298. ->select("'discussion'", '', 'type')
  299. ->orderBy('d.DateInserted', 'desc')
  300. ;
  301. if (false !== $query->get('expandBody', null)) {
  302. $db->select('d.Body as body');
  303. }
  304. $terms = $query->get('query', false);
  305. if ($terms) {
  306. $terms = $db->quote('%'.str_replace(['%', '_'], ['\%', '\_'], $terms).'%');
  307. $db->beginWhereGroup();
  308. foreach (['d.Name', 'd.Body'] as $field) {
  309. $db->orWhere("$field like", $terms, true, false);
  310. }
  311. $db->endWhereGroup();
  312. }
  313. if ($name = $query->get('name', false)) {
  314. $db->where('d.Name like', $db->quote('%'.str_replace(['%', '_'], ['\%', '\_'], $name).'%'), true, false);
  315. }
  316. $this->applyUserIDs($db, $query, 'd');
  317. $this->applyDateInsertedSql($db, $query, 'd');
  318. $discussionID = $query->get('discussionID', false);
  319. if ($discussionID !== false) {
  320. $db->where('d.DiscussionID', $discussionID);
  321. }
  322. if (!empty($categoryIDs)) {
  323. $db->whereIn('d.CategoryID', $categoryIDs);
  324. }
  325. $limit = $query->get('limit', 100);
  326. $offset = $query->get('offset', 0);
  327. $db->limit($limit + $offset);
  328. $sql = $db->getSelect(true);
  329. $db->reset();
  330. return $sql;
  331. }
  332. /**
  333. * Apply the dateInserted parameters.
  334. *
  335. * @param \Gdn_SQLDriver $sql
  336. * @param MysqlSearchQuery $query
  337. * @param string $tableAlias
  338. */
  339. protected function applyDateInsertedSql(\Gdn_SQLDriver $sql, MysqlSearchQuery $query, string $tableAlias) {
  340. $dateInserted = $query->getQueryParameter('dateInserted');
  341. if ($dateInserted) {
  342. $schema = new DateFilterSchema();
  343. $sql->where(DateFilterSchema::dateFilterField("$tableAlias.DateInserted", $schema->validate($dateInserted)));
  344. }
  345. }
  346. /**
  347. * Apply the insertUsers part of the SQL query.
  348. *
  349. * @param \Gdn_SQLDriver $sql
  350. * @param MysqlSearchQuery $query
  351. * @param string $tableAlias
  352. */
  353. protected function applyUserIDs(\Gdn_SQLDriver $sql, MysqlSearchQuery $query, string $tableAlias) {
  354. $insertUserIDs = $query->getQueryParameter('insertUserIDs', false);
  355. $insertUserNames = $query->getQueryParameter('insertUserNames', false);
  356. if (!$insertUserIDs && $insertUserNames) {
  357. $users = $this->userModel->getWhere([
  358. 'name' => $insertUserNames,
  359. ])->resultArray();
  360. $insertUserIDs = array_column($users, 'UserID');
  361. }
  362. if ($insertUserIDs) {
  363. $sql->where("$tableAlias.InsertUserID", $insertUserIDs);
  364. }
  365. }
  366. /**
  367. * Get category ids from DB if query has it as a filter
  368. *
  369. * @param SearchQuery $query
  370. * @return array|null
  371. */
  372. protected function getCategoryIDs(SearchQuery $query): ?array {
  373. $categoryIDs = $this->categoryModel->getSearchCategoryIDs(
  374. $query->getQueryParameter('categoryID'),
  375. $query->getQueryParameter('followedCategories'),
  376. $query->getQueryParameter('includeChildCategories'),
  377. $query->getQueryParameter('includeArchivedCategories'),
  378. $query->getQueryParameter('categoryIDs')
  379. );
  380. if ($query->supportsExtenders()) {
  381. /** @var SearchTypeQueryExtenderInterface $extender */
  382. foreach ($this->extenders as $extender) {
  383. $categoryIDs = $extender->extendCategories($categoryIDs);
  384. }
  385. }
  386. return $categoryIDs;
  387. }
  388. /**
  389. * Get user ids by their name if query has insertUserNames argument
  390. *
  391. * @param array $userNames
  392. * @return array|null
  393. */
  394. protected function getUserIDs(array $userNames): ?array {
  395. if (!empty($userNames)) {
  396. $users = $this->userModel->getWhere([
  397. 'name' => $userNames,
  398. ])->resultArray();
  399. $userIDs = array_column($users, 'UserID');
  400. return $userIDs;
  401. } else {
  402. return null;
  403. }
  404. }
  405. /**
  406. * @return string
  407. */
  408. public function getSingularLabel(): string {
  409. return \Gdn::translate('Discussion');
  410. }
  411. /**
  412. * @return string
  413. */
  414. public function getPluralLabel(): string {
  415. return \Gdn::translate('Discussions');
  416. }
  417. /**
  418. * @inheritdoc
  419. */
  420. public function getDTypes(): ?array {
  421. return [0];
  422. }
  423. /**
  424. * @inheritdoc
  425. */
  426. public function guidToRecordID(int $guid): ?int {
  427. return ($guid - 1) / 10;
  428. }
  429. }