* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Component\Finder\Site\Model; use Joomla\CMS\Factory; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\MVC\Model\ListModel; use Joomla\Component\Finder\Administrator\Indexer\Query; use Joomla\String\StringHelper; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * Search model class for the Finder package. * * @since 2.5 */ class SearchModel extends ListModel { /** * Context string for the model type * * @var string * @since 2.5 */ protected $context = 'com_finder.search'; /** * The query object is an instance of Query which contains and * models the entire search query including the text input; static and * dynamic taxonomy filters; date filters; etc. * * @var Query * @since 2.5 */ protected $searchquery; /** * An array of all excluded terms ids. * * @var array * @since 2.5 */ protected $excludedTerms = array(); /** * An array of all included terms ids. * * @var array * @since 2.5 */ protected $includedTerms = array(); /** * An array of all required terms ids. * * @var array * @since 2.5 */ protected $requiredTerms = array(); /** * Method to get the results of the query. * * @return array An array of Result objects. * * @since 2.5 * @throws \Exception on database error. */ public function getItems() { $items = parent::getItems(); // Check the data. if (empty($items)) { return null; } $results = array(); // Convert the rows to result objects. foreach ($items as $rk => $row) { // Build the result object. if (is_resource($row->object)) { $result = unserialize(stream_get_contents($row->object)); } else { $result = unserialize($row->object); } $result->cleanURL = $result->route; // Add the result back to the stack. $results[] = $result; } // Return the results. return $results; } /** * Method to get the query object. * * @return Query A query object. * * @since 2.5 */ public function getQuery() { // Return the query object. return $this->searchquery; } /** * Method to build a database query to load the list data. * * @return \Joomla\Database\DatabaseQuery A database query. * * @since 2.5 */ protected function getListQuery() { // Create a new query object. $db = $this->getDatabase(); $query = $db->getQuery(true); // Select the required fields from the table. $query->select( $this->getState( 'list.select', 'l.link_id, l.object' ) ); $query->from('#__finder_links AS l'); $user = Factory::getUser(); $groups = $this->getState('user.groups', $user->getAuthorisedViewLevels()); $query->whereIn($db->quoteName('l.access'), $groups) ->where('l.state = 1') ->where('l.published = 1'); // Get the current date, minus seconds. $nowDate = $db->quote(substr_replace(Factory::getDate()->toSql(), '00', -2)); // Add the publish up and publish down filters. $query->where('(l.publish_start_date IS NULL OR l.publish_start_date <= ' . $nowDate . ')') ->where('(l.publish_end_date IS NULL OR l.publish_end_date >= ' . $nowDate . ')'); $query->group('l.link_id'); $query->group('l.object'); /* * Add the taxonomy filters to the query. We have to join the taxonomy * map table for each group so that we can use AND clauses across * groups. Within each group there can be an array of values that will * use OR clauses. */ if (!empty($this->searchquery->filters)) { // Convert the associative array to a numerically indexed array. $groups = array_values($this->searchquery->filters); $taxonomies = call_user_func_array('array_merge', array_values($this->searchquery->filters)); $query->join('INNER', $db->quoteName('#__finder_taxonomy_map') . ' AS t ON t.link_id = l.link_id') ->where('t.node_id IN (' . implode(',', array_unique($taxonomies)) . ')'); // Iterate through each taxonomy group. for ($i = 0, $c = count($groups); $i < $c; $i++) { $query->having('SUM(CASE WHEN t.node_id IN (' . implode(',', $groups[$i]) . ') THEN 1 ELSE 0 END) > 0'); } } // Add the start date filter to the query. if (!empty($this->searchquery->date1)) { // Escape the date. $date1 = $db->quote($this->searchquery->date1); // Add the appropriate WHERE condition. if ($this->searchquery->when1 === 'before') { $query->where($db->quoteName('l.start_date') . ' <= ' . $date1); } elseif ($this->searchquery->when1 === 'after') { $query->where($db->quoteName('l.start_date') . ' >= ' . $date1); } else { $query->where($db->quoteName('l.start_date') . ' = ' . $date1); } } // Add the end date filter to the query. if (!empty($this->searchquery->date2)) { // Escape the date. $date2 = $db->quote($this->searchquery->date2); // Add the appropriate WHERE condition. if ($this->searchquery->when2 === 'before') { $query->where($db->quoteName('l.start_date') . ' <= ' . $date2); } elseif ($this->searchquery->when2 === 'after') { $query->where($db->quoteName('l.start_date') . ' >= ' . $date2); } else { $query->where($db->quoteName('l.start_date') . ' = ' . $date2); } } // Filter by language if ($this->getState('filter.language')) { $query->where('l.language IN (' . $db->quote(Factory::getLanguage()->getTag()) . ', ' . $db->quote('*') . ')'); } // Get the result ordering and direction. $ordering = $this->getState('list.ordering', 'm.weight'); $direction = $this->getState('list.direction', 'DESC'); /* * If we are ordering by relevance we have to add up the relevance * scores that are contained in the ordering field. */ if ($ordering === 'm.weight') { // Get the base query and add the ordering information. $query->select('SUM(' . $db->escape($ordering) . ') AS ordering'); } else { /** * If we are not ordering by relevance, we just have to add * the unique items to the set. */ // Get the base query and add the ordering information. $query->select($db->escape($ordering) . ' AS ordering'); } $query->order('ordering ' . $db->escape($direction)); /* * If there are no optional or required search terms in the query, we * can get the results in one relatively simple database query. */ if (empty($this->includedTerms) && $this->searchquery->empty && $this->searchquery->input == '') { // Return the results. return $query; } /* * If there are no optional or required search terms in the query and * empty searches are not allowed, we return an empty query. * If the search term is not empty and empty searches are allowed, * but no terms were found, we return an empty query as well. */ if ( empty($this->includedTerms) && (!$this->searchquery->empty || ($this->searchquery->empty && $this->searchquery->input != '')) ) { // Since we need to return a query, we simplify this one. $query->clear('join') ->clear('where') ->clear('bounded') ->clear('having') ->clear('group') ->where('false'); return $query; } $included = call_user_func_array('array_merge', array_values($this->includedTerms)); $query->join('INNER', $db->quoteName('#__finder_links_terms') . ' AS m ON m.link_id = l.link_id') ->where('m.term_id IN (' . implode(',', $included) . ')'); // Check if there are any excluded terms to deal with. if (count($this->excludedTerms)) { $query2 = $db->getQuery(true); $query2->select('e.link_id') ->from($db->quoteName('#__finder_links_terms', 'e')) ->where('e.term_id IN (' . implode(',', $this->excludedTerms) . ')'); $query->where('l.link_id NOT IN (' . $query2 . ')'); } /* * The query contains required search terms. */ if (count($this->requiredTerms)) { foreach ($this->requiredTerms as $terms) { if (count($terms)) { $query->having('SUM(CASE WHEN m.term_id IN (' . implode(',', $terms) . ') THEN 1 ELSE 0 END) > 0'); } else { $query->where('false'); break; } } } return $query; } /** * Method to get a store id based on model the configuration state. * * This is necessary because the model is used by the component and * different modules that might need different sets of data or different * ordering requirements. * * @param string $id An identifier string to generate the store id. [optional] * @param boolean $page True to store the data paged, false to store all data. [optional] * * @return string A store id. * * @since 2.5 */ protected function getStoreId($id = '', $page = true) { // Get the query object. $query = $this->getQuery(); // Add the search query state. $id .= ':' . $query->input; $id .= ':' . $query->language; $id .= ':' . $query->filter; $id .= ':' . serialize($query->filters); $id .= ':' . $query->date1; $id .= ':' . $query->date2; $id .= ':' . $query->when1; $id .= ':' . $query->when2; if ($page) { // Add the list state for page specific data. $id .= ':' . $this->getState('list.start'); $id .= ':' . $this->getState('list.limit'); $id .= ':' . $this->getState('list.ordering'); $id .= ':' . $this->getState('list.direction'); } return parent::getStoreId($id); } /** * Method to auto-populate the model state. Calling getState in this method will result in recursion. * * @param string $ordering An optional ordering field. [optional] * @param string $direction An optional direction. [optional] * * @return void * * @since 2.5 */ protected function populateState($ordering = null, $direction = null) { // Get the configuration options. $app = Factory::getApplication(); $input = $app->input; $params = $app->getParams(); $user = Factory::getUser(); $language = Factory::getLanguage(); $this->setState('filter.language', Multilanguage::isEnabled()); $request = $input->request; $options = array(); // Get the empty query setting. $options['empty'] = $params->get('allow_empty_query', 0); // Get the static taxonomy filters. $options['filter'] = $request->getInt('f', $params->get('f', '')); // Get the dynamic taxonomy filters. $options['filters'] = $request->get('t', $params->get('t', array()), 'array'); // Get the query string. $options['input'] = $request->getString('q', $params->get('q', '')); // Get the query language. $options['language'] = $request->getCmd('l', $params->get('l', $language->getTag())); // Set the word match mode $options['word_match'] = $params->get('word_match', 'exact'); // Get the start date and start date modifier filters. $options['date1'] = $request->getString('d1', $params->get('d1', '')); $options['when1'] = $request->getString('w1', $params->get('w1', '')); // Get the end date and end date modifier filters. $options['date2'] = $request->getString('d2', $params->get('d2', '')); $options['when2'] = $request->getString('w2', $params->get('w2', '')); // Load the query object. $this->searchquery = new Query($options, $this->getDatabase()); // Load the query token data. $this->excludedTerms = $this->searchquery->getExcludedTermIds(); $this->includedTerms = $this->searchquery->getIncludedTermIds(); $this->requiredTerms = $this->searchquery->getRequiredTermIds(); // Load the list state. $this->setState('list.start', $input->get('limitstart', 0, 'uint')); $this->setState('list.limit', $input->get('limit', $params->get('list_limit', $app->get('list_limit', 20)), 'uint')); /* * Load the sort ordering. * Currently this is 'hard' coded via menu item parameter but may not satisfy a users need. * More flexibility was way more user friendly. So we allow the user to pass a custom value * from the pool of fields that are indexed like the 'title' field. * Also, we allow this parameter to be passed in either case (lower/upper). */ $order = $input->getWord('o', $params->get('sort_order', 'relevance')); $order = StringHelper::strtolower($order); $this->setState('list.raworder', $order); switch ($order) { case 'date': $this->setState('list.ordering', 'l.start_date'); break; case 'price': $this->setState('list.ordering', 'l.list_price'); break; case ($order === 'relevance' && !empty($this->includedTerms)): $this->setState('list.ordering', 'm.weight'); break; case 'title': $this->setState('list.ordering', 'l.title'); break; default: $this->setState('list.ordering', 'l.link_id'); $this->setState('list.raworder'); break; } /* * Load the sort direction. * Currently this is 'hard' coded via menu item parameter but may not satisfy a users need. * More flexibility was way more user friendly. So we allow to be inverted. * Also, we allow this parameter to be passed in either case (lower/upper). */ $dirn = $input->getWord('od', $params->get('sort_direction', 'desc')); $dirn = StringHelper::strtolower($dirn); switch ($dirn) { case 'asc': $this->setState('list.direction', 'ASC'); break; default: $this->setState('list.direction', 'DESC'); break; } // Set the match limit. $this->setState('match.limit', 1000); // Load the parameters. $this->setState('params', $params); // Load the user state. $this->setState('user.id', (int) $user->get('id')); $this->setState('user.groups', $user->getAuthorisedViewLevels()); } }