yf_search_driver_elasticsearch classElasticsearch 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.
| File | plugins/search/classes/search/yf_search_driver_elasticsearch.class.php |
|---|---|
| Use | _class('search_driver_elasticsearch', 'classes/search/') |
| Extends | yf_search_driver |
A site sets them in its config with $PROJECT_CONF['search_driver_elasticsearch']['NAME'] = ...;
| Property | Default | Description |
|---|---|---|
$BULK_SIZEint |
500 |
Documents per _bulk request |
$CA_FILEstring |
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_TIMEOUTint|float |
2 |
|
$FIELD_TYPESarray<string,array> |
|
create_index() schema type => mapping |
$HTTPobject|null |
curl */
Property [ public $HTTP = NULL |
Transport: request(method, url, headers, body) returns ['status', 'body', 'error']; null = curl |
$ID_FIELDstring |
only _id */
Property [ public $ID_FIELD = 'id' |
Field that gets the document id on writes, '' = only _id |
$MAX_FACETSint |
10 |
Facet fields per search at most (one terms aggregation each) |
$PARENT |
null |
|
$PASSstring |
'' |
|
$PROXYstring |
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 |
$REFRESHbool|string |
false |
refresh of writes: false (the index refresh interval), true or 'wait_for' (searchable on return) |
$SUGGEST_FIELDstring |
'yf_suggest' |
Field that create_index() fills from the text fields for suggest() |
$SUGGEST_LIMITint |
10 |
Default number of suggest() results |
$TIMEOUTint|float |
10 |
Seconds for a whole request |
$TRACK_TOTAL_HITSbool|int |
true |
track_total_hits: true counts every match, a number counts up to it (then total_relation is 'gte') |
$URLstring |
'http://127.0.0.1:9200' |
Base URL, http(s)://host:port, optionally with a path prefix |
$USERstring |
none */
Property [ public $USER = '' |
Basic auth user, '' = none |
$VERIFY_SSLbool |
true |
Verify the server's TLS certificate and host name |
$_curlCurlHandle|null |
null |
Kept between requests for keep-alive |
$_serverarray|null |
null |
GET / answer, cached by connect() |
from yf_search_driver: $DEF_LIMIT, $MAX_LIMIT, $MAX_WINDOW, $MAX_WORDS, $last_error
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]);
}
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;
}
public function capabilities()
public function capabilities()
{
return ['highlight' => true, 'suggest' => true, 'facets' => true, 'write' => true, 'ddl' => true, 'json' => true];
}
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;
}
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]]);
}
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;
}
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;
}
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);
}
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;
}
public function is_ready()
public function is_ready()
{
return (bool) $this->connect();
}
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;
}
public function search(array $params = [], &$error_message = '')
$params array public function search(array $params = [], &$error_message = '')
{
$started = microtime(true);
$error_message = $error = '';
$p = $this->_normalize_params($params, $error);
if ($p !== false && ! $this->_is_valid_index($p['index'])) {
[$p, $error] = [false, 'bad or missing index'];
}
if ($p !== false && count($p['facets']) > max(0, (int) $this->MAX_FACETS)) {
[$p, $error] = [false, 'too many facets'];
}
if ($p === false) {
return $this->_fail($error, $error_message);
}
$markers = $this->_highlight_markers();
$response = $this->_request('POST', $this->_path($p['index'], '_search'), $this->_search_body($p, $markers), $error);
if ($response === false) {
return $this->_fail($error, $error_message);
}
$items = [];
foreach ($response['hits']['hits'] ?? [] as $hit) {
$doc = (array) ($hit['_source'] ?? []);
$item = ['id' => $doc[$p['id_field']] ?? $hit['_id'] ?? null, 'score' => (float) ($hit['_score'] ?? 0), 'doc' => $doc, 'highlight' => []];
foreach ($p['highlight']['fields'] ?? [] as $name) {
$text = implode(' ', (array) ($hit['highlight'][$name] ?? []));
$item['highlight'][$name] = $this->_highlight_html($text, $markers, $p['highlight']['pre'], $p['highlight']['post']);
}
$items[] = $item;
}
$facets = [];
foreach ($p['facets'] as $name) {
$facets[$name] = [];
foreach ($response['aggregations'][$name]['buckets'] ?? [] as $bucket) {
$facets[$name][] = ['value' => $bucket['key'] ?? null, 'count' => (int) ($bucket['doc_count'] ?? 0)];
}
}
$total = $response['hits']['total'] ?? count($items);
$this->last_error = '';
return [
'total' => (int) (is_array($total) ? ($total['value'] ?? 0) : $total),
'total_relation' => ($total['relation'] ?? 'eq') === 'gte' ? 'gte' : 'eq',
'items' => $items,
'facets' => $facets,
'took_ms' => round((microtime(true) - $started) * 1000, 2),
];
}
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);
}
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) []]]);
}
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]);
}
_ 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 __clone()
public function __clone()
{
$this->_curl = null;
}
public function __destruct()
public function __destruct()
{
$this->_curl = null;
}
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;
}
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;
}
public function _auth_user()
Basic auth user, '' = none.
public function _auth_user()
{
return (string) $this->USER;
}
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;
}
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';
}
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(),
]);
}
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));
}
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}"];
}
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' => ''];
}
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;
}
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);
}
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));
}
public function _refresh()
Query string for writes.
public function _refresh()
{
if ($this->REFRESH === 'wait_for') {
return '?refresh=wait_for';
}
return $this->REFRESH ? '?refresh=true' : '';
}
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 stringReturns 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;
}
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'] : '');
}
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);
}
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);
}
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;
}
from yf_search_driver: _doc_id(), _fail(), _highlight_html(), _is_valid_name(), _name(), _normalize_filter(), _normalize_params(), _not_supported(), _without_id(), _words()