sopheos / pebble_es
Elasticsearch REST client on top of Guzzle, with query DSL builders
Requires
- php: >=8.1.0
- ext-json: *
- guzzlehttp/guzzle: ^7.0
Requires (Dev)
- phpunit/phpunit: ^9.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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() : stringIndex courant.setIndex(string $index) : staticChange l'index courant.hasIndex() : boolHEAD {index}, vrai si le statut est 200.createIndex(array $mappings = [], array $settings = []) : ResponsePUT {index}.deleteIndex() : ResponseDELETE {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) : boolHEAD {index}/_doc/{id}.getDoc(string $id) : ResponseGET {index}/_doc/{id}.createDoc(array $data, ?string $id = null) : ResponsePOST {index}/_docsans id,PUT {index}/_doc/{id}avec : insère ou remplace.updateDoc(string $id, array $data) : ResponsePOST {index}/_update/{id}.$datacontientdocouscript.deleteDoc(string $id) : ResponseDELETE {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 = []) : ResponsePOST {index}/_search.scroll(string|array $data = [], string $time = '1m') : ResponseRecherche longue : un tableau lance la recherche, une chaîne (scroll id) demande la page suivante.clearScroll(string $id) : ResponseSupprime une recherche longue.bulk(array $data) : ResponsePOST _bulk(endpoint global, en NDJSON). Chaque ligne d'action doit porter son_index.updateByQuery(array $data, bool $proceedOnConflicts = true) : ResponsePOST {index}/_update_by_query.deleteByQuery(array $data, bool $proceedOnConflicts = true) : ResponsePOST {index}/_delete_by_query.
Bas niveau
jsonRequest(string $method, string $url, array $data = []) : ResponseEnvoie$dataen JSON (aucun corps si$dataest vide).request(string $method, string $url, array $params) : ResponseAppel Guzzle brut avec ses options.lastRequest() : arrayLes 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() : intStatut HTTP (500 en cas d'erreur de transport).isSuccess() : boolStatut entre 200 et 208.body() : stringCorps brut.json() : arrayCorps décodé,[]s'il est vide ou invalide.took() : intTemps d'exécution en millisecondes.total() : intNombre total de résultats (hits.total.value, ou l'ancien entierhits.total).maxScore() : intMeilleur score. Attention : tronqué en entier (voirTODO.md).hits() : arrayLes hits bruts.sources() : arrayLes_sourceindexés par_id(nullsi_sourceest absent).scrollID() : ?stringLe_scroll_id.aggs(...$names)Résultat d'une agrégation : sesbuckets, sinon savalue, sinonnull. 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() : arrayRenvoie les données du composant. Les composants imbriqués restent des objets : on obtient le JSON final avecjson_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() : QueryAlternative au constructeur.set(string $key, $value) : staticAjoute ou remplace une clé.size(int $value = 10) : staticNombre de résultats.from(int $value = 0) : staticDécalage (offset).searchAfter(array $value) : staticNe renvoie que les résultats situés après ces valeurs de tri (pagination profonde).query(AbstractRaw $query) : staticLa requête.aggs(AbstractRaw $agg) : staticAjoute une agrégation (fusionnée avec les précédentes).sort(AbstractRaw $sort) : staticLe tri (remplace le précédent).highlight(AbstractRaw $highlight) : staticLe highlight.suggest(AbstractRaw $suggest) : staticLes suggestions.script(AbstractRaw $script) : staticUn script (pourupdateByQuery). UnScriptest déballé de sa cléscript.conflictsProceed() : staticAjouteconflicts: proceedau 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() : BoolQueryAlternative au constructeur.must(AbstractRaw $raw) : staticAjoute une clausemust.should(AbstractRaw $raw) : staticAjoute une clauseshould.filter(AbstractRaw $raw) : staticAjoute une clausefilter.mustNot(AbstractRaw $raw) : staticAjoute une clausemust_not.minimumSouldMatch(int $value) : staticminimum_should_match(le nom contient une faute de frappe).boost(float $value) : staticRenforce 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.