[TikiWiki-commits] [Git][tikiwiki/tiki][29.x] [BP][FIX][ENH] Search: Fix failures when using MySQL native stopwords with AND operator

"MAGENE Sem Joel \(@Jomagene\) via TikiWiki-cvs" <[email protected]>
Newsgroups gmane.comp.cms.tiki.cvs
Message-ID <68e9725052c78_2ce09844141d@gitlab-sidekiq-low-urgency-cpu-bound-v2-79c784b9f9-xtrnx.mail>

MAGENE Sem Joel pushed to branch 29.x at Tiki Wiki CMS Groupware / Tiki


Commits:
3e2bdd5a by MAGENE Sem Joel at 2025-10-10T20:47:00+00:00
[BP][FIX][ENH] Search: Fix failures when using MySQL native stopwords with AND operator
---
* [FIX][ENH] Search: Fix failures when using MySQL native stopwords with AND operator
---
* [FIX][ENH] Search: Fix failures when using MySQL native stopwords with AND operator

See merge request tikiwiki/tiki!8603

(cherry picked from commit 2325502aaa4d3fb12c6c7236d04e74ad3991320f)

See merge request tikiwiki/tiki!8757

- - - - -


4 changed files:

- lib/core/Search/MySql/FieldQueryBuilder.php
- lib/core/Search/MySql/QueryBuilder.php
- lib/core/Search/MySql/Table.php
- lib/prefs/unified.php


Changes:

=====================================
lib/core/Search/MySql/FieldQueryBuilder.php
=====================================
@@ -15,17 +15,49 @@ class Search_MySql_FieldQueryBuilder
     private $invert = false;
     private $boolean_or = ' ';
     private $escapeCallback;
