<?php
// +-------------------------------------------------+
// © 2002-2004 PMB Services / www.sigb.net pmb@sigb.net et contributeurs (voir www.sigb.net)
// +-------------------------------------------------+
// $Id: sphinxql_client.class.php,v 1.1 2026/07/15 10:10:54 rtigero Exp $

if (stristr($_SERVER['REQUEST_URI'], ".class.php")) die("no access");

global $class_path;
// On charge l'API historique uniquement pour les constantes SPH_* (SPH_MATCH_*, SPH_RANK_*, SPH_GROUPBY_*...)
require_once $class_path.'/sphinx/api/sphinxapi.php';
require_once $class_path.'/search_engine/search_engine_client.interface.php';

/**
 * Adaptateur SphinxQL.
 *
 * Cette classe expose la meme interface que SphinxClient (l'API binaire historique,
 * depreciee par Sphinx 3) mais execute les recherches via le protocole MySQL (SphinxQL).
 * Elle permet de conserver les searchers existants sans les modifier en profondeur,
 * tout en beneficiant du support a jour de Sphinx 3 (notamment la prise en compte
 * effective de max_matches, ignore par l'API binaire en version 3).
 *
 * Seules les methodes reellement utilisees par les searchers sont implementees.
 */
class sphinxql_client implements search_engine_client
{
    // Compatibilite SphinxClient : hote/port positionnes par le searcher.
    // La connexion reelle se fait via la chaine de connexion injectee (set_connect_string)
    // ou, a defaut, via la globale $sphinx_mysql_connect (port SphinxQL, 9306 par defaut).
    public $_host = 'localhost';
    public $_port = 9306;

    // Chaine de connexion injectee par la factory (host:port,flag,user,pass).
    // Si null, on retombe sur la globale $sphinx_mysql_connect (compatibilite).
    protected $_connect_string = null;

    // Liens SphinxQL (mysqli) partages entre instances, indexes par chaine de connexion
    protected static $_links = array();

    // Lien courant
    protected $_link = null;

    protected $_error = '';
    protected $_warning = '';

    // Derniere requete SphinxQL construite (diagnostic / comparaison de moteurs)
    protected $_last_query = '';

    // Etat de la requete (reproduit le comportement persistant de SphinxClient)
    protected $_offset = 0;
    protected $_limit = 20;
    protected $_maxmatches = 1000;
    protected $_cutoff = 0;
    protected $_arrayresult = true;
    protected $_matchmode = null;
    protected $_ranker = null;
    protected $_rankexpr = '';
    protected $_sortby = '';
    protected $_select = '*';
    protected $_fieldweights = array();
    protected $_groupby = '';
    protected $_groupfunc = null;
    protected $_filters = array();

    /**
     * Constructeur
     */
    public function __construct()
    {
        if (defined('SPH_MATCH_EXTENDED')) {
            $this->_matchmode = SPH_MATCH_EXTENDED;
        }
        if (defined('SPH_RANK_PROXIMITY_BM25')) {
            $this->_ranker = SPH_RANK_PROXIMITY_BM25;
        }
        if (defined('SPH_GROUPBY_ATTR')) {
            $this->_groupfunc = SPH_GROUPBY_ATTR;
        }
    }

    // ------------------------------------------------------------------
    // Connexion
    // ------------------------------------------------------------------

    /**
     * Definit la chaine de connexion a utiliser (injectee par la factory).
     *
     * @param string $connect_string host:port,flag,user,pass
     * @return void
     */
    public function set_connect_string($connect_string)
    {
        $this->_connect_string = $connect_string;
    }

    /**
     * Retourne la chaine de connexion effective : celle injectee si presente,
     * sinon la globale $sphinx_mysql_connect (compatibilite ascendante).
     *
     * @return string
     */
    protected function _get_connect_string()
    {
        if ($this->_connect_string !== null) {
            return (string) $this->_connect_string;
        }
        global $sphinx_mysql_connect;
        return (string) $sphinx_mysql_connect;
    }

    /**
     * Ouvre (ou reutilise) la connexion SphinxQL.
     *
     * @return bool
     */
    public function Open()
    {
        $key = $this->_get_connect_string();
        if (isset(self::$_links[$key]) && self::$_links[$key]) {
            $this->_link = self::$_links[$key];
            return true;
        }
        $link = $this->_connect();
        if ($link) {
            self::$_links[$key] = $link;
            $this->_link = $link;
            return true;
        }
        $this->_error = 'SphinxQL: connexion impossible';
        return false;
    }

