yf_search_driver_elasticsearch class

Elasticsearch 8 / 9 over its REST API with ext-curl (OpenSearch: the opensearch driver extends this one). Every request body is built with json_encode() from validated params, never by string concatenation. Search text goes into one multi_match (cross_fields, operator and; every field when `fields` is empty): every word must match in some field, and the text is only analyzed, never parsed, so no wildcard, regexp, fuzzy, range or field syntax reaches the server (simple_query_string / query_string would turn `*`, `~2` or `title:x` into expensive or targeted queries). With `fields` given, list text fields only: a numeric field with non-numeric text answers HTTP 400 (lenient is used only when `fields` is empty). Filters are bool filter / must_not clauses (term, terms, range): use keyword or numeric fields, a text field is analyzed. Facets: terms aggregations on keyword / numeric fields. Highlight: the server marks the matches with random markers, the driver escapes the text and puts pre / post in. Suggest needs an index made by create_index() with 'suggest' => true. Document ids are positive integers (the _id), also stored in ID_FIELD for sort and filters. Index and field names must come from the caller's whitelist (they are checked as identifiers and URL-encoded in paths; index names also as lowercase without a leading _ or ., so _all, _cat or .kibana never reach a path); raw() takes trusted requests only. last_error can carry the server address and its error: for logs, never show it to visitors. Credentials never go into errors.

Fileplugins/search/classes/search/yf_search_driver_elasticsearch.class.php
Use_class('search_driver_elasticsearch', 'classes/search/')
Extendsyf_search_driver

Public properties

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

