Search by

sopheos / pebble_es

sopheos

Elasticsearch REST client on top of Guzzle, with query DSL builders

Package info

github.com/sopheos/pebble_es

pkg:composer/sopheos/pebble_es

Statistics

Installs: 369

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

1.1.2 2026-10-06 12:45 UTC

This package is auto-updated.

Last update: 2026-10-06 12:46:31 UTC


README

Client REST Elasticsearch minimal sur Guzzle, avec des builders pour le DSL de requête.

Ce document est un guide d'utilisation du client et du query builder. La lib ne gère ni les nœuds, ni les retries, ni l'hydratation des résultats : elle envoie du JSON à une URL relative et renvoie le statut et le corps.

Installation

composer require sopheos/pebble_es

Claude Code

Ce package fournit un skill Claude Code dans skills/pebble-es/. Il documente les patterns d'usage et les pièges de la librairie : search() qui prend un tableau et non un Query, ids de document non encodés dans l'URL, Terms::make() variadique, BoolQuery vide qui devient match_all, maxScore() tronqué, etc.

Dans un projet qui dépend de sopheos/pebble_es, copie-le une fois dans .claude/skills/ après composer install pour que Claude Code le charge automatiquement. Le nom du dossier doit correspondre au name déclaré dans SKILL.md :

cp -r vendor/sopheos/pebble_es/skills/pebble-es .claude/skills/pebble-es

Pour la maintenance de la lib elle-même, voir CLAUDE.md. Les bugs connus sont listés dans TODO.md.

Exemple simple

Préparer une recherche

Pour effectuer une recherche, on prépare une requête \Pebble\ES\Query à laquelle on ajoute un composant \Pebble\ES\BoolQuery.

use Pebble\ES\BoolQuery;
use Pebble\ES\MatchTerm;
use Pebble\ES\Query;
use Pebble\ES\Term;

$bool  = BoolQuery::make()->minimumSouldMatch(1); // sic : faute de frappe dans le nom
$query = Query::make()->size(20)->query($bool);

La requête booléenne groupe des filtres dans des directives (must, must_not, filter, should) :

Term::make('pays', 'FR')->filter($bool);
MatchTerm::make('recherche', $search)->boost(16)->should($bool);
MatchTerm::make('nom', $search)->boost(8)->should($bool);

Voir la documentation d'Elasticsearch : https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html

Envoyer la requête

Le client utilise un \GuzzleHttp\Client injecté. Les URLs sont relatives : le client Guzzle doit avoir un base_uri.

$guzzle  = new \GuzzleHttp\Client(['base_uri' => 'http://127.0.0.1:9200']);
$request = new \Pebble\ES\Request($guzzle, 'loc_villes');

$res = $request->search($query->raw());

search() prend un tableau : on passe $query->raw() (les builders imbriqués sont sérialisés par Guzzle via JsonSerializable), pas l'objet Query.

Voir la documentation de Guzzle : https://github.com/guzzle/guzzle

Récupérer les résultats

Toutes les méthodes de Request renvoient un \Pebble\ES\Response.

if ($res->isSuccess()) {
    $total   = $res->total();
    $sources = $res->sources(); // ['_id' => _source, ...]
}

Request

new \Pebble\ES\Request(\GuzzleHttp\Client $guzzle, string $index). Il n'y a pas de make().

Les erreurs HTTP ne lèvent pas d'exception (http_errors => false) : il faut tester status() ou isSuccess(). Une erreur de transport (serveur injoignable) renvoie une Response(500, message) et lève une notice E_USER_NOTICE.

Index

  • getIndex() : string Index courant.
  • setIndex(string $index) : static Change l'index courant.
  • hasIndex() : bool HEAD {index}, vrai si le statut est 200.
  • createIndex(array $mappings = [], array $settings = []) : Response PUT {index}.
  • deleteIndex() : Response DELETE {index}.

Documents