    /**
     * Etablit une connexion SphinxQL via la chaine de connexion effective
     * (meme logique que sphinx_base).
     *
     * @return mysqli|int
     */
    protected function _connect()
    {
        $connect = $this->_get_connect_string();
        if (!empty($connect)) {
            $params = explode(',', $connect);
            if (isset($params[1]) && $params[1]) {
                return pmb_mysql_connect($params[0], $params[2], $params[3]);
            }
            return pmb_mysql_connect($params[0]);
        }
        $server = $this->_host;
        if ($this->_port) {
            $server .= ':'.$this->_port;
        }
        return pmb_mysql_connect($server);
    }

    /**
     * Retourne le lien courant, en l'ouvrant si besoin.
     *
     * @return mysqli|int|null
     */
    protected function _get_link()
    {
        if (!$this->_link) {
            $this->Open();
        }
        return $this->_link;
    }

    /**
     * Compatibilite SphinxClient : la connexion est partagee, on ne la ferme pas.
     *
     * @return bool
     */
    public function Close()
    {
        return true;
    }

    /**
     * @return string
     */
    public function GetLastError()
    {
        return $this->_error;
    }

    /**
     * @return string
     */
    public function GetLastWarning()
    {
        return $this->_warning;
    }

    /**
     * Retourne la derniere requete SphinxQL construite (diagnostic).
     *
     * @return string
     */
    public function getLastQuery()
    {
        return $this->_last_query;
    }

    /**
     * Execute une requete SphinxQL arbitraire sur le lien du moteur et retourne
     * les lignes (tableau associatif). Reserve au diagnostic / a la comparaison.
     *
     * @param string $sql
     * @return array
     */
    public function debugExec($sql)
    {
        $link = $this->_get_link();
        if (!$link) {
            return array();
        }
        $rows = array();
        $res = pmb_mysql_query($sql, $link);
        if ($res !== false && $res !== null) {
            while ($row = pmb_mysql_fetch_assoc($res)) {
                $rows[] = $row;
            }
            pmb_mysql_free_result($res);
        }
        return $rows;
    }

    /**
     * @return bool
     */
    public function IsConnectError()
    {
        return false;
    }

    // ------------------------------------------------------------------
    // Parametrage (compatibilite SphinxClient)
    // ------------------------------------------------------------------

    /**
     * @param bool $arrayresult
     * @return void
     */
    public function SetArrayResult($arrayresult)
    {
        $this->_arrayresult = (bool) $arrayresult;
    }

    /**
     * @param int $mode SPH_MATCH_EXTENDED ou SPH_MATCH_FULLSCAN
     * @return void
     */
    public function SetMatchMode($mode)
    {
        $this->_matchmode = $mode;
    }

    /**
     * @param int $mode (ignore : SphinxQL utilise ORDER BY)
     * @param string $sortby Expression de tri (ex : '@weight DESC')
     * @return void
     */
    public function SetSortMode($mode, $sortby = '')
    {
        $this->_sortby = $sortby;
    }

    /**
     * @param int $ranker SPH_RANK_EXPR, SPH_RANK_NONE...
     * @param string $rankexpr Expression de ranking si SPH_RANK_EXPR
     * @return void
     */
    public function SetRankingMode($ranker, $rankexpr = '')
    {
        $this->_ranker = $ranker;
        $this->_rankexpr = $rankexpr;
    }

    /**
     * @param string $select Liste de colonnes (ex : 'id' ou 'id, num_record')
     * @return void
     */
    public function SetSelect($select)
    {
        $this->_select = $select;
    }

    /**
     * @param array $weights Tableau champ => poids
     * @return void
     */
    public function SetFieldWeights($weights)
    {
        $this->_fieldweights = is_array($weights) ? $weights : array();
    }

    /**
     * @param int $offset
     * @param int $limit
     * @param int $max max_matches (0 = inchange, comme SphinxClient)
     * @param int $cutoff
     * @return void
     */
    public function SetLimits($offset, $limit, $max = 0, $cutoff = 0)
    {
        $this->_offset = max(0, intval($offset));
        $this->_limit = max(1, intval($limit));
        if ($max > 0) {
            $this->_maxmatches = intval($max);
        }
        if ($cutoff > 0) {
            $this->_cutoff = intval($cutoff);
        }
    }

