Files
admin_hura_8/inc/Hura8/System/Model/aSearchBaseModel.php
2024-01-29 10:39:53 +07:00

531 lines
18 KiB
PHP

<?php
namespace Hura8\System\Model;
use Hura8\Interfaces\AppResponse;
use Hura8\System\Language;
use Hura8\Database\iConnectDB;
use Hura8\Interfaces\iSearch;
use Hura8\Traits\ClassCacheTrait;
abstract class aSearchBaseModel implements iSearch
{
use ClassCacheTrait;
/* @var iConnectDB $db */
protected $db;
protected $tb_main = "";
protected $tb_fulltext = "";
// if false then search 'nhà tôi' = 'nha toi', if true then 'nhà tôi' != 'nha toi'
// require rebuilding search if change this value
protected $search_vietnamese = false;
// require rebuilding search if change this value
protected $star_search = true; //values: 1|0, enable star search on fulltext: abc* -> 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 "<br />";
$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 = <br /> ";
//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);
}
}