yf_search_driver abstract classYF 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.
| File | plugins/search/classes/search/yf_search_driver.class.php |
|---|---|
| Use | _class('search_driver', 'classes/search/') |
A site sets them in its config with $PROJECT_CONF['search_driver']['NAME'] = ...;
| Property | Default | Description |
|---|---|---|
$DEF_LIMITint |
20 |
Limit when neither limit nor per_page is given |
$MAX_LIMITint |
1000 |
Upper bound for limit / per_page / facet_limit |
$MAX_WINDOWint |
10000 |
Upper bound for offset + limit: deep pages cost the backend (Manticore sizes max_matches from it) |
$MAX_WORDSint |
32 |
Words of q used at most, the rest is dropped |
$last_errorstring |
'' |
Error of the last call that failed |
public function add($index, $id, $doc)
Insert or replace one document.
public function add($index, $id, $doc)
{
return $this->_not_supported(__FUNCTION__);
}
public function bulk($index, $docs)
$docs mixed id => doc public function bulk($index, $docs)
{
return $this->_not_supported(__FUNCTION__);
}
public function capabilities()
Returns array<string,bool>
public function capabilities()
{
return ['highlight' => false, 'suggest' => false, 'facets' => false, 'write' => false, 'ddl' => false, 'json' => false];
}
public function connect()
Open the connection (drivers also call it lazily); returns the connection or false.
public function connect()
{
return $this->_not_supported(__FUNCTION__);
}
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__);
}
public function delete($index, $ids)
$ids mixed one id or a list public function delete($index, $ids)
{
return $this->_not_supported(__FUNCTION__);
}
public function drop_index($index)
public function drop_index($index)
{
return $this->_not_supported(__FUNCTION__);
}
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__);
}
public function index_exists($index)
public function index_exists($index)
{
return $this->_not_supported(__FUNCTION__);
}
public function is_ready()
public function is_ready()
{
return false;
}
public function raw($query)
Native query passthrough, nothing is escaped: never pass request data.
public function raw($query)
{
return $this->_not_supported(__FUNCTION__);
}
abstract public function search(array $params = [], &$error_message = '')
Result shape in the class docblock; false on failure.
$params array abstract public function search(array $params = [], &$error_message = '');
public function suggest($text, $params = [])
Completions for $text: a list of strings.
public function suggest($text, $params = [])
{
return $this->_not_supported(__FUNCTION__);
}
public function truncate($index)
public function truncate($index)
{
return $this->_not_supported(__FUNCTION__);
}
public function update($index, $id, $fields)
Change some fields of one document.
public function update($index, $id, $fields)
{
return $this->_not_supported(__FUNCTION__);
}
_ hooks and helpers; not routable as page actionspublic function __call($name, $args)
Catch missing method call.
public function __call($name, $args)
{
return main()->extend_call($this, $name, $args);
}
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;
}
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;
}
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;
}
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);
}
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));
}