    /**
     * @return void
     */
    public function ResetFilters()
    {
        $this->_filters = array();
    }

    /**
     * @param string $attribute
     * @param array $values
     * @param bool $exclude
     * @return void
     */
    public function SetFilter($attribute, $values, $exclude = false)
    {
        if (!is_array($values)) {
            $values = array($values);
        }
        $this->_filters[] = array(
            'type' => 'values',
            'attr' => $attribute,
            'values' => $values,
            'exclude' => (bool) $exclude,
        );
    }

    /**
     * @param string $attribute
     * @param int $min
     * @param int $max
     * @param bool $exclude
     * @return void
     */
    public function SetFilterRange($attribute, $min, $max, $exclude = false)
    {
        $this->_filters[] = array(
            'type' => 'range',
            'attr' => $attribute,
            'min' => $min,
            'max' => $max,
            'exclude' => (bool) $exclude,
        );
    }

    /**
     * @param string $attribute
     * @param int $func SPH_GROUPBY_ATTR...
     * @param string $groupsort (ignore)
     * @return void
     */
    public function SetGroupBy($attribute, $func, $groupsort = '@group desc')
    {
        $this->_groupby = $attribute;
        $this->_groupfunc = $func;
    }

    // ------------------------------------------------------------------
    // Execution
    // ------------------------------------------------------------------

    /**
     * Execute une recherche et retourne un resultat au format SphinxClient.
     *
     * @param string $query Texte de la requete (syntaxe etendue Sphinx)
     * @param string $index Liste d'index (separes par des virgules)
     * @param string $comment (ignore)
     * @return array|false
     */
    public function Query($query, $index = '*', $comment = '')
    {
        $link = $this->_get_link();
        if (!$link) {
            $this->_error = 'SphinxQL: connexion impossible';
            return false;
        }

        $this->_error = '';
        $this->_warning = '';

        $sql = $this->_build_select($query, $index);
        $this->_last_query = $sql;
        $res = pmb_mysql_query($sql, $link);
        if ($res === false || $res === null) {
            $this->_error = pmb_mysql_error($link);
            return false;
        }

        $matches = array();
        $i = 0;
        while ($row = pmb_mysql_fetch_assoc($res)) {
            $match = array(
                'id' => isset($row['id']) ? $row['id'] : 0,
                'weight' => isset($row['weight']) ? intval($row['weight']) : 1,
                'attrs' => array(),
            );
            foreach ($row as $col => $val) {
                if ($col === 'id' || $col === 'weight') {
                    continue;
                }
                $match['attrs'][$col] = $val;
            }
            $matches[$i++] = $match;
        }
        pmb_mysql_free_result($res);

        $meta = $this->_fetch_meta($link);
        $total_found = isset($meta['total_found']) ? intval($meta['total_found']) : count($matches);
        $total = isset($meta['total']) ? intval($meta['total']) : count($matches);
        if (isset($meta['warning'])) {
            $this->_warning = $meta['warning'];
        }

        return array(
            'matches' => $matches,
            'total' => $total,
            'total_found' => $total_found,
            'error' => '',
            'warning' => $this->_warning,
            'words' => array(),
        );
    }

    /**
     * Recupere les meta-donnees (total_found, total...) de la derniere requete.
     *
     * @param mysqli|int $link
     * @return array
     */
    protected function _fetch_meta($link)
    {
        $meta = array();
        $res = pmb_mysql_query('SHOW META', $link);
        if ($res !== false && $res !== null) {
            while ($row = pmb_mysql_fetch_row($res)) {
                $meta[$row[0]] = $row[1];
            }
            pmb_mysql_free_result($res);
        }
        return $meta;
    }

    // ------------------------------------------------------------------
    // Construction de la requete SphinxQL
    // ------------------------------------------------------------------