Les identifiants sont concaténés dans l'URL sans encodage : ne pas y passer une entrée utilisateur brute (voir TODO.md).

  • hasDoc(string $id) : bool HEAD {index}/_doc/{id}.
  • getDoc(string $id) : Response GET {index}/_doc/{id}.
  • createDoc(array $data, ?string $id = null) : Response POST {index}/_doc sans id, PUT {index}/_doc/{id} avec : insère ou remplace.
  • updateDoc(string $id, array $data) : Response POST {index}/_update/{id}. $data contient doc ou script.
  • deleteDoc(string $id) : Response DELETE {index}/_doc/{id}.
$res = $request->createDoc(['name' => 'Mathieu', 'age' => 2], '1');
$success = in_array($res->json()['result'] ?? null, ['created', 'updated'], true);

$request->updateDoc('1', ['doc' => ['age' => 3]]);
$request->updateDoc('1', ['script' => 'ctx._source.age += 5']);

$res = $request->deleteDoc('1');
$success = ($res->json()['result'] ?? null) === 'deleted';

Multi documents

  • search(array $data = []) : Response POST {index}/_search.
  • scroll(string|array $data = [], string $time = '1m') : Response Recherche longue : un tableau lance la recherche, une chaîne (scroll id) demande la page suivante.
  • clearScroll(string $id) : Response Supprime une recherche longue.
  • bulk(array $data) : Response POST _bulk (endpoint global, en NDJSON). Chaque ligne d'action doit porter son _index.
  • updateByQuery(array $data, bool $proceedOnConflicts = true) : Response POST {index}/_update_by_query.
  • deleteByQuery(array $data, bool $proceedOnConflicts = true) : Response POST {index}/_delete_by_query.

Bas niveau

  • jsonRequest(string $method, string $url, array $data = []) : Response Envoie $data en JSON (aucun corps si $data est vide).
  • request(string $method, string $url, array $params) : Response Appel Guzzle brut avec ses options.
  • lastRequest() : array Les options Guzzle de la dernière requête.

Les recherches longues

Une recherche standard est limitée à 10 000 éléments. Pour récupérer tous les résultats d'une recherche longue, on utilise un scroll.

Première recherche : on appelle scroll() avec le corps de la recherche, comme search(). Le second paramètre fixe la durée de conservation du contexte. La réponse contient un _scroll_id, lu par scrollID(), qu'on repasse à scroll() pour obtenir la page suivante. Une fois terminé, on supprime le contexte avec clearScroll().

$res = $request->scroll(Query::make()->size(1000)->raw());
$scroll_id = $res->scrollID();

while ($res->hits()) {
    // ...
    $res = $request->scroll($scroll_id);
    $scroll_id = $res->scrollID() ?? $scroll_id;
}

$request->clearScroll($scroll_id);

Insertion de masse

$data = [];
$data[] = ['index' => ['_index' => 'tests', '_id' => '1']];
$data[] = ['name' => 'Pierre'];
$data[] = ['index' => ['_index' => 'tests', '_id' => '2']];
$data[] = ['name' => 'Chloé'];

$request->bulk($data);

Suppression de masse

$bool = BoolQuery::make();
// ...
$request->deleteByQuery(Query::make()->query($bool)->raw());

