singletonIf('listings.items.search.sphinx', static::class); return bff('listings.items.search.sphinx'); } public function init() { $this->moduleID('listings-items'); parent::init(); } /** * @return bool */ public static function enabled() { return ( config::sysAdmin('listings.search.engine', 'default') === 'sphinx' && parent::enabled() ); } /** * Список индексов * @param bool $prefixed с учетом префиксов * @return array */ public function indexes($prefixed = true) { $prefix = ($prefixed ? static::prefix() : ''); return [ 'main' => $prefix . static::INDEX_NAME . 'Main', 'delta' => $prefix . static::INDEX_NAME . 'Delta', ]; } /** * Настройки Sphinx * @param array $settings исходные настройки: [ * 'table' => 'bff_sphinx', * 'charset' => 'utf8', * 'sources' => [], @ref * 'indexes' => [], @ref * ] */ public function moduleSettings(array $settings) { # itemsSource: $settings['sources']['itemsSource'] = [ ':extends' => 'baseSource', 'sql_query_range' => 'SELECT MIN(id), MAX(id) FROM ' . Listings::TABLE_ITEMS, 'sql_range_step' => 1000, # задержка в миллисекундах между индексируемыми порциями данных 'sql_ranged_throttle' => 0, 'sql_attr_uint' => [ 'is_publicated', 'status', 'is_moderating', 'moderated', 'import', 'imgcnt', 'user_id', 'company_id', 'geo_city', // + geo_region1, ... 'addr_lat', 'addr_lon', 'district_id', 'metro_id', 'cat_id', 'cat_type', 'owner_type', 'regions_delivery', 'created', ], 'sql_attr_float' => [ 'price_search', ], ]; # categories: cat_idN for ($i = 1; $i <= Listings::catsDepthLimit(); $i++) { $settings['sources']['itemsSource']['sql_attr_uint'][] = 'cat_id' . $i; } # dynprops: fN $dynpropsSettings = [ 'datafield_int_last' => 15, 'datafield_text_first' => 16, 'datafield_text_last' => 20, ]; $dynpropsSettings = array_merge($dynpropsSettings, Listings::dp()->getSettings(array_keys($dynpropsSettings))); for ($i = 1; $i <= $dynpropsSettings['datafield_int_last']; $i++) { $settings['sources']['itemsSource']['sql_attr_uint'][] = 'f' . $i; } $query = [ 'id', 'user_id', 'company_id', 'is_publicated'/*new*/, 'status', 'is_moderating'/*new*/, 'moderated', 'import'/*new*/, 'phones'/*new*/, 'geo_city', 'district_id', 'metro_id', 'regions_delivery', 'addr_addr'/*new*/, 'addr_lat'/*new*/, 'addr_lon'/*new*/, 'imgcnt', 'price_search', 'cat_id', 'cat_type', 'owner_type', ':created' => 'UNIX_TIMESTAMP({prefix}.created) as created', ]; $translatable = Listings::model()->langItemTranslatable; foreach ($translatable as $f) { $query[':' . $f] = 'IFNULL({prefix}.' . $f . '_translates, {prefix}.' . $f . ') AS ' . $f; } for ($i = 1; $i <= Listings::catsDepthLimit(); $i++) { $query[] = 'cat_id' . $i; } for ($i = 1; $i <= $dynpropsSettings['datafield_text_last']; $i++) { $query[] = 'f' . $i; } $deep = Geo::maxDeep(); for ($i = 1; $i <= $deep; $i++) { $settings['sources']['itemsSource']['sql_attr_uint'][] = 'geo_region' . $i; $query[] = 'geo_region' . $i; } $schema = $this->db->schema(Listings::TABLE_ITEMS); foreach ($schema['result'] as $v) { if ($v['Field'] == 'svc_fixed_order') { $settings['sources']['itemsSource']['sql_attr_uint'][] = 'svc_fixed_order'; $query[':fix'] = 'UNIX_TIMESTAMP({prefix}.svc_fixed_order) as svc_fixed_order'; break; } } $queryPrefix = 'i'; foreach ($query as $k => $v) { if (is_string($k)) { $query[$k] = str_replace('{prefix}', $queryPrefix, $v); } else { $query[$k] = $queryPrefix . '.' . $v; } } # itemsSourceMain: $settings['sources']['itemsSourceMain'] = [ ':extends' => 'itemsSource', 'sql_query_pre' => [ 'SET CHARACTER_SET_RESULTS=' . $settings['charset'], 'SET NAMES ' . $settings['charset'], 'UPDATE ' . $settings['table'] . ' SET indexed = NOW() WHERE module = ' . $this->db->str2sql($this->moduleID()), ], 'sql_query' => ' \\ SELECT ' . join(', ', $query) . ' \\ FROM ' . Listings::TABLE_ITEMS . ' i \\ WHERE i.modified=$start AND i.id<=$end' ]; # itemsSourceDelta: $settings['sources']['itemsSourceDelta'] = [ ':extends' => 'itemsSource', 'sql_query_pre' => [ 'SET CHARACTER_SET_RESULTS=' . $settings['charset'], 'SET NAMES ' . $settings['charset'], 'UPDATE ' . $settings['table'] . ' SET indexed_delta = NOW() WHERE module = ' . $this->db->str2sql($this->moduleID()), ], 'sql_query' => ' \\ SELECT ' . join(', ', $query) . ' \\ FROM ' . Listings::TABLE_ITEMS . ' i \\ WHERE i.modified >= (SELECT indexed FROM ' . $settings['table'] . ' WHERE module = ' . $this->db->str2sql($this->moduleID()) . ') \\ AND i.id>=$start AND i.id<=$end', ]; # itemsIndexMain: $indexes = $this->indexes(false); $main = [ 'source' => 'itemsSourceMain', ]; $this->wordformsConfig($main); $settings['indexes'][$indexes['main']] = array_merge($settings['indexes']['indexTemplate'], $main); # itemsIndexDelta: $settings['indexes'][$indexes['delta']] = [ ':extends' => $indexes['main'], 'source' => 'itemsSourceDelta', ]; $settings['index_names'][static::INDEX_NAME] = $indexes; } /** * Поиск объявлений * @param string $query строка поиска * @param array $filter дополнительные фильтры * @param bool $count только подсчет кол-ва * @param int $limit лимит результатов на страницу * @param int $offset смещение вывода результатов * @param string $order сортировка * @return array|int */ public function searchItems(string $query, array $filter, bool $count = false, int $limit = 1, int $offset = 0, string $order = '') { $weights = []; $translatable = Listings::model()->langItemTranslatable; $w = 40; if (count($translatable) > 2) { $w = (int)(40 / (count($translatable) - 1)); } foreach ($translatable as $f) { if ($f == 'title') { $weights[] = $f . '=60'; } else { $weights[] = $f . '=' . $w; } } $options = ['field_weights' => '(' . join(',', $weights) . ')']; $query = $this->prepareSearchQuery($query, $options); if ($query === '') { # Empty query => do not search if ($count) { return 0; } } $bind = [':q' => $query]; $where = []; $select = []; # категория if (isset($filter[':cat-filter'])) { $catsData = Listings::model()->catsDataByFilter(['id' => $filter[':cat-filter']], ['id','numlevel']); foreach ($catsData as $v) { if (! empty($v['numlevel'])) { $filter['cat_id' . $v['numlevel']] = intval($v['id']); } } } # регион if (isset($filter[':region-filter'])) { $regionID = $filter[':region-filter']; if ($regionID > 0) { $regionData = Geo::regionData($regionID); if ($this->config('listings.search.delivery', true, TYPE_BOOL)) { if (Geo::coveringType(Geo::COVERING_COUNTRIES)) { if (! empty($regionData['numlevel'])) { if ($regionData['numlevel'] == 1) { $select[] = '(geo_region1 = :country OR (geo_region1 = :country AND regions_delivery = 1)) AS f_deliv'; $bind[':country'] = (int)$regionID; } else { $select[] = '(geo_region' . $regionData['numlevel'] . ' = :reg OR (geo_region1 = :country AND regions_delivery = 1)) AS f_deliv'; $bind[':country'] = (int)Geo::regionCountry($regionData); $bind[':reg'] = (int)$regionID; } } } else { if (! empty($regionData['numlevel'])) { $select[] = '(geo_region' . $regionData['numlevel'] . ' = :reg OR regions_delivery = 1) AS f_deliv'; $bind[':reg'] = (int)$regionID; } } $where[] = 'f_deliv = 1'; } else { if (! empty($regionData['numlevel'])) { $filter['geo_region' . $regionData['numlevel']] = (int)$regionID; } } } } # цена if (isset($filter[':price'])) { $select[] = $filter[':price'] . ' AS f_price'; $where[] = 'f_price = 1'; } # дин. св-ва if (! empty($filter[':dp']) && is_array($filter[':dp'])) { $i = 1; foreach ($filter[':dp'] as $v) { $v = str_replace('I.', '', $v); if (strpos($v, '&') || strpos($v, 'OR')) { $select[] = $v . ' AS f_dp' . $i; $where[] = 'f_dp' . $i . ' = 1'; $i++; } else { $where[] = $v; } } } # условия: column => condition foreach ($filter as $key => $value) { if (is_string($key) && !empty($key) && $key[0] !== ':') { $where[] = Model::condition($key, $value, $bind); } } $opt = []; foreach ($options as $k => $v) { $opt[] = $k . '=' . $v; } $orderBy = ''; if (! $count) { $order = mb_strtolower($order); if (mb_strpos($order, 'price_search') !== false) { $select[] = 'price_search'; if (mb_strpos($order, 'desc')) { $orderBy = ' ORDER BY price_search DESC'; } else { $orderBy = ' ORDER BY price_search ASC'; } } elseif (mb_strpos($order, 'svc_fixed_order') !== false) { $select[] = 'svc_fixed_order'; $select[] = 'WEIGHT() AS w'; $orderBy = ' ORDER BY svc_fixed_order DESC, w DESC'; } } else { if (! $limit) { $limit = 1; } } # http://sphinxsearch.com/docs/current/sphinxql-select.html $indexes = $this->indexes(); $data = $this->exec( 'SELECT id ' . (!empty($select) ? ', ' . join(', ', $select) : '') . ' FROM ' . $indexes['main'] . ', ' . $indexes['delta'] . ' WHERE MATCH (:q) ' . (!empty($where) ? ' AND ' . join(' AND ', $where) : '') . ' ' . $orderBy . ' LIMIT ' . ($offset ? $offset . ',' : '') . $limit . ' ' . (!empty($opt) ? 'OPTION ' . join(',', $opt) : ''), $bind, PDO::FETCH_COLUMN, 'fetchAll' ); if ($data === false) { return false; } if ($count) { # только подсчет кол-ва # http://khaletskiy.blogspot.com/2014/06/sphinx-pagination.html $meta = $this->select('SHOW META'); foreach ($meta as $v) { if ($v['Variable_name'] == 'total') { return (int)$v['Value']; } } } return $data; } /** * Обновляем атрибуты индексов * @param array $data * @param array $filter * @param array $indexes * @param array $options * @return int|mixed */ public function updateAttributes($data, $filter, array $indexes = [], array $options = []) { $list = $this->indexes(); if (empty($indexes)) { $indexes = $list; } else { foreach ($indexes as $k => $v) { if (isset($list[$v])) { $indexes[$k] = $list[$v]; } elseif (array_search($v, $list, true)) { continue; } else { unset($indexes[$k]); } } } return parent::updateAttributes($data, $filter, $indexes, $options); } /** * Обновление атрибутов в индексе при обновлении объявлений * @param array $update * @param array $filter * @return void */ public function itemsUpdateByFilter(array $update, array $filter) { if (! static::enabled()) { return; } $ids = $filter['id'] ?? []; if (empty($ids)) { return; } if (is_array($ids)) { foreach ($ids as & $v) { $v = (int)$v; } unset($v); } else { $ids = (int)$ids; } $attrUint = [ 'is_publicated', 'status', 'is_moderating', 'moderated', 'import', 'imgcnt', 'user_id', 'company_id', 'geo_city', 'addr_lat', 'addr_lon', 'district_id', 'metro_id', 'cat_id', 'cat_type', 'owner_type', 'regions_delivery', ]; $deep = Geo::maxDeep(); for ($i = 1; $i <= $deep; $i++) { $attrUint[] = 'geo_region' . $i; } for ($i = 1; $i <= Listings::catsDepthLimit(); $i++) { $attrUint[] = 'cat_id' . $i; } $attrTimestamp = []; if (Listings::itemServices('fix')) { $attrTimestamp[] = 'svc_fixed_order'; } $sphinx = []; foreach ($attrUint as $v) { if (! isset($update[$v])) { continue; } $sphinx[$v] = (int)$update[$v]; } foreach ($attrTimestamp as $v) { if (! isset($update[$v])) { continue; } $sphinx[$v] = (int)strtotime($update[$v]); } if (! empty($sphinx)) { $this->updateAttributes( $sphinx, ['id' => $ids] ); } } }