    /**
     * @param string $query
     * @param string $index
     * @return string
     */
    protected function _build_select($query, $index)
    {
        $select_cols = $this->_build_select_columns();
        $from = $this->_build_from($index);

        $where = array();
        $match = $this->_build_match($query);
        if ($match !== '') {
            $where[] = $match;
        }
        $filters = $this->_build_filters();
        if ($filters !== '') {
            $where[] = $filters;
        }

        $sql = 'SELECT '.$select_cols.' FROM '.$from;
        if (count($where)) {
            $sql .= ' WHERE '.implode(' AND ', $where);
        }
        if ($this->_groupby !== '') {
            $sql .= ' GROUP BY '.$this->_escape_ident($this->_groupby);
            // Sans WITHIN GROUP ORDER BY, le representant de groupe (et donc le
            // weight() rapporte pour le groupe) est choisi par defaut moteur : ce
            // defaut differe entre Sphinx 3 et Manticore, ce qui produit des
            // classements distincts pour une meme requete groupee. On force le
            // meilleur document du groupe selon le poids decroissant afin que le
            // score de chaque groupe = poids de sa meilleure occurrence, de maniere
            // deterministe et identique sur les deux moteurs (SphinxQL standard).
            $sql .= ' WITHIN GROUP ORDER BY weight() DESC';
        }
        $order = $this->_build_order();
        if ($order !== '') {
            $sql .= ' ORDER BY '.$order;
        }
        $sql .= ' LIMIT '.intval($this->_offset).', '.intval($this->_get_effective_limit());
        $sql .= $this->_build_options();

        return $sql;
    }

    /**
     * Calcule le nombre de resultats a retourner.
     *
     * En Sphinx 3, l'option max_matches est depreciee : la fenetre de resultats
     * (et donc la taille du pool de facteurs de ranking) se controle via LIMIT.
     * On plafonne donc le nombre de lignes pour que offset + count ne depasse pas
     * max_matches, ce qui borne le pool tout en preservant total_found (SHOW META).
     *
     * @return int
     */
    protected function _get_effective_limit()
    {
        $limit = $this->_limit;
        if ($this->_maxmatches > 0) {
            $remaining = $this->_maxmatches - $this->_offset;
            if ($remaining < 0) {
                $remaining = 0;
            }
            if ($remaining < $limit) {
                $limit = $remaining;
            }
        }
        return $limit;
    }

    /**
     * Construit la liste des colonnes du SELECT.
     * id et weight() sont toujours presents ; les colonnes de SetSelect (hors id/weight)
     * sont ajoutees et seront exposees dans la cle 'attrs' des resultats.
     *
     * @return string
     */
    protected function _build_select_columns()
    {
        $cols = array('id', 'weight() AS weight');
        if ($this->_select !== '' && $this->_select !== '*' && strtolower($this->_select) !== 'id') {
            $items = explode(',', $this->_select);
            foreach ($items as $item) {
                $item = trim($item);
                $lower = strtolower($item);
                if ($item === '' || $lower === 'id' || $lower === 'weight') {
                    continue;
                }
                $cols[] = $item;
            }
        }
        return implode(', ', $cols);
    }

    /**
     * @param string $index
     * @return string
     */
    protected function _build_from($index)
    {
        $index = trim($index);
        if ($index === '' || $index === '*') {
            return '*';
        }
        $parts = array_map('trim', explode(',', $index));
        $parts = array_filter($parts, 'strlen');
        return implode(', ', $parts);
    }

    /**
     * @param string $query
     * @return string
     */
    protected function _build_match($query)
    {
        if (defined('SPH_MATCH_FULLSCAN') && $this->_matchmode == SPH_MATCH_FULLSCAN) {
            return '';
        }
        $query = trim($query);
        if ($query === '' || $query === '*') {
            return '';
        }
        return "MATCH('".$this->_escape_string($query)."')";
    }

    /**
     * @return string
     */
    protected function _build_filters()
    {
        $clauses = array();
        foreach ($this->_filters as $f) {
            $attr = $this->_escape_ident($f['attr']);
            if ($attr === '') {
                continue;
            }
            if ($f['type'] === 'range') {
                $clause = $attr.' BETWEEN '.intval($f['min']).' AND '.intval($f['max']);
                if (!empty($f['exclude'])) {
                    $clause = 'NOT ('.$clause.')';
                }
            } else {
                $vals = array();
                foreach ($f['values'] as $v) {
                    $vals[] = is_numeric($v) ? ($v + 0) : "'".$this->_escape_string($v)."'";
                }
                if (!count($vals)) {
                    continue;
                }
                $clause = $attr.(!empty($f['exclude']) ? ' NOT IN(' : ' IN(').implode(',', $vals).')';
            }
            $clauses[] = $clause;
        }
        return implode(' AND ', $clauses);
    }

