= 8) { $v = (int)$v; return pack("NN", $v >> 32, $v & 0xFFFFFFFF); } if (function_exists("bcmul")) { if (bccomp($v, "0") == -1) $v = bcadd("18446744073709551616", $v); $h = bcdiv($v, "4294967296", 0); $l = bcmod($v, "4294967296"); return pack("NN", (float)$h, (float)$l); } return pack("NN", 0, (int)$v); // Fallback for simple ints } function sphPackU64($v): string { return sphPackI64($v); } function sphUnpackU64($v) { $data = unpack("Nhi/Nlo", $v); $hi = $data['hi']; $lo = $data['lo']; if (PHP_INT_SIZE >= 8) { if ($hi < 0) $hi += (1 << 32); if ($lo < 0) $lo += (1 << 32); return ($hi << 32) + $lo; } $hi = sprintf("%u", $hi); $lo = sprintf("%u", $lo); if (function_exists("bcmul")) { return bcadd($lo, bcmul($hi, "4294967296")); } return $lo; } function sphUnpackI64($v) { return sphUnpackU64($v); } function sphFixUint($value) { if (PHP_INT_SIZE >= 8) { if ($value < 0) $value += (1 << 32); return $value; } return sprintf("%u", $value); } class SphinxClient { public $_host = "localhost"; public $_port = 9312; public $_offset = 0; public $_limit = 20; public $_mode = SPH_MATCH_ALL; public $_weights = []; public $_sort = SPH_SORT_RELEVANCE; public $_sortby = ""; public $_min_id = 0; public $_max_id = 0; public $_filters = []; public $_groupby = ""; public $_groupfunc = SPH_GROUPBY_DAY; public $_groupsort = "@group desc"; public $_groupdistinct = ""; public $_maxmatches = 1000; public $_cutoff = 0; public $_retrycount = 0; public $_retrydelay = 0; public $_anchor = []; public $_indexweights = []; public $_ranker = SPH_RANK_PROXIMITY_BM25; public $_maxquerytime = 0; public $_fieldweights = []; public $_overrides = []; public $_select = "*"; public $_error = ""; public $_warning = ""; public $_connerror = false; public $_reqs = []; public $_mbenc = ""; public $_arrayresult = false; public $_timeout = 0; protected $_socket = false; protected $_path = false; public function __construct() { // Initialized via property defaults } public function __destruct() { if ($this->_socket !== false) { fclose($this->_socket); } } public function GetLastError(): string { return $this->_error; } public function GetLastWarning(): string { return $this->_warning; } public function IsConnectError(): bool { return $this->_connerror; } public function SetServer(string $host, int $port = 0): void { if ($host !== '' && $host[0] === '/') { $this->_path = 'unix://' . $host; return; } if (str_starts_with($host, "unix://")) { $this->_path = $host; return; } $this->_host = $host; $this->_port = $port; $this->_path = ''; } public function SetConnectTimeout($timeout): void { $this->_timeout = (float)$timeout; } protected function _Send($handle, $data, $length): bool { if (feof($handle) || fwrite($handle, $data, $length) !== $length) { $this->_error = 'connection unexpectedly closed (timed out?)'; $this->_connerror = true; return false; } return true; } protected function _MBPush(): void { $this->_mbenc = ""; // mbstring.func_overload is removed in PHP 8.0, but we check for legacy if (function_exists('mb_internal_encoding') && (ini_get("mbstring.func_overload") & 2)) { $this->_mbenc = mb_internal_encoding(); mb_internal_encoding("latin1"); } } protected function _MBPop(): void { if ($this->_mbenc !== "" && function_exists('mb_internal_encoding')) { mb_internal_encoding($this->_mbenc); } } protected function _Connect() { if ($this->_socket !== false) { if (!feof($this->_socket)) return $this->_socket; $this->_socket = false; } $errno = 0; $errstr = ""; $this->_connerror = false; $host = $this->_path ?: $this->_host; $port = $this->_path ? 0 : $this->_port; $fp = @fsockopen($host, $port, $errno, $errstr, $this->_timeout > 0 ? $this->_timeout : 5); if (!$fp) { $location = $this->_path ?: "{$this->_host}:{$this->_port}"; $this->_error = "connection to $location failed (errno=$errno, msg=" . trim($errstr) . ")"; $this->_connerror = true; return false; } if (!$this->_Send($fp, pack("N", 1), 4)) { fclose($fp); $this->_error = "failed to send client protocol version"; return false; } $vData = fread($fp, 4); if (strlen($vData) < 4) { fclose($fp); $this->_error = "failed to read searchd version"; return false; } $v = unpack("N", $vData)[1]; if ($v < 1) { fclose($fp); $this->_error = "expected searchd protocol version 1+, got version '$v'"; return false; } return $fp; } protected function _GetResponse($fp, $client_ver) { $response = ""; $header = fread($fp, 8); if (strlen($header) == 8) { $data = unpack("nstatus/nver/Nlen", $header); $status = $data['status']; $ver = $data['ver']; $len = $data['len']; $left = $len; while ($left > 0 && !feof($fp)) { $chunk = fread($fp, min($left, 8192)); if ($chunk) { $response .= $chunk; $left -= strlen($chunk); } } } else { $status = SEARCHD_ERROR; $ver = 0; $len = 0; } if ($this->_socket === false) fclose($fp); $read = strlen($response); if (!$response && $len > 0) { $this->_error = "received zero-sized searchd response"; return false; } if ($status == SEARCHD_WARNING) { $wlen = unpack("N", substr($response, 0, 4))[1]; $this->_warning = substr($response, 4, $wlen); return substr($response, 4 + $wlen); } if ($status == SEARCHD_ERROR || $status == SEARCHD_RETRY) { $this->_error = "searchd error: " . substr($response, 4); return false; } if ($status != SEARCHD_OK) { $this->_error = "unknown status code '$status'"; return false; } if ($ver < $client_ver) { $this->_warning = sprintf("searchd command v.%d.%d older than client, some options might not work", $ver >> 8, $ver & 0xff); } return $response; } public function SetLimits(int $offset, int $limit, int $max = 0, int $cutoff = 0): void { $this->_offset = $offset; $this->_limit = $limit; if ($max > 0) $this->_maxmatches = $max; if ($cutoff > 0) $this->_cutoff = $cutoff; } public function SetMaxQueryTime(int $max): void { $this->_maxquerytime = max(0, $max); } public function SetMatchMode(int $mode): void { $this->_mode = $mode; } public function SetRankingMode(int $ranker): void { $this->_ranker = $ranker; } public function SetSortMode(int $mode, string $sortby = ""): void { $this->_sort = $mode; $this->_sortby = $sortby; } public function SetFieldWeights(array $weights): void { $this->_fieldweights = $weights; } public function SetIndexWeights(array $weights): void { $this->_indexweights = $weights; } public function SetIDRange($min, $max): void { $this->_min_id = $min; $this->_max_id = $max; } public function SetFilter(string $attribute, array $values, bool $exclude = false): void { if (!empty($values)) { $this->_filters[] = ["type" => SPH_FILTER_VALUES, "attr" => $attribute, "exclude" => $exclude, "values" => $values]; } } public function SetFilterRange(string $attribute, $min, $max, bool $exclude = false): void { $this->_filters[] = ["type" => SPH_FILTER_RANGE, "attr" => $attribute, "exclude" => $exclude, "min" => $min, "max" => $max]; } public function SetFilterFloatRange(string $attribute, float $min, float $max, bool $exclude = false): void { $this->_filters[] = ["type" => SPH_FILTER_FLOATRANGE, "attr" => $attribute, "exclude" => $exclude, "min" => $min, "max" => $max]; } public function SetGeoAnchor(string $attrlat, string $attrlong, float $lat, float $long): void { $this->_anchor = ["attrlat" => $attrlat, "attrlong" => $attrlong, "lat" => $lat, "long" => $long]; } public function SetGroupBy(string $attribute, int $func, string $groupsort = "@group desc"): void { $this->_groupby = $attribute; $this->_groupfunc = $func; $this->_groupsort = $groupsort; } public function SetGroupDistinct(string $attribute): void { $this->_groupdistinct = $attribute; } public function SetRetries(int $count, int $delay = 0): void { $this->_retrycount = $count; $this->_retrydelay = $delay; } public function SetArrayResult(bool $arrayresult): void { $this->_arrayresult = $arrayresult; } public function SetOverride(string $attrname, int $attrtype, array $values): void { $this->_overrides[$attrname] = ["attr" => $attrname, "type" => $attrtype, "values" => $values]; } public function SetSelect(string $select): void { $this->_select = $select; } public function ResetFilters(): void { $this->_filters = []; $this->_anchor = []; } public function ResetGroupBy(): void { $this->_groupby = ""; $this->_groupfunc = SPH_GROUPBY_DAY; $this->_groupsort = "@group desc"; $this->_groupdistinct = ""; } public function ResetOverrides(): void { $this->_overrides = []; } public function Query(string $query, string $index = "*", string $comment = "") { $this->_reqs = []; $this->AddQuery($query, $index, $comment); $results = $this->RunQueries(); $this->_reqs = []; if (!is_array($results) || !isset($results[0])) return false; $this->_error = $results[0]["error"]; $this->_warning = $results[0]["warning"]; return ($results[0]["status"] == SEARCHD_ERROR) ? false : $results[0]; } protected function _PackFloat($f): string { return pack("N", unpack("L", pack("f", (float)$f))[1]); } public function AddQuery(string $query, string $index = "*", string $comment = ""): int { $this->_MBPush(); $req = pack("NNNNN", $this->_offset, $this->_limit, $this->_mode, $this->_ranker, $this->_sort); $req .= pack("N", strlen($this->_sortby)) . $this->_sortby; $req .= pack("N", strlen($query)) . $query; $req .= pack("N", count($this->_weights)); foreach ($this->_weights as $weight) $req .= pack("N", (int)$weight); $req .= pack("N", strlen($index)) . $index; $req .= pack("N", 1); $req .= sphPackU64($this->_min_id) . sphPackU64($this->_max_id); $req .= pack("N", count($this->_filters)); foreach ($this->_filters as $filter) { $req .= pack("N", strlen($filter["attr"])) . $filter["attr"]; $req .= pack("N", $filter["type"]); switch ($filter["type"]) { case SPH_FILTER_VALUES: $req .= pack("N", count($filter["values"])); foreach ($filter["values"] as $value) $req .= sphPackI64($value); break; case SPH_FILTER_RANGE: $req .= sphPackI64($filter["min"]) . sphPackI64($filter["max"]); break; case SPH_FILTER_FLOATRANGE: $req .= $this->_PackFloat($filter["min"]) . $this->_PackFloat($filter["max"]); break; } $req .= pack("N", (int)$filter["exclude"]); } $req .= pack("NN", $this->_groupfunc, strlen($this->_groupby)) . $this->_groupby; $req .= pack("N", $this->_maxmatches); $req .= pack("N", strlen($this->_groupsort)) . $this->_groupsort; $req .= pack("NNN", $this->_cutoff, $this->_retrycount, $this->_retrydelay); $req .= pack("N", strlen($this->_groupdistinct)) . $this->_groupdistinct; if (empty($this->_anchor)) { $req .= pack("N", 0); } else { $req .= pack("N", 1); $req .= pack("N", strlen($this->_anchor["attrlat"])) . $this->_anchor["attrlat"]; $req .= pack("N", strlen($this->_anchor["attrlong"])) . $this->_anchor["attrlong"]; $req .= $this->_PackFloat($this->_anchor["lat"]) . $this->_PackFloat($this->_anchor["long"]); } $req .= pack("N", count($this->_indexweights)); foreach ($this->_indexweights as $idx => $weight) $req .= pack("N", strlen($idx)) . $idx . pack("N", (int)$weight); $req .= pack("N", $this->_maxquerytime); $req .= pack("N", count($this->_fieldweights)); foreach ($this->_fieldweights as $field => $weight) $req .= pack("N", strlen($field)) . $field . pack("N", (int)$weight); $req .= pack("N", strlen($comment)) . $comment; $req .= pack("N", count($this->_overrides)); foreach ($this->_overrides as $entry) { $req .= pack("N", strlen($entry["attr"])) . $entry["attr"]; $req .= pack("NN", $entry["type"], count($entry["values"])); foreach ($entry["values"] as $id => $val) { $req .= sphPackU64($id); switch ($entry["type"]) { case SPH_ATTR_FLOAT: $req .= $this->_PackFloat($val); break; case SPH_ATTR_BIGINT: $req .= sphPackI64($val); break; default: $req .= pack("N", (int)$val); break; } } } $req .= pack("N", strlen($this->_select)) . $this->_select; $this->_MBPop(); $this->_reqs[] = $req; return count($this->_reqs) - 1; } public function RunQueries() { if (empty($this->_reqs)) { $this->_error = "no queries defined, issue AddQuery() first"; return false; } $this->_MBPush(); if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; } $nreqs = count($this->_reqs); $req = implode("", $this->_reqs); $len = 4 + strlen($req); $req = pack("nnNN", SEARCHD_COMMAND_SEARCH, VER_COMMAND_SEARCH, $len, $nreqs) . $req; if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_SEARCH))) { $this->_MBPop(); return false; } $this->_reqs = []; $res = $this->_ParseSearchResponse($response, $nreqs); $this->_MBPop(); return $res; } protected function _ParseSearchResponse($response, $nreqs): array { $p = 0; $max = strlen($response); $results = []; for ($ires = 0; $ires < $nreqs && $p < $max; $ires++) { $result = ["error" => "", "warning" => ""]; $status = unpack("N", substr($response, $p, 4))[1]; $p += 4; $result["status"] = $status; if ($status != SEARCHD_OK) { $len = unpack("N", substr($response, $p, 4))[1]; $p += 4; $message = substr($response, $p, $len); $p += $len; if ($status == SEARCHD_WARNING) $result["warning"] = $message; else { $result["error"] = $message; $results[] = $result; continue; } } $fields = []; $nfields = unpack("N", substr($response, $p, 4))[1]; $p += 4; while ($nfields-- > 0 && $p < $max) { $len = unpack("N", substr($response, $p, 4))[1]; $p += 4; $fields[] = substr($response, $p, $len); $p += $len; } $result["fields"] = $fields; $attrs = []; $nattrs = unpack("N", substr($response, $p, 4))[1]; $p += 4; while ($nattrs-- > 0 && $p < $max) { $len = unpack("N", substr($response, $p, 4))[1]; $p += 4; $attr = substr($response, $p, $len); $p += $len; $type = unpack("N", substr($response, $p, 4))[1]; $p += 4; $attrs[$attr] = $type; } $result["attrs"] = $attrs; $count = unpack("N", substr($response, $p, 4))[1]; $p += 4; $id64 = unpack("N", substr($response, $p, 4))[1]; $p += 4; $result["matches"] = []; while ($count-- > 0 && $p < $max) { if ($id64) { $doc = sphUnpackU64(substr($response, $p, 8)); $p += 8; $weight = unpack("N", substr($response, $p, 4))[1]; $p += 4; } else { $matchData = unpack("Ndoc/Nweight", substr($response, $p, 8)); $p += 8; $doc = sphFixUint($matchData['doc']); $weight = $matchData['weight']; } $attrvals = []; foreach ($attrs as $attr => $type) { if ($type == SPH_ATTR_BIGINT) { $attrvals[$attr] = sphUnpackI64(substr($response, $p, 8)); $p += 8; } elseif ($type == SPH_ATTR_FLOAT) { $uval = unpack("N", substr($response, $p, 4))[1]; $p += 4; $attrvals[$attr] = unpack("f", pack("L", $uval))[1]; } else { $val = unpack("N", substr($response, $p, 4))[1]; $p += 4; if ($type & SPH_ATTR_MULTI) { $vals = []; $nvals = $val; while ($nvals-- > 0 && $p < $max) { $vals[] = sphFixUint(unpack("N", substr($response, $p, 4))[1]); $p += 4; } $attrvals[$attr] = $vals; } else { $attrvals[$attr] = sphFixUint($val); } } } if ($this->_arrayresult) $result["matches"][] = ["id" => $doc, "weight" => $weight, "attrs" => $attrvals]; else $result["matches"][$doc] = ["weight" => $weight, "attrs" => $attrvals]; } $stats = unpack("Ntotal/Nfound/Nmsecs/Nwords", substr($response, $p, 16)); $p += 16; $result["total"] = sprintf("%u", $stats['total']); $result["total_found"] = sprintf("%u", $stats['found']); $result["time"] = sprintf("%.3f", $stats['msecs'] / 1000); $nwords = $stats['words']; while ($nwords-- > 0 && $p < $max) { $len = unpack("N", substr($response, $p, 4))[1]; $p += 4; $word = substr($response, $p, $len); $p += $len; $wstats = unpack("Ndocs/Nhits", substr($response, $p, 8)); $p += 8; $result["words"][$word] = ["docs" => sprintf("%u", $wstats['docs']), "hits" => sprintf("%u", $wstats['hits'])]; } $results[] = $result; } return $results; } public function BuildExcerpts(array $docs, string $index, string $words, array $opts = []) { $this->_MBPush(); if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; } $opts += [ "before_match" => "", "after_match" => "", "chunk_separator" => " ... ", "limit" => 256, "around" => 5, "exact_phrase" => false, "single_passage" => false, "use_boundaries" => false, "weight_order" => false ]; $flags = 1; if ($opts["exact_phrase"]) $flags |= 2; if ($opts["single_passage"]) $flags |= 4; if ($opts["use_boundaries"]) $flags |= 8; if ($opts["weight_order"]) $flags |= 16; $req = pack("NN", 0, $flags); $req .= pack("N", strlen($index)) . $index; $req .= pack("N", strlen($words)) . $words; $req .= pack("N", strlen($opts["before_match"])) . $opts["before_match"]; $req .= pack("N", strlen($opts["after_match"])) . $opts["after_match"]; $req .= pack("N", strlen($opts["chunk_separator"])) . $opts["chunk_separator"]; $req .= pack("N", (int)$opts["limit"]); $req .= pack("N", (int)$opts["around"]); $req .= pack("N", count($docs)); foreach ($docs as $doc) $req .= pack("N", strlen($doc)) . $doc; $len = strlen($req); $req = pack("nnN", SEARCHD_COMMAND_EXCERPT, VER_COMMAND_EXCERPT, $len) . $req; if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_EXCERPT))) { $this->_MBPop(); return false; } $pos = 0; $res = []; foreach ($docs as $unused) { $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4; $res[] = substr($response, $pos, $len); $pos += $len; } $this->_MBPop(); return $res; } public function BuildKeywords(string $query, string $index, bool $hits) { $this->_MBPush(); if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; } $req = pack("N", strlen($query)) . $query; $req .= pack("N", strlen($index)) . $index; $req .= pack("N", (int)$hits); $len = strlen($req); $req = pack("nnN", SEARCHD_COMMAND_KEYWORDS, VER_COMMAND_KEYWORDS, $len) . $req; if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_KEYWORDS))) { $this->_MBPop(); return false; } $pos = 0; $res = []; $nwords = unpack("N", substr($response, $pos, 4))[1]; $pos += 4; for ($i = 0; $i < $nwords; $i++) { $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4; $tokenized = substr($response, $pos, $len); $pos += $len; $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4; $normalized = substr($response, $pos, $len); $pos += $len; $entry = ["tokenized" => $tokenized, "normalized" => $normalized]; if ($hits) { $wstats = unpack("Ndocs/Nhits", substr($response, $pos, 8)); $pos += 8; $entry["docs"] = $wstats['docs']; $entry["hits"] = $wstats['hits']; } $res[] = $entry; } $this->_MBPop(); return $res; } public function EscapeString(string $string): string { $chars = ['\\', '(', ')', '|', '-', '!', '@', '~', '"', '&', '/', '^', '$', '=']; $replacements = array_map(fn($c) => '\\' . $c, $chars); return str_replace($chars, $replacements, $string); } public function UpdateAttributes(string $index, array $attrs, array $values, bool $mva = false): int { $req = pack("N", strlen($index)) . $index; $req .= pack("N", count($attrs)); foreach ($attrs as $attr) { $req .= pack("N", strlen($attr)) . $attr; $req .= pack("N", (int)$mva); } $req .= pack("N", count($values)); foreach ($values as $id => $entry) { $req .= sphPackU64($id); foreach ($entry as $v) { $req .= pack("N", $mva ? count($v) : (int)$v); if ($mva) foreach ($v as $vv) $req .= pack("N", (int)$vv); } } if (!($fp = $this->_Connect())) return -1; $len = strlen($req); $req = pack("nnN", SEARCHD_COMMAND_UPDATE, VER_COMMAND_UPDATE, $len) . $req; if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_UPDATE))) return -1; return unpack("N", substr($response, 0, 4))[1]; } public function Status() { $this->_MBPush(); if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; } $req = pack("nnNN", SEARCHD_COMMAND_STATUS, VER_COMMAND_STATUS, 4, 1); if (!$this->_Send($fp, $req, 12) || !($response = $this->_GetResponse($fp, VER_COMMAND_STATUS))) { $this->_MBPop(); return false; } $p = 4; $stats = unpack("Nrows/Ncols", substr($response, $p, 8)); $p += 8; $rows = $stats['rows']; $cols = $stats['cols']; $res = []; for ($i = 0; $i < $rows; $i++) { for ($j = 0; $j < $cols; $j++) { $len = unpack("N", substr($response, $p, 4))[1]; $p += 4; $res[$i][] = substr($response, $p, $len); $p += $len; } } $this->_MBPop(); return $res; } }