+    private $stopwords = [];
+    private $minTokenSize = 3;
+
+    /**
+    * Sets the list of stopwords to be ignored during query building.
+    *
+    * @param array $stopwords The list of stopword strings.
+    */
+    public function setStopwords(array $stopwords)
+    {
+        $this->stopwords = array_map('strtolower', $stopwords);
+    }
+
+    /**
+    * Sets the minimum word length for a token to be included in the query.
+    * This should match the @@innodb_ft_min_token_size setting of the MySQL server.
+    *
+    * @param int $size The minimum number of characters a word must have.
+    */
+    public function setMinTokenSize(int $size)
+    {
+        $this->minTokenSize = $size;
+    }
 
     public function build(Search_Expr_Interface $expr, Search_Type_Factory_Interface $factory)
     {
         $invert = false;
         $string = $expr->walk(
             function ($node, $childNodes) use ($factory, &$invert) {
+                $childNodes = array_filter($childNodes);
                 if ($node instanceof Token) {
                     $string = $node->getValue($factory)->getValue();
                     if (is_array($string)) {
                         $string = implode(' ', $string);
                     }
+                    // If it's too short, treat it as empty.
+                    if (mb_strlen($string) < $this->minTokenSize) {
+                        return '';
+                    }
+                    // If it's a stopword, treat it as an empty token.
+                    if (in_array(strtolower($string), $this->stopwords, true)) {
+                        return '';
+                    }
                     if ($this->escapeCallback) {
                         $string = call_user_func($this->escapeCallback, $string);
                     }


=====================================
lib/core/Search/MySql/QueryBuilder.php
=====================================
@@ -21,6 +21,8 @@ class Search_MySql_QueryBuilder
     private $fieldBuilder;
     private $tfTranslator;
     private $indexes = [];
+    private $stopwords = null;
+    private $minTokenSize = null;
 
     public function __construct($db, Search_MySql_Table|null $table = null)
     {
@@ -31,6 +33,23 @@ class Search_MySql_QueryBuilder
         $this->tfTranslator = new Search_MySql_TrackerFieldTranslator();
     }
 
+    /**
+    * Gets the list of stopwords from Tiki preferences.
+    * It caches the result locally to avoid repeated lookups.
+    *
+    * @return array The list of stopwords.
+    */
+    private function getStopwords()
+    {
+        if ($this->stopwords === null) {
+            global $prefs;
+            $this->stopwords = ! empty($prefs['unified_stopwords']) && is_array($prefs['unified_stopwords'])
+                ? $prefs['unified_stopwords']
+                : [];
+        }
+        return $this->stopwords;
+    }
+
     public function build(Search_Expr_Interface $expr)
     {
         $this->indexes = [];
@@ -44,6 +63,20 @@ class Search_MySql_QueryBuilder
         return array_values($this->indexes);
     }
 
+    /**
+    * Gets the minimum word length for InnoDB Full-Text Search.
+    * This value is fetched directly from the database and cached locally.
+    *
+    * @return int The value of @@innodb_ft_min_token_size.
+    */
+    private function getMinTokenSize()
+    {
+        if ($this->minTokenSize === null) {
+            $this->minTokenSize = (int) $this->db->getOne("SELECT @@innodb_ft_min_token_size");
+        }
+        return $this->minTokenSize;
+    }
+
     public function __invoke($node, $childNodes)
     {
         $exception = null;
@@ -71,11 +104,20 @@ class Search_MySql_QueryBuilder
             if (! $node instanceof NotX && count($fields) == 1 && $this->isFullText($node)) {
                 // $query contains the token string to compare against $fields[0] in the unified search table
                 // $fields[0] can be i.e  'allowed_users', 'allowed_groups'
+                $this->fieldBuilder->setStopwords($this->getStopwords());
+                $this->fieldBuilder->setMinTokenSize($this->getMinTokenSize());
                 $query = $this->fieldBuilder->build($node, $this->factory);
+
+                // If the query is empty, it only contained stopwords.
+                if (empty($query) && ! $node instanceof MoreLikeThis) {
+                    return '';
+                }
+
                 if ($node instanceof MoreLikeThis) {
                     $type = $node->getObjectType();
                     $object = $node->getObjectId();
-                    $str = $node->getContent() ?: $this->getDocumentContent($type, $object);
+                    $field = $node->getField();
+                    $str = $node->getContent() ?: $this->getDocumentContent($type, $object, $field);
                 } else {
                     $str = $this->db->qstr($query);
                 }
@@ -180,12 +222,12 @@ class Search_MySql_QueryBuilder
         return $node->getValue($this->factory)->getValue();
     }
 
-    private function getDocumentContent($type, $object)
+    private function getDocumentContent($type, $object, $field)
     {
-        $results = $this->table->fetchAllIndex(['contents'], ['object_type' => $type, 'object_id' => $object]);
+        $results = $this->table->fetchAllIndex([$field], ['object_type' => $type, 'object_id' => $object]);
 
-        if (! empty($results[0]['contents'])) {
-            return $this->db->qstr($results[0]['contents']);
+        if (! empty($results[0][$field])) {
+            return $this->db->qstr($results[0][$field]);
         }
 
         return '';


=====================================
lib/core/Search/MySql/Table.php
=====================================
@@ -62,11 +62,16 @@ class Search_MySql_Table extends TikiDb_Table
 
     public function drop()
     {
+        $stopwordTableName = $this->tableName . '_stopwords';
+        $escapedStopwordTable = $this->escapeIdentifier($stopwordTableName);
+        $this->db->query("DROP TABLE IF EXISTS $escapedStopwordTable", options: [TikiDB::QUERY_OPTION_LOG_GROUP => self::UNIFIED_MYSQL_WRITE_LOG_GROUP]);
+
         $tables = $this->indexTables();
         foreach ($tables as $table) {
             $table = $this->escapeIdentifier($table);
             $this->db->query("DROP TABLE IF EXISTS $table", options: [TikiDB::QUERY_OPTION_LOG_GROUP => self::UNIFIED_MYSQL_WRITE_LOG_GROUP]);
         }
+
         $this->definition = false;
         $this->exists = false;
 
@@ -298,9 +303,13 @@ class Search_MySql_Table extends TikiDb_Table
             }
             $tables = [$tableName];
             $result = $this->db->fetchAll("SHOW TABLES LIKE '" . $tableName . "_%'", options: [TikiDB::QUERY_OPTION_LOG_GROUP => self::UNIFIED_MYSQL_READ_LOG_GROUP]);
-            foreach ($result as $row) {
-                $tables[] = array_shift($row);
-            }
+
+            $partitions = array_filter(
+                array_map(fn($row) => array_shift($row), $result),
+                fn($table) => preg_match('/_[0-9]+$/', $table)
+            );
+
+            $tables = array_merge($tables, $partitions);
         }
         return $tables;
     }
@@ -355,6 +364,7 @@ class Search_MySql_Table extends TikiDb_Table
         );
         $this->exists = true;
 
+        $this->setupStopwordTable();
         $this->emptyBuffer();
     }
 
@@ -416,13 +426,42 @@ class Search_MySql_Table extends TikiDb_Table
         $this->schemaBuffer->push("ADD INDEX $escapedIndex ($escapedField)");
     }
 
+    /**
+    * Creates and populates a custom stopword table for the current index.
+    * This allows Tiki to control InnoDB's stopword list directly during index creation.
+    * This method is called right before a FULLTEXT index is added.
+    *
+    * @return string The name of the created stopword table.
+    */
+    private function setupStopwordTable(): string
+    {
+        global $prefs;
+
+        $stopwordTableName = $this->tableName . '_stopwords';
+
+        $this->db->query("DROP TABLE IF EXISTS `{$stopwordTableName}`");
+        $this->db->query("CREATE TABLE `{$stopwordTableName}` (value VARCHAR(30)) ENGINE=INNODB");
+        $stopwords = $prefs['unified_stopwords'] ?? [];
+        if (! empty($stopwords)) {
+            $stopwordTable = $this->db->table($stopwordTableName, false);
+            foreach ($stopwords as $word) {
+                $stopwordTable->insert(['value' => $word]);
+            }
+        }
+
+        return $stopwordTableName;
+    }
+
     private function addFullText($fieldName)
     {
+        $stopwordTableName = $this->setupStopwordTable();
+        $dbName = $this->db->getOne("SELECT DATABASE()");
+        $this->db->query("SET SESSION innodb_ft_user_stopword_table = ?", ["{$dbName}/{$stopwordTableName}"]);
+
         $table = $this->escapeIdentifier($this->definition[$fieldName]['table']);
         $this->schemaBuffer->setPrefix("ALTER TABLE $table ");
 
         $indexName = $fieldName . '_fulltext';
-        $table = $this->escapeIdentifier($this->tableName);
         $escapedIndex = $this->escapeIdentifier($this->tfTranslator->shortenize($indexName));
         $escapedField = $this->escapeIdentifier($this->tfTranslator->shortenize($fieldName));
 


=====================================
lib/prefs/unified.php
=====================================
@@ -390,7 +390,7 @@ function prefs_unified_list()
             'type' => 'text',
             'default' => ["a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it", "not", "of", "on", "or", "s", "such", "t", "that", "the", "their", "then", "there", "these", "they", "this", "to", "was", "will", "with"],
             'separator' => ',',
-            'hint' => tr('MySQL full-text search has its own list of stop words configured in the server.'),
+            'hint' => tr('This list is applied to the selected search engine. Note for MySQL: It completely replaces the native InnoDB stopword list. If left empty, will disable stopword filtering.'),
         ],
         'unified_trim_sorted_search' => [
             'name' => tra('Automatically trim Elasticsearch results on date-sorted query'),



View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/3e2bdd5a011a363a200e494fab2c8df7b991e831

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/3e2bdd5a011a363a200e494fab2c8df7b991e831
You're receiving this email because of your account on gitlab.com.

_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.