    /**
     * Traduit l'expression de tri de l'API (ex : '@weight DESC') en clause SphinxQL.
     *
     * @return string
     */
    protected function _build_order()
    {
        $sortby = trim($this->_sortby);
        if ($sortby === '') {
            return '';
        }
        // L'API utilise @weight ; en SphinxQL on s'appuie sur l'alias weight.
        $sortby = str_replace('@weight', 'weight', $sortby);
        // Suppression d'un eventuel prefixe @ sur d'autres attributs systeme.
        $sortby = str_replace('@', '', $sortby);
        return $sortby;
    }

    /**
     * Construit la clause OPTION (max_matches, ranker, sort_mem, field_weights, cutoff).
     *
     * max_matches : en Manticore, l'offset d'une requete est borne par max_matches
     * (defaut serveur = 1000). Le simple plafonnement par LIMIT ne suffit donc pas a
     * paginer/recuperer au-dela de 1000 (sinon erreur "offset out of bounds"). On
     * dimensionne max_matches a la fenetre reellement demandee (offset + limite
     * effective), ce qui borne aussi le pool de facteurs au nombre de resultats retenus.
     * (Option signalee depreciee par Manticore mais toujours fonctionnelle et necessaire.)
     * Le budget memoire du tri peut etre ajuste via la globale $sphinx_sort_mem
     * (ex : "128M"), traduite en OPTION sort_mem.
     *
     * @return string
     */
    protected function _build_options()
    {
        global $sphinx_sort_mem;

        $options = array();

        // Fenetre = offset + nombre de lignes demandees : max_matches doit la couvrir.
        // On n'emet l'option que si la fenetre depasse le defaut serveur (1000), pour
        // limiter le warning de depreciation Manticore sur les petites recherches.
        // Valide pour Sphinx 3 comme pour Manticore (syntaxe SphinxQL standard).
        $window = intval($this->_offset) + intval($this->_get_effective_limit());
        if ($window > 1000) {
            $options[] = 'max_matches='.$window;
        }

        if (defined('SPH_RANK_EXPR') && $this->_ranker == SPH_RANK_EXPR && $this->_rankexpr !== '') {
            $options[] = "ranker=expr('".$this->_escape_string($this->_rankexpr)."')";
        } elseif (defined('SPH_RANK_NONE') && $this->_ranker == SPH_RANK_NONE) {
            $options[] = 'ranker=none';
        }
        if (!empty($sphinx_sort_mem)) {
            $options[] = 'sort_mem='.$this->_escape_ident($sphinx_sort_mem);
        }
        if ($this->_cutoff > 0) {
            $options[] = 'cutoff='.intval($this->_cutoff);
        }
        if (count($this->_fieldweights)) {
            $fw = array();
            foreach ($this->_fieldweights as $field => $weight) {
                $field = $this->_escape_ident($field);
                if ($field === '') {
                    continue;
                }
                $fw[] = $field.'='.intval($weight);
            }
            if (count($fw)) {
                $options[] = 'field_weights=('.implode(', ', $fw).')';
            }
        }
        if (!count($options)) {
            return '';
        }
        return ' OPTION '.implode(', ', $options);
    }

    /**
     * Echappe une chaine pour un litteral SQL SphinxQL.
     *
     * @param string $s
     * @return string
     */
    protected function _escape_string($s)
    {
        return str_replace(array('\\', "'"), array('\\\\', "\\'"), $s);
    }

    /**
     * Securise un nom d'attribut / de champ (identifiant).
     *
     * @param string $ident
     * @return string
     */
    protected function _escape_ident($ident)
    {
        return preg_replace('/[^A-Za-z0-9_]/', '', (string) $ident);
    }

    // ------------------------------------------------------------------
    // Fonctionnalites natives (CALL SNIPPETS / CALL QSUGGEST)
    // ------------------------------------------------------------------

    /**
     * Construit la liste d'options d'une instruction CALL (SNIPPETS, QSUGGEST...).
     * Syntaxe SphinxQL : ", valeur AS option". Les chaines sont quotees, les
     * entiers/flottants laisses bruts, les booleens convertis en 0/1.
     *
     * @param array $opts
     * @return string
     */
    protected function _build_call_options($opts)
    {
        if (!is_array($opts) || !count($opts)) {
            return '';
        }
        $parts = array();
        foreach ($opts as $name => $value) {
            $name = $this->_escape_ident($name);
            if ($name === '') {
                continue;
            }
            if (is_int($value) || is_float($value)) {
                $val_sql = $value + 0;
            } elseif (is_bool($value)) {
                $val_sql = $value ? 1 : 0;
            } else {
                $val_sql = "'".$this->_escape_string($value)."'";
            }
            $parts[] = $val_sql.' AS '.$name;
        }
        if (!count($parts)) {
            return '';
        }
        return ', '.implode(', ', $parts);
    }

