matches: abcd, abc, abcde // require rebuilding search if change this value protected $min_star_search_length = 2; //min star word length: result: abcd -> keywords: ab, abc, abcd NOT a // define list of fields to be the filters protected $config_filter_fields = [ //format: field_name => map table_name.field //'price' => "tb_product.price", //'quantity' => "tb_product.quantity", ]; protected $config_fulltext_fields = [ //format: field_name => map [table_name.field] //"product_keywords" => ["tb_product.title", "tb_product.model", "tb_product.sku"], //"category_keywords" => ["tb_category.title"], ]; public function __construct( $tb_main, array $config_fulltext_fields , array $config_filter_fields = [] ) { // ovewrite default if(defined('CONFIG_STAR_SEARCH')) $this->star_search = CONFIG_STAR_SEARCH; if(defined('CONFIG_STAR_SEARCH_MIN_LENGTH')) $this->min_star_search_length = CONFIG_STAR_SEARCH_MIN_LENGTH; if(defined('CONFIG_SEARCH_VIETNAMESE')) $this->search_vietnamese = CONFIG_SEARCH_VIETNAMESE; $this->db = get_db('', ENABLE_DB_DEBUG); $this->config_fulltext_fields = $config_fulltext_fields; $this->config_filter_fields = $config_filter_fields; $this->tb_main = $tb_main; $part_name = str_replace("tb_", "", preg_replace("/[^a-z0-9_]/i", "", $tb_main)); $this->tb_fulltext = "tb_search_".strtolower($part_name); } /** * @description get filter fields * @param array[string] */ public function getFilterFields(): array { return $this->config_filter_fields; } /** * @description get fulltext fields * @param array[string] */ public function getFulltextFields(): array { return $this->config_fulltext_fields; } public function getSampleData() : array { $query = $this->db->runQuery( "SELECT * FROM `".$this->tb_fulltext."` ORDER BY `item_id` DESC LIMIT 10" ); return $this->db->fetchAll($query); } public function getTableName() : string { return $this->tb_fulltext; } /** * @param array $field_filters ["price" => [">", 100], "brand" => ["=", 1]] * @return array */ protected function _buildFieldFilters(array $field_filters = []) { $permit_operations = [">","=","<",">=","<=", "BETWEEN", "IN"]; $where_clause = []; $bind_types = []; $bind_values = []; foreach ($field_filters as $field => $info) { list($operation, $value) = $info; $operation = strtoupper($operation); if(!in_array(strtolower($operation), $permit_operations)) continue; if($operation == 'BETWEEN') { // $value must be array(value_int_1, value_int_2) if(is_array($value) && sizeof($value) == 2) { list($value_1, $value_2) = $value; if(is_int($value_1) && is_int($value_2) && $value_1 < $value_2) { $where_clause[] = " AND `".$field."` BETWEEN (?, ?) "; $bind_types[] = "d"; $bind_types[] = "d"; $bind_values[] = $value_1; $bind_values[] = $value_2; } } continue; } if($operation == 'IN') { // $value must be array(value1, value2) if(is_array($value) && sizeof($value) > 0) { $parameterized = []; foreach ($value as $_v) { $parameterized[] = "?"; $bind_types[] = "s"; $bind_values[] = $_v; } $where_clause[] = " AND `".$field."` IN (".join(",", $parameterized).") "; } continue; } // default operation, comparison requires value to be a digit $where_clause[] = " AND `".$field."` ".$operation." ? "; $bind_types[] = "d"; $bind_values[] = $value; } return array(join(" ", $where_clause), $bind_types, $bind_values); } /** * @param $keyword * @param array $field_filters ["price" => [">", 100], "brand" => ["=", 1]] * @param array $fulltext_fields ['title_keywords', "category_keywords"] * @param int $limit_result * @return int[] */ public function find($keyword, array $field_filters = [], array $fulltext_fields = [], $limit_result = 2000) : array { if(!sizeof($fulltext_fields)) $fulltext_fields = array_keys($this->config_fulltext_fields); $cache_key = md5("find-".$keyword); return static::getCache($cache_key, function () use ($keyword, $field_filters, $fulltext_fields, $limit_result){ $keyword_clean = ($this->search_vietnamese) ? $this->make_text_search_vn($keyword) : $this->make_text_clean($keyword) ; //make_text_search_vn $keyword_clean_ele = array_filter(explode(" ", $keyword_clean)); $build_boolean_search = "+".join(" +", $keyword_clean_ele); if(!$limit_result || $limit_result > 5000) $limit_result = 5000; $limit_condition = ($limit_result > 0) ? " LIMIT ".$limit_result : ""; list($where_clause, $bind_types, $bind_values) = $this->_buildFieldFilters($field_filters); //validate search fields $validated_fulltext_fields = array_filter($fulltext_fields, function ($item) { return array_key_exists($item, $this->config_fulltext_fields); }); if(!sizeof($validated_fulltext_fields)) { return []; } $query = $this->db->runQuery( "SELECT `item_id` FROM `".$this->tb_fulltext."` WHERE MATCH(".join(',', $validated_fulltext_fields).") AGAINST('".$build_boolean_search."' IN BOOLEAN MODE) ".$where_clause." ORDER BY `item_id` DESC ".$limit_condition." ", $bind_types, $bind_values ); return array_map(function ($item){ return $item['item_id']; }, $this->db->fetchAll($query) ); }); } protected function getItemInfo($item_id) { $query = $this->db->runQuery( "SELECT * FROM `".$this->tb_fulltext."` WHERE `item_id` = ? LIMIT 1", ['d'], [$item_id] ); return $this->db->fetchAssoc($query); } protected function checkExist($item_id) : bool { $query = $this->db->runQuery( "SELECT `item_id` FROM `".$this->tb_fulltext."` WHERE `item_id` = ? LIMIT 1", ['d'], [$item_id] ); return (bool) $this->db->fetchAssoc($query); } // get all table_fields to watch which will affect the search protected function getWatchTableFields() { $result = []; foreach ($this->config_filter_fields as $field => $table_field ) { $result[] = $table_field; } foreach ($this->config_fulltext_fields as $field => $table_fields ) { $result = array_merge($result, $table_fields); } return $result; } /** * @description update or create item if not exist * @param $item_id * @param array $table_field_values $table_field_values = [ "tb_product.price" => 2000000, "tb_product.title" => "Máy tính ABC", "tb_category.price" => "Máy tính", "tb_product.field_not_be_update" => "Mmodel", ]; * @return AppResponse */ public function updateItem($item_id, array $table_field_values = []) : AppResponse { $fulltext_fields = []; foreach ($this->config_fulltext_fields as $fulltext_field => $fulltext_map_fields ) { foreach ($fulltext_map_fields as $_table_field) { if(array_key_exists($_table_field, $table_field_values)) { $fulltext_fields[$fulltext_field][$_table_field] = $table_field_values[$_table_field]; } } } //echo "fulltext_fields"; //debug_var($fulltext_fields); //echo "
"; $field_filters = []; foreach ($this->config_filter_fields as $filter_field => $filter_map_field ) { if(array_key_exists($filter_map_field, $table_field_values)) { $field_filters[$filter_field] = $table_field_values[$filter_map_field]; } } //echo "field_filters"; //debug_var($field_filters); // nothing to update if(!sizeof($fulltext_fields) && !sizeof($field_filters)) { return new AppResponse('error', "nothing to update" ); } // create entry if not exist if(!$this->checkExist($item_id)) { $this->db->insert($this->tb_fulltext, ['item_id' => $item_id, 'create_time' => CURRENT_TIME]); } $current_info = $this->getItemInfo($item_id); if(!$current_info) { return new AppResponse('error', "Cannot find record for ".$item_id ); } // update $updated_fulltext = []; foreach ($this->config_fulltext_fields as $_filter_field => $_n ) { if(!isset($fulltext_fields[$_filter_field])) continue; $current_filter_field_base = ($current_info[$_filter_field."_base"]) ? \json_decode($current_info[$_filter_field."_base"], true) : []; $new_filter_field_base = $current_filter_field_base; foreach ($fulltext_fields[$_filter_field] as $_table_field => $_new_value) { $new_filter_field_base[$_table_field] = $_new_value; } if(json_encode($new_filter_field_base) != json_encode($current_filter_field_base)) { $updated_fulltext[$_filter_field."_base"] = $new_filter_field_base; $updated_fulltext[$_filter_field] = ($this->search_vietnamese) ? $this->make_text_search_vn(join(" ", array_values($new_filter_field_base))) : $this->make_text_clean(join(" ", array_values($new_filter_field_base))); } } $updated_filters = []; foreach ($this->config_filter_fields as $_filter_field => $_table_field ) { if(!isset($field_filters[$_filter_field])) continue; if($field_filters[$_filter_field] != $current_info[$_filter_field] ) { $updated_filters[$_filter_field] = $field_filters[$_filter_field]; } } // nothing to update if(!sizeof($updated_filters) && !sizeof($updated_fulltext)) { return new AppResponse('error', "nothing to update" ); } $updated_info = array_merge($updated_filters, $updated_fulltext); $updated_info['last_update'] = CURRENT_TIME; //echo "updated_info =
"; //debug_var($updated_info); $this->db->update( $this->tb_fulltext, $updated_info, ['item_id' => $item_id] ); return new AppResponse('ok'); } public function deleteItem($item_id) : AppResponse { $this->db->runQuery( "DELETE FROM `".$this->tb_fulltext."` WHERE `item_id` = ? LIMIT 1", ['d'], [$item_id] ); return new AppResponse('ok'); } public function deleteItems(array $item_list_ids) : AppResponse { list($parameterized_ids, $bind_types) = create_bind_sql_parameter_from_value_list($item_list_ids, "int"); $this->db->runQuery( "DELETE FROM `".$this->tb_fulltext."` WHERE `item_id` IN (".$parameterized_ids.") ", $bind_types, $item_list_ids ); return new AppResponse('ok'); } public function recreateTableSearch() { if($this->db->checkTableExist($this->tb_fulltext)) { $this->db->runQuery( "DROP TABLE `".$this->tb_fulltext."` " ); } $this->createTableSearch(); } public function createTableSearch() { // check if table exist if($this->db->checkTableExist($this->tb_fulltext)) { return true; } if(!$this->db->checkTableExist($this->tb_main)) { return false; } $sql = "CREATE TABLE `".$this->tb_fulltext."` ( `item_id` INT(11) UNSIGNED NOT NULL DEFAULT '0', "; // add fields foreach ($this->config_filter_fields as $_filter_field => $_map_table_field ) { list($_table, $_f) = explode(".", $_map_table_field); $_table_info = $this->db->getTableInfo($_table); $column_default = $_table_info[$_f]['COLUMN_DEFAULT']; $default_value = (in_array($_table_info[$_f]['DATA_TYPE'], ['mediumint', 'int', 'tinyint', 'double', 'float'])) ? 0 : $column_default; $sql .= " `".$_filter_field."` ".$_table_info[$_f]['COLUMN_TYPE']." NOT NULL DEFAULT '".$default_value."', "; } foreach ($this->config_fulltext_fields as $_filter_field => $_t ) { $sql .= " `".$_filter_field."` TEXT NULL , "; // create field-data to compare new values before re-indexing $sql .= " `".$_filter_field."_base` TEXT NULL , "; } $sql .= " `create_time` int(11) NOT NULL DEFAULT '0', "; $sql .= " `last_update` int(11) NOT NULL DEFAULT '0', "; // index foreach ($this->config_filter_fields as $_filter_field => $_data_type ) { $sql .= " INDEX (`".$_filter_field."`), "; } // fulltext on separate columns foreach ($this->config_fulltext_fields as $_filter_field => $_t ) { $sql .= " FULLTEXT INDEX (`".$_filter_field."` ) , "; } // create full text for all columns combined, so we can do query match(col_1, col_2) against keyword $_filter_fields = array_keys($this->config_fulltext_fields); if(sizeof($_filter_fields)) $sql .= " FULLTEXT INDEX (".join(", ", $_filter_fields).") , "; $sql .= " PRIMARY KEY ( `item_id` ) "; $sql .= " ) ENGINE=InnoDB DEFAULT CHARSET=utf8"; //return $sql; $this->db->runQuery($sql); // add foreign key constraint $this->db->runQuery(" ALTER TABLE `".$this->tb_fulltext."` ADD FOREIGN KEY (`item_id`) REFERENCES `".$this->tb_main."`(`id`) ON DELETE CASCADE; "); return true; } //11-11-2012 chuyen text thanh tieng viet voi dau chuyen doi: vd. nhà = nhas //because mysql allow fulltext search with minimum 4 characters, we have a trick: add hura to everyone what has length < 4 protected function make_text_search_vn($text, $enable_star = false){ $text = Language::convertText($text); $text = preg_replace("@[^a-z0-9\s]@si", " ", strtolower($text)); //$text = str_replace(" "," ",$text); $text_ele = array_filter(explode(" ", $text)); $new_text = ""; foreach($text_ele as $ele) { if($ele == '/' ) continue; //skip this character if it stands alone if(strlen($ele) < 4 || $ele == 'plus') $new_text .= " hura".$ele; else $new_text .= " ".$ele; } return trim($new_text); } //11-11-2012 chuyen text thanh tieng viet khong dau: vd. nhà = nha /* @variable: - $text: text to be index //16-11-2012 - $enable_star: true: allow search abcd by word: ab or abc false: not enabled - $min_len: if $enable_star = true, abcd -> tao thanh: ab, abd, abcd (if $min_len = 2) */ protected function make_text_clean($_text, $enable_star = false){ //$text = Language::chuyenKhongdau(Language::convert_lower($text)); $text = Language::chuyenKhongdau($_text); $text = preg_replace("@[^a-z0-9\s]@si", " ", strtolower($text)); $text_ele = array_filter(explode(" ", $text)); $new_text = ""; foreach($text_ele as $ele) { if($this->star_search && $enable_star) { $word_list = static::create_star_search($ele, $this->min_star_search_length); foreach($word_list as $new_ele) { if(strlen($new_ele) < 4 || $ele == 'plus') $new_text .= " hura".$new_ele; else $new_text .= " ".$new_ele; } }else{ if(strlen($ele) < 4 || $ele == 'plus') $new_text .= " hura".$ele; else $new_text .= " ".$ele; } } return trim($new_text); } //16-11-2012 //create all possible combination for a word for star search: abcd -> tao thanh: ab, abd, abcd, bc, bcd, cd (if $min_len = 2) protected function create_star_search($word, $min_len = 2){ if($min_len < 2) $min_len = 2; //in case missing value for CONFIG_STAR_SEARCH_MIN_LENGTH $word_len = strlen($word); $result = array(); if($word_len <= $min_len) return array($word); for($i = $min_len; $i < $word_len; $i ++ ){ $result[] = substr($word, 0, $i); } $result[] = $word; //09-01-2012 //reduce $word 1 character to create new word and create combination from there $new_word = substr($word, 1); $new_result = self::create_star_search($new_word, $min_len = 2); foreach($new_result as $el){ $result[] = $el; } return array_unique($result); } }