PropertyDefaultDescription
$BULK_SIZE
int
500 Documents per _bulk request
$CA_FILE
string
the system one */ Property [ public $CA_FILE = '' CA bundle for VERIFY_SSL (the http_ca.crt of a self-signed ES 8 node), '' = the system one
$CONNECT_TIMEOUT
int|float
2
$FIELD_TYPES
array<string,array>
['text' => ['type' => 'text'], 'keyword' => ['type' => 'keyword'], 'string' => ['type' => 'keyword'], 'int' => ['type' => 'long'], 'uint' => ['type' => 'long'], 'float' => ['type' => 'double'], ... (9 items)]
array (
  'text' => 
  array (
    'type' => 'text',
  ),
  'keyword' => 
  array (
    'type' => 'keyword',
  ),
  'string' => 
  array (
    'type' => 'keyword',
  ),
  'int' => 
  array (
    'type' => 'long',
  ),
  'uint' => 
  array (
    'type' => 'long',
  ),
  'float' => 
  array (
    'type' => 'double',
  ),
  'bool' => 
  array (
    'type' => 'boolean',
  ),
  'timestamp' => 
  array (
    'type' => 'date',
    'format' => 'epoch_second',
  ),
  'json' => 
  array (
    'type' => 'object',
  ),
)
create_index() schema type => mapping
$HTTP
object|null
curl */ Property [ public $HTTP = NULL Transport: request(method, url, headers, body) returns ['status', 'body', 'error']; null = curl
$ID_FIELD
string
only _id */ Property [ public $ID_FIELD = 'id' Field that gets the document id on writes, '' = only _id
$MAX_FACETS
int
10 Facet fields per search at most (one terms aggregation each)
$PARENT null
$PASS
string
''
$PROXY
string
none: the http_proxy / https_proxy env vars are ignored */ Property [ public $PROXY = '' Proxy for curl ([scheme://][user:pass@]host:port), '' = none: the http_proxy / https_proxy env vars are ignored
$REFRESH
bool|string
false refresh of writes: false (the index refresh interval), true or 'wait_for' (searchable on return)
$SUGGEST_FIELD
string
'yf_suggest' Field that create_index() fills from the text fields for suggest()
$SUGGEST_LIMIT
int
10 Default number of suggest() results
$TIMEOUT
int|float
10 Seconds for a whole request
$TRACK_TOTAL_HITS
bool|int
true track_total_hits: true counts every match, a number counts up to it (then total_relation is 'gte')
$URL
string
'http://127.0.0.1:9200' Base URL, http(s)://host:port, optionally with a path prefix
$USER
string
none */ Property [ public $USER = '' Basic auth user, '' = none
$VERIFY_SSL
bool
true Verify the server's TLS certificate and host name
$_curl
CurlHandle|null
null Kept between requests for keep-alive
$_server
array|null
null GET / answer, cached by connect()

Inherited properties

from yf_search_driver: $DEF_LIMIT, $MAX_LIMIT, $MAX_WINDOW, $MAX_WORDS, $last_error

Public methods 39

add

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

Index or replace one document (see bulk()).

    public function add($index, $id, $doc)
    {
        return $this->bulk($index, [$id => $doc]);
    }

bulk

public function bulk($index, $docs)

_bulk index actions, BULK_SIZE documents per request; a failing document stops the rest, earlier ones stay written. Null values are left out.

  • $docs mixed id => doc
    public function bulk($index, $docs)
    {
        if ( ! $this->_is_valid_index($index)) {
            return $this->_fail('bad index');
        }
        $lines = [];
        foreach ((array) $docs as $id => $doc) {
            $doc_id = $this->_doc_id($id);
            $doc = $this->_without_field($this->_without_id(array_filter((array) $doc, fn ($v) => $v !== null)), $this->ID_FIELD);
            if ($doc_id === false || ! $doc) {
                return $this->_fail('bad document ' . ($doc_id === false ? 'id' : $doc_id));
            }
            foreach (array_keys($doc) as $name) {
                if ( ! $this->_is_valid_name($name)) {
                    return $this->_fail('bad field name in document ' . $doc_id);
                }
            }
            if ($this->ID_FIELD !== '') {
                $doc[$this->ID_FIELD] = (int) $doc_id;
            }
            $json = $this->_json($doc);
            if ($json === false) {
                return $this->_fail('bad value in document ' . $doc_id);
            }
            $lines[] = $this->_json(['index' => ['_id' => $doc_id]]) . "\n" . $json . "\n";
        }
        foreach (array_chunk($lines, max(1, (int) $this->BULK_SIZE)) as $chunk) {
            if ( ! $this->_bulk($index, implode('', $chunk))) {
                return false;
            }
        }
        $this->last_error = '';
        return true;
    }

capabilities

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

connect

public function connect()

GET / once per instance; returns the server info or false.

    public function connect()
    {
        if (is_array($this->_server)) {
            return $this->_server;
        }
        $error = '';
        $info = $this->_request('GET', '/', null, $error);
        if ($info === false) {
            return $this->_fail($error);
        }
        $error = $this->_check_server($info);
        if ($error !== '') {
            return $this->_fail($error);
        }
        $this->last_error = '';
        return $this->_server = $info;
    }

create_index

public function create_index($index, $schema = [])

Index with a mapping from the schema; 'suggest' => true copies the text fields into SUGGEST_FIELD, a text field with fielddata (suggest() aggregates its words; heap use grows with the vocabulary).

  • $schema mixed ['fields' => [name => text|keyword|string|int|uint|float|bool|timestamp|json], 'suggest' => bool]
    public function create_index($index, $schema = [])
    {
        $schema = (array) $schema;
        if ( ! $this->_is_valid_index($index)) {
            return $this->_fail('bad index');
        }
        $suggest = ! empty($schema['suggest']);
        if (($suggest && ! $this->_is_valid_name($this->SUGGEST_FIELD)) || ($this->ID_FIELD !== '' && ! $this->_is_valid_name($this->ID_FIELD))) {
            return $this->_fail('bad SUGGEST_FIELD or ID_FIELD');
        }
        $properties = [];
        foreach ((array) ($schema['fields'] ?? []) as $name => $type) {
            $mapping = is_string($type) ? ($this->FIELD_TYPES[strtolower($type)] ?? null) : null;
            if ( ! $this->_is_valid_name($name) || ! is_array($mapping)) {
                return $this->_fail('bad schema field ' . ($this->_is_valid_name($name) ? $name : 'name'));
            }
            if ($suggest && ($mapping['type'] ?? '') === 'text') {
                $mapping['copy_to'] = [$this->SUGGEST_FIELD];
            }
            if (strtolower($name) !== 'id') {
                $properties[$name] = $mapping;
            }
        }
        if ( ! $properties) {
            return $this->_fail('create_index() needs schema fields');
        }
        if ($this->ID_FIELD !== '') {
            $properties[$this->ID_FIELD] = ['type' => 'long'];
        }
        if ($suggest) {
            $properties[$this->SUGGEST_FIELD] = ['type' => 'text', 'fielddata' => true];
        }
        return $this->_write('PUT', $this->_path($index), ['mappings' => ['properties' => $properties]]);
    }

delete

public function delete($index, $ids)

Missing ids are not an error.

  • $ids mixed one id or a list
    public function delete($index, $ids)
    {
        if ( ! $this->_is_valid_index($index)) {
            return $this->_fail('bad index');
        }
        $lines = [];
        foreach ((array) $ids as $id) {
            $doc_id = $this->_doc_id($id);
            if ($doc_id === false) {
                return $this->_fail('bad id');
            }
            $lines[$doc_id] = $this->_json(['delete' => ['_id' => $doc_id]]) . "\n";
        }
        if ($lines && ! $this->_bulk($index, implode('', $lines))) {
            return false;
        }
        $this->last_error = '';
        return true;
    }

drop_index

public function drop_index($index)

A missing index is not an error.

    public function drop_index($index)
    {
        if ( ! $this->_is_valid_index($index)) {
            return $this->_fail('bad index');
        }
        $error = '';
        $status = 0;
        if ($this->_request('DELETE', $this->_path($index), null, $error, $status) === false && $status !== 404) {
            return $this->_fail($error);
        }
        $this->last_error = '';
        return true;
    }

escape

public function escape($text)

search() needs no escaping (the text goes into a JSON string and is analyzed, not parsed). This is query_string / simple_query_string syntax escaping for raw() requests: reserved characters backslashed, < > dropped (they cannot be escaped), control characters as spaces, the uppercase-only operators AND / OR / NOT lowercased.

    public function escape($text)
    {
        $text = preg_replace('~[\x00-\x1f\x7f<>]~', ' ', (string) $text);
        $text = preg_replace('~[+\-=&|!(){}\[\]^"\~*?:\\\\/]~', '\\\\$0', $text);
        return preg_replace_callback('~(?<![\w\\\\])(AND|OR|NOT)(?!\w)~', fn ($m) => strtolower($m[1]), $text);
    }

index_exists

public function index_exists($index)
    public function index_exists($index)
    {
        if ( ! $this->_is_valid_index($index)) {
            return $this->_fail('bad index');
        }
        $error = '';
        $status = 0;
        if ($this->_request('HEAD', $this->_path($index), null, $error, $status) === false && $status !== 404) {
            return $this->_fail($error);
        }
        $this->last_error = '';
        return $status === 200;
    }

is_ready

public function is_ready()
    public function is_ready()
    {
        return (bool) $this->connect();
    }

raw

public function raw($query)

REST passthrough: [method, path, body]; body an array (JSON) or a string (NDJSON). Returns the decoded answer. Nothing is checked or escaped: trusted requests only, never request data.

    public function raw($query)
    {
        [$method, $path, $body] = array_values((array) $query) + [null, null, null];
        $method = strtoupper((string) $method);
        if ( ! in_array($method, ['GET', 'POST', 'PUT', 'DELETE', 'HEAD'], true) || ! is_string($path) || ! str_starts_with($path, '/')) {
            return $this->_fail('raw() takes [method, path, body]');
        }
        $error = '';
        $response = $this->_request($method, $path, $body, $error);
        if ($response === false) {
            return $this->_fail($error);
        }
        $this->last_error = '';
        return $response;
    }

suggest

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

Completions of the last word of $text, each after the words before it, most documents first: a terms aggregation on SUGGEST_FIELD. 'mode' => 'fuzzy': spelling corrections of the last word instead (term suggester).

  • $params mixed index, limit, mode
    public function suggest($text, $params = [])
    {
        $params = (array) $params;
        if ( ! $this->_is_valid_index($params['index'] ?? '') || ! $this->_is_valid_name($this->SUGGEST_FIELD)) {
            return $this->_fail('suggest needs index');
        }
        $words = $this->_words($text);
        $last = mb_strtolower((string) array_pop($words));
        // the include regex is limited by index.max_regex_length (1000)
        if ($last === '' || mb_strlen($last) > 100) {
            return [];
        }
        $before = $words ? implode(' ', $words) . ' ' : '';
        $limit = min(max(1, (int) $this->MAX_LIMIT), max(1, (int) ($params['limit'] ?? $this->SUGGEST_LIMIT)));
        $fuzzy = ($params['mode'] ?? '') === 'fuzzy';
        // a long word makes the edit-distance automaton expensive
        if ($fuzzy && mb_strlen($last) > 32) {
            return [];
        }
        $body = ['size' => 0, 'track_total_hits' => false];
        if ($fuzzy) {
            $term = ['field' => $this->SUGGEST_FIELD, 'size' => $limit, 'suggest_mode' => 'always', 'max_edits' => 2, 'prefix_length' => 1];
            $body['suggest']['s'] = ['text' => $last, 'term' => $term];
        } else {
            $include = preg_replace('~[^\p{L}\p{N}]~u', '\\\\$0', $last) . '.*';
            $body['aggs']['s'] = ['terms' => ['field' => $this->SUGGEST_FIELD, 'include' => $include, 'size' => $limit]];
        }
        $error = '';
        $response = $this->_request('POST', $this->_path($params['index'], '_search'), $body, $error);
        if ($response === false) {
            return $this->_fail($error);
        }
        $list = $fuzzy ? array_column($response['suggest']['s'][0]['options'] ?? [], 'text') : array_column($response['aggregations']['s']['buckets'] ?? [], 'key');
        $this->last_error = '';
        return array_map(fn ($word) => $before . $word, $list);
    }

truncate

public function truncate($index)

Deletes every document (_delete_by_query), the mapping stays. Asynchronous unless REFRESH is set: the server answers with a task id at once (a big index takes longer than TIMEOUT) and the documents disappear while it runs. With REFRESH it waits and refreshes, so the index is empty on return.

    public function truncate($index)
    {
        if ( ! $this->_is_valid_index($index)) {
            return $this->_fail('bad index');
        }
        $query = '?conflicts=proceed' . ($this->REFRESH ? '&refresh=true' : '&wait_for_completion=false');
        return $this->_write('POST', $this->_path($index, '_delete_by_query') . $query, ['query' => ['match_all' => (object) []]]);
    }

update

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

Partial update: the given fields change, the others stay (text fields too).

    public function update($index, $id, $fields)
    {
        $doc_id = $this->_doc_id($id);
        $fields = $this->_without_field($this->_without_id((array) $fields), $this->ID_FIELD);
        if ( ! $this->_is_valid_index($index) || $doc_id === false || ! $fields) {
            return $this->_fail('update needs index, id and fields');
        }
        foreach (array_keys($fields) as $name) {
            if ( ! $this->_is_valid_name($name)) {
                return $this->_fail('bad field name');
            }
        }
        return $this->_write('POST', $this->_path($index, '_update', $doc_id) . $this->_refresh(), ['doc' => $fields]);
    }

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);
    }

__clone

public function __clone()
    public function __clone()
    {
        $this->_curl = null;
    }

__destruct

public function __destruct()
    public function __destruct()
    {
        $this->_curl = null;
    }

__serialize

public function __serialize(): array

The curl handle stays out: it cannot be serialized and belongs to this process.

    public function __serialize(): array
    {
        $data = get_object_vars($this);
        unset($data['_curl']);
        return $data;
    }

__unserialize

public function __unserialize(array $data): void
  • $data array
    public function __unserialize(array $data): void
    {
        foreach ($data as $name => $value) {
            $this->$name = $value;
        }
        $this->_curl = null;
    }

_auth_user

public function _auth_user()

Basic auth user, '' = none.

    public function _auth_user()
    {
        return (string) $this->USER;
    }

_bulk

public function _bulk($index, $ndjson)

POST /index/_bulk; false with the first failed item in last_error.

  • $index mixed validated
    public function _bulk($index, $ndjson)
    {
        $error = '';
        $response = $this->_request('POST', $this->_path($index, '_bulk') . $this->_refresh(), $ndjson, $error);
        if ($response === false) {
            return $this->_fail($error);
        }
        if ( ! empty($response['errors'])) {
            foreach ($response['items'] ?? [] as $item) {
                $action = is_array($item) ? reset($item) : null;
                if (isset($action['error'])) {
                    return $this->_fail('document ' . ($action['_id'] ?? '?') . ': ' . $this->_error_text((int) ($action['status'] ?? 0), ['error' => $action['error']], ''));
                }
            }
            return $this->_fail('bulk failed');
        }
        return true;
    }

_check_server

public function _check_server($info)

Why the GET / answer is not a server this driver talks to, '' when it is.

    public function _check_server($info)
    {
        return isset($info['version']['number']) ? '' : $this->_safe_url() . ' is not an Elasticsearch server';
    }

_debug_record

public function _debug_record($method, $path, $payload, $status, $data, $error, $time)

The request for the debug console's search tab (DEBUG_MODE only; debug() keeps at most 500): the body cut to 4 KB with the password scrubbed, the hit count and the server's own time, not the answer itself.

  • $data mixed decoded answer
  • $time mixed start, microtime(true)
    public function _debug_record($method, $path, $payload, $status, $data, $error, $time)
    {
        // full: no trace to build for a record debug() would drop
        if (count((array) debug('search_requests')) >= 500) {
            return false;
        }
        $body = (string) $payload;
        if (strlen($body) > 4096) {
            $body = mb_strcut($body, 0, 4096, 'UTF-8') . '... (' . strlen($body) . ' bytes)';
        }
        $hits = is_array($data) ? ($data['hits']['total']['value'] ?? $data['hits']['total'] ?? $data['count'] ?? null) : null;
        return debug('search_requests[]', [
            'driver' => $this->_name(),
            'url' => $this->_safe_url(),
            'method' => (string) $method,
            'path' => $this->_scrub((string) $path),
            'body' => $this->_scrub($body),
            'status' => (int) $status,
            'hits' => is_scalar($hits) ? $hits : null,
            'took' => is_array($data) && isset($data['took']) && is_scalar($data['took']) ? $data['took'] : null,
            'error' => (string) $error,
            'time' => $time ? microtime(true) - $time : 0,
            'trace' => main()->trace_string(),
        ]);
    }

_error_text

public function _error_text($status, $data, $raw)

"HTTP 400 type: reason (cause type: cause reason)" from an error answer, at most 500 characters.

  • $data mixed decoded answer
    public function _error_text($status, $data, $raw)
    {
        $error = is_array($data) ? ($data['error'] ?? null) : null;
        if (is_array($error)) {
            $text = ($error['type'] ?? 'error') . ': ' . ($error['reason'] ?? '');
            $cause = $error['root_cause'][0] ?? $error['caused_by'] ?? null;
            if (is_array($cause) && ($cause['reason'] ?? '') !== ($error['reason'] ?? '')) {
                $text .= ' (' . ($cause['type'] ?? '') . ': ' . ($cause['reason'] ?? '') . ')';
            }
        } elseif (is_scalar($error)) {
            $text = (string) $error;
        } else {
            $text = trim(preg_replace('~\s+~', ' ', strip_tags((string) $raw)));
        }
        return $this->_scrub(mb_substr('HTTP ' . $status . ($text !== '' ? ' ' . $text : ''), 0, 500));
    }

_filters

public function _filters($filters)

bool clauses of normalized filters: = in < <= > >= as filter, != not_in as must_not.

Returns array{filter?: array, must_not?: array}

    public function _filters($filters)
    {
        $ranges = ['<' => 'lt', '<=' => 'lte', '>' => 'gt', '>=' => 'gte'];
        $out = [];
        foreach ((array) $filters as $name => $conds) {
            foreach ($conds as [$op, $value]) {
                if (isset($ranges[$op])) {
                    $out['filter'][] = ['range' => [$name => [$ranges[$op] => $value]]];
                } else {
                    $clause = in_array($op, ['in', 'not_in'], true) ? ['terms' => [$name => array_values($value)]] : ['term' => [$name => $value]];
                    $out[in_array($op, ['!=', 'not_in'], true) ? 'must_not' : 'filter'][] = $clause;
                }
            }
        }
        return $out;
    }
public function _highlight_markers()

Markers inserted by the server's highlighter: private-use characters, because OpenSearch trims control characters at the start of a highlighted field.

Returns array{0: string, 1: string}

    public function _highlight_markers()
    {
        return ["\u{E000}" . bin2hex(random_bytes(6)) . "\u{E000}", "\u{E001}" . bin2hex(random_bytes(6)) . "\u{E001}"];
    }

_http

public function _http($method, $url, $headers, $payload)

Returns array{status: int, body: string, error: string}

    public function _http($method, $url, $headers, $payload)
    {
        if (is_object($this->HTTP)) {
            return $this->HTTP->request($method, $url, $headers, $payload);
        }
        if ( ! function_exists('curl_init')) {
            return ['status' => 0, 'body' => '', 'error' => 'ext-curl is missing'];
        }
        $this->_curl ??= curl_init();
        curl_reset($this->_curl);
        $options = [
            CURLOPT_URL => $url,
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_NOBODY => $method === 'HEAD',
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_CONNECTTIMEOUT_MS => max(1, (int) round((float) $this->CONNECT_TIMEOUT * 1000)),
            CURLOPT_TIMEOUT_MS => max(1, (int) round((float) $this->TIMEOUT * 1000)),
            CURLOPT_SSL_VERIFYPEER => (bool) $this->VERIFY_SSL,
            CURLOPT_SSL_VERIFYHOST => $this->VERIFY_SSL ? 2 : 0,
            CURLOPT_ENCODING => '',
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROXY => (string) $this->PROXY,
        ];
        // the _STR options (PHP 8.3, curl 7.85) replace the deprecated bitmask ones
        if (defined('CURLOPT_PROTOCOLS_STR')) {
            $options[constant('CURLOPT_PROTOCOLS_STR')] = 'http,https';
            $options[constant('CURLOPT_REDIR_PROTOCOLS_STR')] = 'http,https';
        } else {
            $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
            $options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
        }
        if ((string) $this->CA_FILE !== '') {
            $options[CURLOPT_CAINFO] = (string) $this->CA_FILE;
        }
        if ($payload !== null) {
            $options[CURLOPT_POSTFIELDS] = $payload;
        }
        curl_setopt_array($this->_curl, $options);
        $body = curl_exec($this->_curl);
        if ($body === false) {
            return ['status' => 0, 'body' => '', 'error' => curl_error($this->_curl) ?: 'curl error ' . curl_errno($this->_curl)];
        }
        return ['status' => (int) curl_getinfo($this->_curl, CURLINFO_RESPONSE_CODE), 'body' => (string) $body, 'error' => ''];
    }

_init

public function _init()

Kept empty for site subclasses that call parent::_init(); it no longer loads send_mail.

    public function _init()
    {
    }
public function _is_valid_index($index)

Index name the server takes and that cannot address many indexes or an API: lowercase, no leading _ or . (_all, _cat, .kibana), on top of the identifier check.

    public function _is_valid_index($index)
    {
        return $this->_is_valid_name($index) && preg_match('~^[a-z0-9][a-z0-9_]*\z~', $index) === 1;
    }

_json

public function _json($data)

Returns string|false

    public function _json($data)
    {
        return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_INVALID_UTF8_SUBSTITUTE);
    }

_path

public function _path(...$parts)

URL path of validated names: /a/b, each part URL-encoded.

  • $parts string
    public function _path(...$parts)
    {
        return '/' . implode('/', array_map(fn ($part) => rawurlencode((string) $part), $parts));
    }

_refresh

public function _refresh()

Query string for writes.

    public function _refresh()
    {
        if ($this->REFRESH === 'wait_for') {
            return '?refresh=wait_for';
        }
        return $this->REFRESH ? '?refresh=true' : '';
    }

_request

public function _request($method, $path, $body = null, &$error = '', &$status = 0)

One request: an array body goes as JSON, a string body as NDJSON. Returns the decoded answer ([] for an empty one), or false with the reason in $error; $status is the HTTP status (0 when the server was not reached).

  • $path mixed with the query string

Returns array|false

    public function _request($method, $path, $body = null, &$error = '', &$status = 0)
    {
        $status = 0;
        $payload = $body === null || is_string($body) ? $body : $this->_json($body);
        if ($payload === false) {
            $error = 'cannot encode the request: ' . json_last_error_msg();
            return false;
        }
        $headers = ['Accept: application/json', 'Content-Type: application/' . (is_string($body) ? 'x-ndjson' : 'json'), 'Expect:'];
        $user = (string) $this->_auth_user();
        if ($user !== '') {
            $headers[] = 'Authorization: Basic ' . base64_encode($user . ':' . $this->PASS);
        }
        $debug = defined('DEBUG_MODE') && DEBUG_MODE;
        $time = $debug ? microtime(true) : 0;
        $response = $this->_http($method, rtrim((string) $this->URL, '/') . $path, $headers, $payload);
        if (($response['error'] ?? '') !== '') {
            $error = 'cannot reach ' . $this->_safe_url() . ': ' . $this->_scrub((string) $response['error']);
            $debug && $this->_debug_record($method, $path, $payload, 0, null, $error, $time);
            return false;
        }
        $status = (int) ($response['status'] ?? 0);
        $raw = (string) ($response['body'] ?? '');
        $data = $raw === '' ? [] : json_decode($raw, true);
        $where = $method . ' ' . explode('?', (string) $path, 2)[0] . ': ';
        $failed = '';
        if ($status < 200 || $status >= 300) {
            $failed = $where . $this->_error_text($status, $data, $raw);
        } elseif ( ! is_array($data)) {
            $failed = $where . 'the answer is not a JSON object';
        }
        $debug && $this->_debug_record($method, $path, $payload, $status, $data, $failed, $time);
        if ($failed !== '') {
            $error = $failed;
            return false;
        }
        return $data;
    }

_safe_url

public function _safe_url()

scheme://host:port of URL, without credentials or path.

    public function _safe_url()
    {
        $url = parse_url((string) $this->URL) ?: [];
        return ($url['scheme'] ?? 'http') . '://' . ($url['host'] ?? '?') . (isset($url['port']) ? ':' . $url['port'] : '');
    }

_scrub

public function _scrub($text)

Credentials out of a message: user:pass@ of URLs and the password itself.

    public function _scrub($text)
    {
        $text = preg_replace('~(//)[^/@\s]*@~', '$1', (string) $text);
        $pass = (string) $this->PASS;
        return $pass === '' ? $text : str_replace($pass, '***', $text);
    }

_search_body

public function _search_body($p, $markers = ['', ''])

_search body for normalized params.

  • $markers mixed from _highlight_markers()
    public function _search_body($p, $markers = ["\u{E000}", "\u{E001}"])
    {
        $words = $this->_words($p['q']);
        $bool = [];
        if ($words) {
            $match = ['query' => implode(' ', $words), 'type' => 'cross_fields', 'operator' => 'and'];
            if ($p['fields']) {
                foreach ($p['fields'] as $name => $weight) {
                    $match['fields'][] = $name . '^' . $weight;
                }
            } else {
                // every field, numeric ones too: lenient skips the fields the text cannot be parsed for
                $match['lenient'] = true;
            }
            $bool['must'] = [['multi_match' => $match]];
        }
        $bool += $this->_filters($p['filters']);
        $body = [
            'query' => $bool ? ['bool' => $bool] : ['match_all' => (object) []],
            'from' => (int) $p['offset'],
            'size' => (int) $p['limit'],
            'track_total_hits' => is_bool($this->TRACK_TOTAL_HITS) ? $this->TRACK_TOTAL_HITS : max(1, (int) $this->TRACK_TOTAL_HITS),
            '_source' => $p['return'] ? array_values(array_unique([$p['id_field'], ...$p['return']])) : true,
            'sort' => [],
        ];
        $sort = $p['sort'] ?: ($words ? ['_score' => 'desc'] : []);
        foreach ($sort as $name => $dir) {
            $body['sort'][] = [$name => ['order' => $dir]];
        }
        if ( ! isset($sort[$p['id_field']])) {
            // stable pages; unmapped_type: an index without the id field sorts without an error
            $body['sort'][] = [$p['id_field'] => ['order' => 'asc', 'unmapped_type' => 'long']];
        }
        if ($words) {
            $body['track_scores'] = true;
        }
        if ($p['highlight']['fields'] ?? []) {
            $body['highlight'] = [
                'pre_tags' => [$markers[0]],
                'post_tags' => [$markers[1]],
                'number_of_fragments' => 0,
                'no_match_size' => 1000000,
                'require_field_match' => false,
                'fields' => array_fill_keys($p['highlight']['fields'], (object) []),
            ];
        }
        foreach ($p['facets'] as $name) {
            $body['aggs'][$name] = ['terms' => ['field' => $name, 'size' => (int) $p['facet_limit'], 'order' => [['_count' => 'desc'], ['_key' => 'asc']]]];
        }
        return $body;
    }
public function _without_field($fields, $name)

$fields without the key $name (any case); '' keeps them all.

  • $fields array
    public function _without_field($fields, $name)
    {
        $name = strtolower((string) $name);
        return $name === '' ? $fields : array_filter($fields, fn ($key) => strtolower((string) $key) !== $name, ARRAY_FILTER_USE_KEY);
    }

_write

public function _write($method, $path, $body = null)
  • $body mixed array (JSON) or null
    public function _write($method, $path, $body = null)
    {
        $error = '';
        if ($this->_request($method, $path, $body, $error) === false) {
            return $this->_fail($error);
        }
        $this->last_error = '';
        return true;
    }

Inherited methods

from yf_search_driver: _doc_id(), _fail(), _highlight_html(), _is_valid_name(), _name(), _normalize_filter(), _normalize_params(), _not_supported(), _without_id(), _words()