<?php
declare (strict_types = 1);
/*
* Copyright (C) Office National de Publication et de Communication - All Rights Reserved
*
* Unauthorized copying of this file, via any medium is strictly prohibited by law
* This file is proprietary and confidential
*/
namespace App\Elasticsearch;
use Elastica\Type;
use Elastica\Query;
use Elastica\Aggregation;
use Elastica\Query\MultiMatch;
use App\Elasticsearch\Contexte;
use Elastica\Query\MatchPhrase;
use App\Elasticsearch\Parametres;
use Knp\Component\Pager\PaginatorInterface;
use FOS\ElasticaBundle\Finder\TransformedFinder;
use Symfony\Component\HttpFoundation\RequestStack;
use Knp\Bundle\PaginatorBundle\Pagination\SlidingPagination;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
final class Moteur
{
private const GRAINE_INDEX = __CLASS__ . '_graine';
private const MAX_RESULT_WINDOW = 1000;
private const PER_PAGE_DEFAULT = 30;
public const PER_PAGE_DEFAULT_2 = SELF::PER_PAGE_DEFAULT; // J'ai en créé une autre par sousis de retro-compatibilité
/** @var TransformedFinder */
private $finder;
/** @var Type */
private $index;
/** @var Paginator */
private $paginateur;
/** @var RequestStack */
private $requestStack;
/** @var SessionInterface */
private $session;
/** @var Query\BoolQuery */
private $filtre;
public function __construct(
TransformedFinder $finder,
Type $index,
PaginatorInterface $paginateur,
RequestStack $requestStack,
SessionInterface $session
) {
$this->finder = $finder;
$this->index = $index;
$this->paginateur = $paginateur;
$this->requestStack = $requestStack;
$this->session = $session;
}
// ----- Operations --------------------------------------------------------
public function cherche(Parametres $parametres): SlidingPagination
{
// Pour les grandes villes, réduire le rayon de recherche
@$lieu = $parametres->lieuSlug;
if ($lieu) {
list($lieu) = explode("-", $lieu);
// $isGrandeVille = \in_array($lieu, ['paris', 'lyon', 'marseille']);
switch ($lieu) {
case 'lyon':
$parametres->villesCoordonneesDistance = 5;
break;
case 'paris':
case 'marseille':
$parametres->villesCoordonneesDistance = 6;
break;
}
}
do {
$query = $this->buildFilteredQuery($parametres);
$query->addSort($this->tri($parametres));
$resultats = $this->finder->createPaginatorAdapter($query);
// Verifie si on est hors les résultats
$totalPages = (int) ceil($resultats->getTotalHits() / $parametres->maxParPage);
if ($parametres->pageActuele > $totalPages) {
return $this->paginateur->paginate(
[],
$parametres->pageActuele,
$parametres->maxParPage > 0 ? $parametres->maxParPage : self::PER_PAGE_DEFAULT
);
}
/** @var SlidingPagination $pagination */
$pagination = $this->paginateur->paginate(
$resultats,
$parametres->pageActuele,
$parametres->maxParPage > 0 ? $parametres->maxParPage : self::PER_PAGE_DEFAULT
);
} while (0 === $pagination->getTotalItemCount() && $parametres->relax());
$pagination->setTotalItemCount($totalPages*$parametres->maxParPage);
return $pagination;
}
public function createContext(Parametres $parametres): Contexte
{
$contexte = new Contexte($parametres->quoiSlug, $parametres->lieuSlug);
$query = $this->buildFilteredQuery($parametres);
$query->addSort($this->tri($parametres));
$result = $this->index->search($query);
foreach ($result as $item) {
$contexte->ids[] = $item->getId();
}
return $contexte;
}
// Renvoi la liste des Ids uniques des villes du contexte de recherche établissements actuel
public function getIdsVille(Parametres $parametres): array
{
$aggregation = new Aggregation\Terms('ville');
$aggregation->setField('ville');
$aggregation->setSize(self::MAX_RESULT_WINDOW);
$query = $this->buildFilteredQuery($parametres);
$query->addAggregation($aggregation);
$ids = [];
$result = $this->index->search($query)->getAggregation('ville');
foreach ($result['buckets'] as $bucket) {
$ids[] = $bucket['key'];
}
return $ids;
}
// Renvoi la liste des Ids uniques des filtres/diplomes
public function getIdsFiltres(Parametres $parametres): array
{
$aggregation = new Aggregation\Terms('filtresArray');
$aggregation->setField('filtresArray');
$aggregation->setSize(self::MAX_RESULT_WINDOW);
$query = $this->buildFilteredQuery($parametres);
$query->addAggregation($aggregation);
$ids = [];
$result = $this->index->search($query)->getAggregation('filtresArray');
foreach ($result['buckets'] as $bucket) {
$ids[] = $bucket['key'];
}
return $ids;
}
// liste des Ids uniques des LVA/LVB (lvx = lva ou lvb)
public function getIdsLVx(Parametres $parametres, string $lvx): array
{
$aggregation = new Aggregation\Terms($lvx);
$aggregation->setField($lvx);
$aggregation->setSize(self::MAX_RESULT_WINDOW);
$query = $this->buildFilteredQuery($parametres);
$query->addAggregation($aggregation);
$ids = [];
$result = $this->index->search($query)->getAggregation($lvx);
foreach ($result['buckets'] as $bucket) {
$ids[] = $bucket['key'];
}
return $ids;
}
// Renvoi la liste des Ids uniques des EenseignementsDeSpecialite
public function getIdsEenseignementsDeSpecialite(Parametres $parametres): array
{
$aggregation = new Aggregation\Terms('eds');
$aggregation->setField('eds');
$aggregation->setSize(self::MAX_RESULT_WINDOW);
$query = $this->buildFilteredQuery($parametres);
$query->addAggregation($aggregation);
$ids = [];
$result = $this->index->search($query)->getAggregation('eds');
foreach ($result['buckets'] as $bucket) {
$ids[] = $bucket['key'];
}
return $ids;
}
// Renvoi la liste des Ids uniques des typesInternat
public function getIdsTypesInternat(Parametres $parametres): array
{
$aggregation = new Aggregation\Terms('typesInternatKeyword');
$aggregation->setField('typesInternatKeyword');
$aggregation->setSize(self::MAX_RESULT_WINDOW);
$query = $this->buildFilteredQuery($parametres);
$query->addAggregation($aggregation);
$ids = [];
$result = $this->index->search($query)->getAggregation('typesInternatKeyword');
foreach ($result['buckets'] as $bucket) {
$ids[] = $bucket['key'];
}
return $ids;
}
public function count(Parametres $parametres): int
{
do {
$query = $this->buildFilteredQuery($parametres);
$count = (int) $this->index->count($query);
} while (0 === $count && $parametres->relax());
return $count;
}
// ----- Query builder -----------------------------------------------------
private function buildFilteredQuery(Parametres $parametres): Query
{
$this->filtre = new Query\BoolQuery();
// // Chercher uniquement les établissement
// $filter1 = new Query\Term();
// $filter1->setTerm('typeClient', 'établissement');
// $this->filtre->addMust($filter1);
// Recherche ...
if (!empty($parametres->typesInternat) || // par internats
!empty($parametres->formations) || // OU par formations
$parametres->filtresShouldMust == 'should' // OU par au moins un des filtres défini
) {
$this->filtre->setMinimumShouldMatch(1);
}
if ($parametres->participantsSeulement) {
$this->participantsSeulement();
}
$this->filtreEtablissement(
(array) $parametres->etablissementsExclues
);
// Filtre "texte"
$this->filtreTexte(
(string) $parametres->texte,
(string) $parametres->texteCorrespondance,
(string) $parametres->texteFlou
);
// Filtre "type"
$this->filtreType(
(array) $parametres->types,
(array) $parametres->typesExclues
);
// Filtres "localisation"
$this->filtreVille((array) $parametres->villes, (array) $parametres->villesCoordonnees, (int) $parametres->villesCoordonneesDistance);
$this->filtreLocalisation('departement', (array) $parametres->departements);
// $this->filtreLocalisation('region', (array) $parametres->regions);
$this->filtreGeoPolygones((array) $parametres->geoPolygones);
// Ne pas filtrer par régions dans certains onglets
// if ( empty($parametres->typesInternat) )
if ($parametres->lieuSlug != "default") {
$this->filtreLocalisation('region', (array) $parametres->regions);
}
// Chercher dans tt les régionss
if (null !== $parametres->nombreEleves) {
$this->nombreEleves($parametres->nombreEleves);
}
if (null !== $parametres->filtresEcole) {
$this->filtresEcole($parametres->filtresEcole);
}
if (null !== $parametres->filtres) {
//$this->filtres((array) $parametres->filtres);
$this->filtres2('filtres', (array) $parametres->filtres, $parametres->filtresShouldMust);
}
if (null !== $parametres->formations) {
$this->formations((array) $parametres->formations);
}
if (null !== $parametres->typesInternat) {
$this->typesInternat((array) $parametres->typesInternat);
}
if (null !== $parametres->ageAdmission && 0 != $parametres->ageAdmission) {
$this->ageAdmission($parametres->ageAdmission);
}
if (null !== $parametres->catholique) {
$this->catholique($parametres->catholique);
}
if (null !== $parametres->sousContrat) {
$this->sousContrat($parametres->sousContrat);
}
if (null !== $parametres->alternance) {
$this->alternance($parametres->alternance);
}
if (null !== $parametres->lva || null !== $parametres->lvb) {
$this->langues($parametres->lva, $parametres->lvb);
}
if ( !empty($parametres->eds) ) {
$this->enseignementDeSpecialite($parametres->eds);
}
// Groupe les filtres et ajoute le tri
$query = new Query();
$query->setQuery($this->filtre);
$query->setSize(self::MAX_RESULT_WINDOW);
return $query;
}
/*
https://www.elastic.co/guide/en/elasticsearch/reference/6.1/query-dsl-terms-set-query.html
https://github.com/ruflin/Elastica
recherche sur deux champs
*/
private function participantsSeulement(): void
{
$participantTerm = new Query\Term();
$participantTerm->setTerm('participant', true);
$participantGestionnaireTerm = new Query\Term();
$participantGestionnaireTerm->setTerm('participantGestionnaire', true);
$clientQuery = new Query\BoolQuery();
$clientQuery->addShould($participantTerm);
$clientQuery->addShould($participantGestionnaireTerm);
$clientQuery->setMinimumShouldMatch(1);
$this->filtre->addMust($clientQuery);
}
private function filtreEtablissement(array $exclues): void
{
if ($exclues) {
$terms = new Query\Terms();
$terms->setTerms('_id', $exclues);
$this->filtre->addMustNot($terms);
}
}
private function filtreTexte(string $texte, string $correspondance, string $flou): void
{
// Le quoi est à "tout" quand on fait une recherche depuis l'annuaire catho/privée
if (!$texte || $texte == 'tout' || strpos($texte, '---') !== false) {
return;
}
$match = new Query\Match();
$match->setFieldQuery('texte', $texte);
$match->setFieldMinimumShouldMatch('texte', $correspondance);
$match->setFieldFuzziness('texte', $flou);
$this->filtre->addMust($match);
}
private function filtreType(array $incluses, array $exclues): void
{
if ($incluses) {
$terms = new Query\Terms();
$terms->setTerms('type', $incluses);
$this->filtre->addMust($terms);
}
if ($exclues) {
$terms = new Query\Terms();
$terms->setTerms('type', $exclues);
$this->filtre->addMustNot($terms);
}
}
private function filtreVille(array $ids, array $coordonnees, int $distance): void
{
if (!$ids && !$coordonnees) {
return;
}
$villeQuery = new Query\BoolQuery();
if ($ids) {
$terms = new Query\Terms();
$terms->setTerms('ville', $ids);
$villeQuery->addShould($terms);
}
// Recherche via les coordonnees
if ($coordonnees && $distance > 0) {
$geoQuery = new Query\BoolQuery();
$distanceInKm = sprintf('%dkm', $distance);
foreach ($coordonnees as $coordonnee) {
$geoDistance = new Query\GeoDistance('coordonnees', $coordonnee, $distanceInKm);
$geoQuery->addShould($geoDistance);
}
$geoQuery->setMinimumShouldMatch(1);
$villeQuery->addShould($geoQuery);
}
$villeQuery->setMinimumShouldMatch(1);
$this->filtre->addMust($villeQuery);
}
private function filtreLocalisation(string $champ, array $ids): void
{
if (!$ids) {
return;
}
$terms = new Query\Terms();
$terms->setTerms($champ, $ids);
$this->filtre->addMust($terms);
}
private function filtreGeoPolygones(array $points): void
{
if (!$points) {
return;
}
$query = new Query\GeoPolygon('coordonnees', $points);
$this->filtre->addFilter($query);
}
// Recherche par nbr d'élèves
// https://www.elastic.co/guide/en/elasticsearch/reference/6.1/query-dsl-range-query.html
private function nombreEleves(int $nombre): void
{
$range = new Query\Range('nombreEleves', [
'gte' => $nombre - 10,
'lte' => $nombre + 10,
]);
$this->filtre->addMust($range);
}
private function filtresEcole(): void
{
$payantTerm = new Query\Term();
$payantTerm->setTerm('filtreEcoleMixte', true);
$clientQuery = new Query\BoolQuery();
$clientQuery->addShould($payantTerm);
$this->filtre->addMust($clientQuery);
}
private function filtres2(string $champ, array $ids, $filtresShouldMust): void
{
if (!$ids) {
return;
}
foreach ($ids as $unFiltre) {
$query = new MatchPhrase();
$query->setFieldQuery('filtres', $unFiltre);
if ($filtresShouldMust == "should") {
$this->filtre->addShould($query);
}
// au moins une option de celles cochées
else {
$this->filtre->addMust($query);
}
// toutes les options
}
// foreach ($ids as $unFiltre) {
// $terms = new Query\Match();
// $terms->setFieldQuery('filtres', $unFiltre);
// $terms->setFieldMinimumShouldMatch('filtres', "75");
// $terms->setFieldFuzziness('filtres', "0.75");
// $this->filtre->addMust($terms);
// }
}
private function formations(array $formations): void
{
if (!$formations) {
return;
}
foreach ($formations as $unFiltre) {
$query = new MatchPhrase();
$query->setFieldQuery('formations', $unFiltre);
$this->filtre->addShould($query); // au moins une option de celles cochées
}
// foreach ($formations as $forma) {
// $query = new MultiMatch();
// $query->setQuery($forma);
// $query->setFields(['formations']);
// $this->filtre->addShould($query); // au moins une option de celles cochées
// }
//
// $match = new Query\Match();
// $match->setFieldQuery('formations', $formations);
// //$match->setFieldMinimumShouldMatch('formations', $correspondance);
// //$match->setFieldFuzziness('formations', $flou);
// //$match->setFieldFuzziness('formations', 'AUTO');
// $this->filtre->addMust($match);
}
// ----- Triage ------------------------------------------------------------
/**
* Construit le tri d'une recherche.
*
* Le tri dépend des paramètres utilisés dans la recherche. Voici le
* vocabulaire pour expliquer le contexte: "participant" = "droit.participant".
*
* L'ordre attendu est:
* 1. Les participants dans le filtre type/ville/département/region
* 2. Les participants hors le filtre type/ville/département/region
* 3. Les participants avec le forfait GX
* 4. Les non participants qu'acceptent le numéro telephone payant
* 5. Les non participants qui n'acceptent pas le numéro telephone payant
*/
private function tri(Parametres $parametres): array
{
return [
'_script' => [
'type' => 'string',
'order' => 'desc',
'script' => [
'lang' => 'painless',
'source' => "
String participant =
'true' == doc['participant'].value ?
'1' : '0';
String type =
false == Collections.disjoint(doc['type'], params.types) ?
'1' : '0';
String ville =
false == Collections.disjoint(doc['ville'], params.villes) ?
'1' : '0';
return
participant + '_' +
type + '_' +
ville + '_' +
(params.graine + doc['graine'].value).hashCode();
",
'params' => [
'graine' => $this->genereGraine($parametres),
'types' => $parametres->types,
'villes' => $parametres->villes,
],
],
],
];
}
public function genereGraine(Parametres $parametres): string
{
// La source du graine est le groupement d'un valeur aléatoire plus les
// paramètres de recherche, donc : (1) on part d'une copie de tout les
// paramètres, (2) suprimme les données volatiles/dynamiques, et (3) on
// injecte le valeur aléatoire unique par utilisateur & session.
$source = clone $parametres;
$source->pageActuele = null;
$source->maxParPage = null;
$source->graine = null;
$source->texte = $source->disableQuoi = null;
// if (!$this->requestStack->getSession()->has(self::GRAINE_INDEX)) {
// $this->requestStack->getSession()->set(self::GRAINE_INDEX, bin2hex(random_bytes(5)));
// }
// $source->graine = $this->requestStack->getSession()->get(self::GRAINE_INDEX);
if (!$this->session->has(self::GRAINE_INDEX)) {
$this->session->set(self::GRAINE_INDEX, bin2hex(random_bytes(5)));
}
$source->graine = $this->session->get(self::GRAINE_INDEX);
return hash('crc32', serialize($source));
}
// Recherche par Type Internat
private function typesInternat(array $ids): void
{
if (!$ids) {
return;
}
// foreach ($ids as $forma) {
// $query = new MultiMatch();
// $query->setMinimumShouldMatch('1');
// $query->setQuery($forma);
// $query->setFields(['typesInternat']);
// $this->filtre->addShould($query); // au moins une option de celles cochées
// }
foreach ($ids as $unFiltre) {
$query = new MatchPhrase();
$query->setFieldQuery('typesInternat', $unFiltre);
$this->filtre->addShould($query); // au moins une option de celles cochées
// $this->filtre->addMust($query); // toutes les options
}
// $villeQuery = new Query\BoolQuery();
//
// if ($ids) {
// $terms = new Query\Terms();
// $terms->setTerms('typesInternat', $ids);
// $villeQuery->addShould($terms);
// }
// $villeQuery->setMinimumShouldMatch(1);
// $this->filtre->addMust($villeQuery);
}
// Recherche par age d'admission
// https://github.com/ruflin/Elastica/blob/master/test/Elastica/Query/RangeTest.php
private function ageAdmission(int $nombre): void
{
if ($nombre > 0) {
$range = new Query\Range('ageAdmission', [
'gte' => 3,
]);
} else {
$range = new Query\Range('ageAdmission', [
'lte' => 2.9,
]);
}
$this->filtre->addMust($range);
}
private function catholique($etat): void
{
$catholique = new Query\Term();
$catholique->setTerm('catholique', $etat);
$clientQuery = new Query\BoolQuery();
$clientQuery->addMust($catholique);
$this->filtre->addMust($clientQuery);
}
private function sousContrat($etat): void
{
$sousContrat = new Query\Term();
$sousContrat->setTerm('sousContrat', $etat);
$clientQuery = new Query\BoolQuery();
$clientQuery->addMust($sousContrat);
$this->filtre->addMust($clientQuery);
}
private function alternance($etat): void
{
$alternance = new Query\Term();
$alternance->setTerm('alternance', $etat);
$clientQuery = new Query\BoolQuery();
$clientQuery->addMust($alternance);
$this->filtre->addMust($clientQuery);
}
private function langues($lva, $lvb = null, $lvc = null): void
{
// Closure (fct anonyme) pour préparer la requete ES
$filtrePrepareQuery = function ($value) {
return '"' . $value . '"';
};
// v3 : query_string
// STATIC TEST OK :
// $qs = new Query\QueryString("lva:(\"LYC-LANG-LV1-ANG\" OR \"LYC-LANG-LV1-ESP\") AND lvb:(\"LYC-LANG-LV2-CHI\")");
// $this->filtre->addMust($qs);
$qs = null;
if ($lva) {
$arr = array_map($filtrePrepareQuery, $lva);
$s = implode(" OR ", $arr);
$qsA = sprintf("lva:(%s)", $s);
$qs = new Query\QueryString($qsA);
}
if ($lvb) {
$arr = array_map($filtrePrepareQuery, $lvb);
$s = implode(" OR ", $arr);
$qsB = sprintf("lvb:(%s)", $s);
$qs = new Query\QueryString($qsB);
}
if ($lva && $lvb) {
$qsAB = sprintf("%s AND %s", $qsA, $qsB);
$qs = new Query\QueryString($qsAB);
}
if ($qs) {
$this->filtre->addMust($qs);
// $this->filtre->setMinimumShouldMatch(0);
}
// v2
// $boolA = new Query\BoolQuery;
// foreach ($lva as $langue) {
// $query = new MatchPhrase();
// $query->setFieldQuery('lva', $langue);
// $boolA->addShould($query); // au moins une option de celles cochées
// }
// $this->filtre->addMust($boolA); // au moins une option de celles cochées
// if ($lvb) {
// $boolB = new Query\BoolQuery;
// foreach ($lvb as $langue) {
// $query = new MatchPhrase();
// $query->setFieldQuery('lvb', $langue);
// $boolB->addShould($query); // au moins une option de celles cochées
// }
// $this->filtre->addMust($boolB); // au moins une option de celles cochées
// }
// $this->filtre->setMinimumShouldMatch(0);
// $bool = new Query\BoolQuery;
// foreach ($lva as $langue) {
// $query = new MatchPhrase();
// $query->setFieldQuery('lva', $langue);
// $bool->addShould($query); // au moins une option de celles cochées
// }
// $this->filtre->addMust($bool); // au moins une option de celles cochées
// $q0 = new Query\BoolQuery();
// $q = array();
// foreach ($lva as $langue) {
// $qlang = new Query\MatchPhrase();
// $q[] = $qlang->setFieldQuery('lva', $langue);
// }
// $q0->addShould($q);
// $this->filtre->addMust($q0);
}
private function enseignementDeSpecialite($eds): void
{
// Closure (fct anonyme) pour préparer la requete ES
$filtrePrepareQuery = function ($value) {
return '"' . $value . '"';
};
$qs = null;
$arr = array_map($filtrePrepareQuery, $eds);
$s = implode(" AND ", $arr);
$qsA = sprintf("eds:(%s)", $s);
$qs = new Query\QueryString($qsA);
$this->filtre->addMust($qs);
}
}