Response

  • status() : int Statut HTTP (500 en cas d'erreur de transport).
  • isSuccess() : bool Statut entre 200 et 208.
  • body() : string Corps brut.
  • json() : array Corps décodé, [] s'il est vide ou invalide.
  • took() : int Temps d'exécution en millisecondes.
  • total() : int Nombre total de résultats (hits.total.value, ou l'ancien entier hits.total).
  • maxScore() : int Meilleur score. Attention : tronqué en entier (voir TODO.md).
  • hits() : array Les hits bruts.
  • sources() : array Les _source indexés par _id (null si _source est absent).
  • scrollID() : ?string Le _scroll_id.
  • aggs(...$names) Résultat d'une agrégation : ses buckets, sinon sa value, sinon null. Les paramètres forment le chemin dans les agrégations imbriquées.

Query builder

Les requêtes Elasticsearch sont très verbeuses. Le query builder aide à construire les plus communes.

Tous les éléments du query builder étendent \Pebble\ES\AbstractRaw et implémentent JsonSerializable.

  • abstract raw() : array Renvoie les données du composant. Les composants imbriqués restent des objets : on obtient le JSON final avec json_encode().

Un composant personnalisé peut être créé avec le helper \Pebble\ES\Raw :

Raw::make(['prefix' => ['user' => 'ki']]);

Query

Conteneur principal du query builder.

  • static make() : Query Alternative au constructeur.
  • set(string $key, $value) : static Ajoute ou remplace une clé.
  • size(int $value = 10) : static Nombre de résultats.
  • from(int $value = 0) : static Décalage (offset).
  • searchAfter(array $value) : static Ne renvoie que les résultats situés après ces valeurs de tri (pagination profonde).
  • query(AbstractRaw $query) : static La requête.
  • aggs(AbstractRaw $agg) : static Ajoute une agrégation (fusionnée avec les précédentes).
  • sort(AbstractRaw $sort) : static Le tri (remplace le précédent).
  • highlight(AbstractRaw $highlight) : static Le highlight.
  • suggest(AbstractRaw $suggest) : static Les suggestions.
  • script(AbstractRaw $script) : static Un script (pour updateByQuery). Un Script est déballé de sa clé script.
  • conflictsProceed() : static Ajoute conflicts: proceed au corps.

BoolQuery

Range les requêtes dans des directives. Sans aucune clause, elle se sérialise en match_all (même si boost() ou minimumSouldMatch() ont été appelés).

  • static make() : BoolQuery Alternative au constructeur.
  • must(AbstractRaw $raw) : static Ajoute une clause must.
  • should(AbstractRaw $raw) : static Ajoute une clause should.
  • filter(AbstractRaw $raw) : static Ajoute une clause filter.
  • mustNot(AbstractRaw $raw) : static Ajoute une clause must_not.
  • minimumSouldMatch(int $value) : static minimum_should_match (le nom contient une faute de frappe).
  • boost(float $value) : static Renforce le score.

Filtres

Tous les filtres étendent \Pebble\ES\AbstractFilter, qui ajoute must(BoolQuery), filter(BoolQuery), should(BoolQuery) et mustNot(BoolQuery) : le filtre s'ajoute à la BoolQuery et renvoie le filtre lui-même.

Tous les filtres d'Elasticsearch n'ont pas été ajoutés à Pebble. Merci de les rajouter au fur et à mesure des besoins.

Des options facultatives peuvent être ajoutées avec $filter->set('key', $value) sur MatchTerm, MatchPhrase et MatchBoolPrefix uniquement : sur Term, Range et Percolate, set() est protégée.

Remarque : certains filtres ont un contexte de recherche (plein texte), le score est impacté. D'autres ont un contexte de filtre, le score n'est pas impacté.

MatchTerm

Recherche plein texte sur un champ analysé (requête match).

MatchTerm::make('titre', 'meuble')->fuzzy('auto')->boost(16);
  • fuzzy($value = 'auto'), boost(int $value = 1), operator(string $value = 'AND'), prefixLen(int $len).

MatchPhrase

Recherche d'une phrase. slop est toujours envoyé (0 par défaut).

MatchPhrase::make('titre', 'table basse', 1);

MatchBoolPrefix

Combine les tokens en termes exacts, sauf le dernier traité en préfixe. Utile en autocomplétion.

MatchBoolPrefix::make('nom', 'mulh')->operator('AND');
  • fuzzy($value = 'auto'), boost(int $value = 1), operator(string $value = 'AND'), prefixLen(int $len).

MatchAll

Tous les documents.

MatchAll::make();

Range

Valeurs bornées par un intervalle.

Range::make('prix')->gte(10)->lte(15);
Range::make('borne')->gte('01/01/2012')->lte('2013')->format('dd/MM/yyyy||yyyy');
  • gte($value) Plus grand ou égal.
  • gt($value) Plus grand.
  • lte($value) Plus petit ou égal.
  • lt($value) Plus petit.
  • format(string $value) Format pour les dates.
  • timeZone(string $value) Fuseau horaire des dates.
  • boost(float $value) Renforce le score.

Exists

Documents dont le champ existe et a une valeur non nulle.

Exists::make('petname');

Term

Une valeur exacte d'un champ.

Term::make('pays', 'FR')->boost(2);

Terms

Une valeur parmi plusieurs. make() est variadique, le constructeur prend un tableau :

Terms::make('pays', 'FR', 'CH', 'BE');
Terms::make('pays', ...$codes);
new Terms('pays', $codes);

Attention : Terms::make('pays', $codes) imbrique le tableau ([["FR", …]]), ce qu'Elasticsearch refuse.

Prefix

Champ non analysé qui commence par une valeur. Étend Term.

Prefix::make('user', 'ki');

Wildcard

Champ non analysé avec des jokers. Étend Term.

Wildcard::make('user', 'ki*y');

Regex

Champ non analysé avec une expression régulière (requête regexp). Étend Term.

Regex::make('user', 'k.*m')->flags('ALL');

Fuzzy

Valeur approximative d'un champ non analysé. Étend Term.

Fuzzy::make('ville', 'milhouse')
    ->fuzziness(1)
    ->prefixLength(0)
    ->maxExpansions(20);

Ids

Documents dont l'identifiant appartient à une liste.

Ids::make(['jsgrvdoi', 'zldns']);

Le second paramètre $type et le filtre Type::make($type) n'existent plus depuis Elasticsearch 8 : ne pas les utiliser.

GeoDistance

Documents dans un rayon autour d'un point. Le champ doit être du type geo_point.

GeoDistance::make('loc', 47.75, 7.31, '100m'); // field, lat, lon, distance

GeoBound

Documents dans un rectangle. Le champ doit être du type geo_point.

GeoBound::make('loc', $latMin, $latMax, $lonMin, $lonMax);

Nested

Requête sur des objets nested. score_mode vaut max par défaut (Elasticsearch utilise avg).

Nested::make('tags', Term::make('tags.id', 3))->scoreMode('avg');

Script et Percolate

Script::make("doc['prix'].value > params.min")->params(['min' => 10])->lang(Script::PAINLESS);
Percolate::make('query', ['message' => 'Bonjour']);

Tris

Sort

Tri par valeur.

Sort::make()->add('_score')->desc('type')->asc('pays_alias', 'min');
  • asc(string $field, ?string $mode = null) / desc(string $field, ?string $mode = null).
  • add(string $field, ?string $order = null, ?string $mode = null) Sans ordre, le champ seul est ajouté.

SortGeo

Tri par distance, toujours croissant et en mètres.

SortGeo::make('loc', 47.75, 7.31); // field, lat, lon

Agrégations

Une Aggregation porte un nom ; field($type, $options) ajoute le type d'agrégation et ses options.

Cherchons la liste des régions :

$aggs = Aggregation::make('admin_list')->field('terms', ['field' => 'admin_nom', 'size' => 1000]);
$query->aggs($aggs); // ou $aggs->addToQuery($query);

Si l'on ne veut que les agrégats, on peut demander à la requête de ne pas renvoyer de résultats :

$query->size(0);

Pour récupérer cette liste, on cherche son nom dans les agrégats de la réponse :

$list = $res->aggs('admin_list');

Éviter les noms d'agrégation numériques ('2020') : Query::aggs() les renumérote.

Highlight

Met en avant les correspondances. Les tags sont facultatifs, Elasticsearch utilise <em> par défaut.

$highlight = Highlight::make('title', 'description')->preTags('<b>')->postTags('</b>');
$query->highlight($highlight);

make() est variadique : un champ par argument. Le résultat se trouve dans la clé highlight de chaque hit.

Suggest

$suggest = Suggest::make()->term('ville', 'mulouse', SuggestTerm::make('ville')->size(3)->modePopular());
$query->suggest($suggest);

Tests

composer install
vendor/bin/phpunit

Aucun serveur Elasticsearch n'est nécessaire : les builders sont testés sur leur JSON, Request sur un MockHandler Guzzle. Les bugs connus sont figés par des tests annotés // BUG: qui vérifient le comportement actuel.