yf_search_driver abstract class

YF Search driver abstract: the common contract of every search backend (docs/rnd/2026-11-11-search-plugin.md ยง7).

search($params, &$error_message) returns
['total' => int, 'total_relation' => 'eq'|'gte', 'items' => [['id', 'score', 'doc' => [...], 'highlight' => [field => html]]],
'facets' => [field => [['value' => v, 'count' => n], ...]], 'took_ms' => float]
or false (reason in $error_message and $this->last_error). The reason can carry the raw database / backend error: it is for
logs and developers, never show it to visitors.
$params: index, q, fields (['title' => 2, 'body' => 1] or ['title', 'body']), filters (['cat_id' => 10, 'price' => ['<' => 80],
'tag' => ['in' => [...]]]), sort (['_score' => 'desc', 'price' => 'asc']), offset / limit (or page / per_page), highlight
(true or ['fields' => [...], 'pre' => '<b>', 'post' => '</b>']; pre / post are trusted developer HTML, emitted unescaped),
facets (['cat_id']), facet_limit, return (fields), id_field. offset + limit above MAX_WINDOW fails with "page too deep".
Every other method has a default here that returns false and sets last_error to "... is not supported", so a site's driver
subclass keeps working when the contract grows.

Fileplugins/search/classes/search/yf_search_driver.class.php
Use_class('search_driver', 'classes/search/')

Public properties

A site sets them in its config with $PROJECT_CONF['search_driver']['NAME'] = ...;

PropertyDefaultDescription
$DEF_LIMIT
int
20 Limit when neither limit nor per_page is given
$MAX_LIMIT
int
1000 Upper bound for limit / per_page / facet_limit
$MAX_WINDOW
int
10000 Upper bound for offset + limit: deep pages cost the backend (Manticore sizes max_matches from it)
$MAX_WORDS
int
32 Words of q used at most, the rest is dropped
$last_error
string
'' Error of the last call that failed

Public methods 27

add

public function add($index, $id, $doc)

Insert or replace one document.

    public function add($index, $id, $doc)
    {
        return $this->_not_supported(__FUNCTION__);
    }

bulk

public function bulk($index, $docs)
  • $docs mixed id => doc
    public function bulk($index, $docs)
    {
        return $this->_not_supported(__FUNCTION__);
    }

capabilities

public function capabilities()

Returns array<string,bool>

    public function capabilities()
    {
        return ['highlight' => false, 'suggest' => false, 'facets' => false, 'write' => false, 'ddl' => false, 'json' => false];
    }

connect

public function connect()

Open the connection (drivers also call it lazily); returns the connection or false.

    public function connect()
    {
        return $this->_not_supported(__FUNCTION__);
    }

create_index

public function create_index($index, $schema = [])
  • $schema mixed ['fields' => ['title' => 'text', 'cat_id' => 'int', 'price' => 'float', 'tag' => 'keyword', 'extra' => 'json'], 'suggest' => true]
    public function create_index($index, $schema = [])
    {
        return $this->_not_supported(__FUNCTION__);
    }

delete

public function delete($index, $ids)
  • $ids mixed one id or a list
    public function delete($index, $ids)
    {
        return $this->_not_supported(__FUNCTION__);
    }

drop_index

public function drop_index($index)
    public function drop_index($index)
    {
        return $this->_not_supported(__FUNCTION__);
    }

escape

public function escape($text)

User text made safe for the backend's query syntax and for its string literal.

    public function escape($text)
    {
        return $this->_not_supported(__FUNCTION__);
    }

index_exists

public function index_exists($index)
    public function index_exists($index)
    {
        return $this->_not_supported(__FUNCTION__);
    }

is_ready

public function is_ready()
    public function is_ready()
    {
        return false;
    }

raw

public function raw($query)

Native query passthrough, nothing is escaped: never pass request data.

    public function raw($query)
    {
        return $this->_not_supported(__FUNCTION__);
    }

suggest

public function suggest($text, $params = [])

Completions for $text: a list of strings.

    public function suggest($text, $params = [])
    {
        return $this->_not_supported(__FUNCTION__);
    }

truncate

public function truncate($index)
    public function truncate($index)
    {
        return $this->_not_supported(__FUNCTION__);
    }

update

public function update($index, $id, $fields)

Change some fields of one document.

    public function update($index, $id, $fields)
    {
        return $this->_not_supported(__FUNCTION__);
    }

Methods starting with _ hooks and helpers; not routable as page actions

__call

public function __call($name, $args)