    /**
     * Genere des extraits surlignes (snippets) via CALL SNIPPETS.
     * Reprend la signature de SphinxClient::BuildExcerpts pour rester un drop-in.
     *
     * @param string|array $docs Texte(s) source(s)
     * @param string $index Index de reference (parametres de tokenisation)
     * @param string $words Termes de la requete a surligner
     * @param array $opts Options (before_match, after_match, around, limit...)
     * @return array|false Tableau de snippets (un par document) ou false si erreur
     */
    public function BuildExcerpts($docs, $index, $words, $opts = array())
    {
        $link = $this->_get_link();
        if (!$link) {
            $this->_error = 'SphinxQL: connexion impossible';
            return false;
        }
        if (!is_array($docs)) {
            $docs = array($docs);
        }
        if (!count($docs)) {
            return array();
        }
        $this->_error = '';

        $data = array();
        foreach ($docs as $doc) {
            $data[] = "'".$this->_escape_string($doc)."'";
        }
        // Une liste de documents s'ecrit (d1, d2, ...) ; un document seul reste brut.
        $data_sql = count($data) > 1 ? '('.implode(', ', $data).')' : $data[0];
        $index_sql = "'".$this->_escape_string($index)."'";
        $words_sql = "'".$this->_escape_string($words)."'";
        $opts_sql = $this->_build_call_options($opts);

        $sql = 'CALL SNIPPETS('.$data_sql.', '.$index_sql.', '.$words_sql.$opts_sql.')';
        $this->_last_query = $sql;

        $res = pmb_mysql_query($sql, $link);
        if ($res === false || $res === null) {
            $this->_error = pmb_mysql_error($link);
            return false;
        }
        $snippets = array();
        while ($row = pmb_mysql_fetch_row($res)) {
            $snippets[] = $row[0];
        }
        pmb_mysql_free_result($res);
        return $snippets;
    }

    /**
     * Retourne des suggestions de termes ("vouliez-vous dire") via CALL QSUGGEST
     * (ou CALL SUGGEST selon $opts['statement']). Necessite min_infix_len sur
     * l'index. Specifique Manticore (a gater par search_engine::supports('suggest')).
     *
     * @param string $keyword Mot a corriger / completer
     * @param string $index Index interroge
     * @param array $opts Options (limit, max_edits, statement='QSUGGEST'|'SUGGEST'...)
     * @return array|false Liste de ['suggest','distance','docs'] ou false si erreur
     */
    public function buildSuggestions($keyword, $index, $opts = array())
    {
        $link = $this->_get_link();
        if (!$link) {
            $this->_error = 'SphinxQL: connexion impossible';
            return false;
        }
        $this->_error = '';

        if (!is_array($opts)) {
            $opts = array();
        }
        $statement = isset($opts['statement']) ? strtoupper($opts['statement']) : 'QSUGGEST';
        if ($statement !== 'SUGGEST' && $statement !== 'QSUGGEST') {
            $statement = 'QSUGGEST';
        }
        unset($opts['statement']);

        $kw_sql = "'".$this->_escape_string($keyword)."'";
        $index_sql = "'".$this->_escape_string($index)."'";
        $opts_sql = $this->_build_call_options($opts);

        $sql = 'CALL '.$statement.'('.$kw_sql.', '.$index_sql.$opts_sql.')';
        $this->_last_query = $sql;

        $res = pmb_mysql_query($sql, $link);
        if ($res === false || $res === null) {
            $this->_error = pmb_mysql_error($link);
            return false;
        }
        $suggestions = array();
        while ($row = pmb_mysql_fetch_assoc($res)) {
            // Colonnes Manticore : suggest, distance, docs (distance/docs absents
            // si result_stats=0).
            $suggestions[] = array(
                'suggest' => isset($row['suggest']) ? $row['suggest'] : '',
                'distance' => isset($row['distance']) ? intval($row['distance']) : null,
                'docs' => isset($row['docs']) ? intval($row['docs']) : null,
            );
        }
        pmb_mysql_free_result($res);
        return $suggestions;
    }
}