Catch missing method call.

    public function __call($name, $args)
    {
        return main()->extend_call($this, $name, $args);
    }

_doc_id

public function _doc_id($id)

Document id as a decimal string, or false unless it is a positive 64-bit integer.

Returns string|false

    public function _doc_id($id)
    {
        if (is_int($id)) {
            return $id > 0 ? (string) $id : false;
        }
        return is_string($id) && preg_match('~^[1-9][0-9]*\z~', $id) && (string) (int) $id === $id ? $id : false;
    }

_fail

public function _fail($error, &$error_message = '')

Returns false

    public function _fail($error, &$error_message = '')
    {
        $error_message = $this->last_error = $this->_name() . ': ' . $error;
        return false;
    }
public function _highlight_html($text, $markers, $pre, $post)

HTML of a highlighted text: the text between the markers escaped (stray marker delimiter characters removed), the markers replaced by pre / post, which are trusted developer HTML and emitted unescaped.

  • $markers mixed from _highlight_markers()
    public function _highlight_html($text, $markers, $pre, $post)
    {
        $stray = [mb_substr($markers[0], 0, 1), mb_substr($markers[1], 0, 1)];
        $html = '';
        foreach (explode($markers[0], (string) $text) as $i => $chunk) {
            $html .= $i ? $pre : '';
            foreach (explode($markers[1], $chunk) as $j => $piece) {
                $html .= ($j ? $post : '') . _prepare_html(str_replace($stray, '', $piece), 0);
            }
        }
        return $html;
    }
public function _highlight_markers()

Match markers for one search: random, so document text cannot fake them, each wrapped in one delimiter character that _highlight_html() strips from the text. Control characters here: the SphinxQL snippet builders and _prepare_html() keep them and the caller's pre / post never go into the query.

Returns array{0: string, 1: string}

    public function _highlight_markers()
    {
        return ["\x02" . bin2hex(random_bytes(6)) . "\x02", "\x03" . bin2hex(random_bytes(6)) . "\x03"];
    }
public function _is_valid_name($name, $allow_db = false)
  • $allow_db mixed whether "db.name" is accepted
    public function _is_valid_name($name, $allow_db = false)
    {
        $re = $allow_db ? '~^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?\z~i' : '~^[a-z_][a-z0-9_]*\z~i';
        return is_string($name) && preg_match($re, $name) === 1;
    }

_name

public function _name()

Driver name without the class prefixes, e.g. "db_like".

    public function _name()
    {
        $class = get_class($this);
        $pos = strpos($class, 'search_driver_');
        return $pos === false ? $class : substr($class, $pos + strlen('search_driver_'));
    }
public function _normalize_filter($cond)

A filter as [[op, value], ...]; op is one of = != < <= > >= in not_in. A scalar means =, a list means in. INF / NAN are refused.

Returns array|false

    public function _normalize_filter($cond)
    {
        $is_value = fn ($v) => is_scalar($v) && ! (is_float($v) && ! is_finite($v));
        if (is_scalar($cond)) {
            return $is_value($cond) ? [['=', $cond]] : false;
        }
        if ( ! is_array($cond) || ! $cond) {
            return false;
        }
        if (array_is_list($cond)) {
            $cond = ['in' => $cond];
        }
        $out = [];
        foreach ($cond as $op => $value) {
            $op = strtolower(str_replace(' ', '_', (string) $op));
            $op = $op === '<>' ? '!=' : $op;
            if (in_array($op, ['in', 'not_in'], true)) {
                $value = array_values((array) $value);
                if ( ! $value || count(array_filter($value, $is_value)) !== count($value)) {
                    return false;
                }
            } elseif ( ! in_array($op, ['=', '!=', '<', '<=', '>', '>='], true) || ! $is_value($value)) {
                return false;
            }
            $out[] = [$op, $value];
        }
        return $out;
    }
public function _normalize_params($params, &$error = '')

Validated, defaulted copy of $params (see the class docblock), or false with the reason in $error. Every name (index, fields, filters, sort, facets, return) must be a plain identifier, so drivers can quote it safely.

Returns array|false

    public function _normalize_params($params, &$error = '')
    {
        $params = (array) $params;
        $q = $params['q'] ?? '';
        $p = [
            'index' => $params['index'] ?? '',
            'q' => is_scalar($q) ? trim((string) $q) : '',
            'id_field' => $params['id_field'] ?? 'id',
        ];
        if ( ! $this->_is_valid_name($p['index'], true)) {
            $error = 'bad or missing index';
            return false;
        }
        if ( ! $this->_is_valid_name($p['id_field'])) {
            $error = 'bad id_field';
            return false;
        }
        $p['fields'] = [];
        foreach ((array) ($params['fields'] ?? []) as $k => $v) {
            [$name, $weight] = is_int($k) ? [$v, 1] : [$k, $v];
            if ( ! $this->_is_valid_name($name) || ! is_numeric($weight) || ! is_finite((float) $weight)) {
                $error = 'bad field';
                return false;
            }
            $p['fields'][$name] = $weight + 0;
        }
        $p['filters'] = [];
        foreach ((array) ($params['filters'] ?? []) as $name => $cond) {
            $cond = $this->_is_valid_name($name) ? $this->_normalize_filter($cond) : false;
            if ($cond === false) {
                $error = 'bad filter on ' . (is_string($name) && $this->_is_valid_name($name) ? $name : 'a field');
                return false;
            }
            $p['filters'][$name] = $cond;
        }
        $p['sort'] = [];
        foreach ((array) ($params['sort'] ?? []) as $k => $v) {
            [$name, $dir] = is_int($k) ? [$v, 'asc'] : [$k, strtolower((string) $v)];
            if (($name !== '_score' && ! $this->_is_valid_name($name)) || ! in_array($dir, ['asc', 'desc'], true)) {
                $error = 'bad sort';
                return false;
            }
            $p['sort'][$name] = $dir;
        }
        $max = max(1, (int) $this->MAX_LIMIT);
        if (isset($params['per_page']) || isset($params['page'])) {
            $p['limit'] = min($max, max(1, (int) ($params['per_page'] ?? $this->DEF_LIMIT)));
            $p['offset'] = (max(1, (int) ($params['page'] ?? 1)) - 1) * $p['limit'];
        } else {
            $p['limit'] = min($max, max(1, (int) ($params['limit'] ?? $this->DEF_LIMIT)));
            $p['offset'] = max(0, (int) ($params['offset'] ?? 0));
        }
        if ($p['offset'] + $p['limit'] > max(1, (int) $this->MAX_WINDOW)) {
            $error = 'page too deep';
            return false;
        }
        $p['highlight'] = [];
        $hl = $params['highlight'] ?? false;
        if ($hl) {
            $hl = is_array($hl) ? $hl : [];
            $hl_fields = array_values((array) ($hl['fields'] ?? array_keys($p['fields'])));
            foreach ($hl_fields as $name) {
                if ( ! $this->_is_valid_name($name)) {
                    $error = 'bad highlight field';
                    return false;
                }
            }
            $p['highlight'] = ['fields' => $hl_fields, 'pre' => (string) ($hl['pre'] ?? '<b>'), 'post' => (string) ($hl['post'] ?? '</b>')];
        }
        foreach (['facets', 'return'] as $key) {
            $p[$key] = array_values((array) ($params[$key] ?? []));
            foreach ($p[$key] as $name) {
                if ( ! $this->_is_valid_name($name)) {
                    $error = 'bad ' . $key . ' field';
                    return false;
                }
            }
        }
        $p['facet_limit'] = min($max, max(1, (int) ($params['facet_limit'] ?? 20)));
        return $p;
    }
public function _not_supported($method)

Quiet on purpose, like yf_db_utils_pgsql::_not_supported(): the reason goes to last_error, no warning per call.

Returns false

    public function _not_supported($method)
    {
        $this->last_error = $this->_name() . ': ' . $method . '() is not supported';
        return false;
    }

_without_id

public function _without_id($fields)

The document id is the id argument: an "id" key in any case is dropped.

  • $fields array
    public function _without_id($fields)
    {
        return array_filter($fields, fn ($name) => strtolower((string) $name) !== 'id', ARRAY_FILTER_USE_KEY);
    }

_words

public function _words($q)

Words of q: split on whitespace, unique (case-insensitive), at most MAX_WORDS.

Returns string[]

    public function _words($q)
    {
        $q = (string) $q;
        if ( ! mb_check_encoding($q, 'UTF-8')) {
            // preg_split() fails on invalid UTF-8, and no words would match every document: drop the invalid bytes
            $q = (string) json_decode(json_encode($q, JSON_INVALID_UTF8_IGNORE));
        }
        $out = [];
        foreach (preg_split('~\s+~u', trim($q), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $word) {
            $out[mb_strtolower($word)] ??= $word;
        }
        return array_slice(array_values($out), 0, max(1, (int) $this->MAX_WORDS));
    }