Subversion Repositories eFlore/Applications.del

Compare Revisions

Ignore whitespace Rev 1489 → Rev 1490

/trunk/services/modules/0.1/observations/ListeObservations.php
19,14 → 19,7
* Sphinx pour auteur, genre, ns, commune, tag et masque-général
*/
 
define('_LISTE_OBS_MAX_RESULT_LIMIT', 1000);
define('_LISTE_OBS_MAX_ID_OBS', 10e7);
// SELECT MAX(num_taxonomique) FROM bdtfx_v2_00;
define('_LISTE_OBS_MAX_BDTFX_NT', 1000000); // 44378 + 1000
// SELECT MAX(num_nom) FROM bdtfx_v2_00;
define('_LISTE_OBS_MAX_BDTFX_NN', 1000000); // 120816 + 10000
 
require_once(dirname(__FILE__) . '/../images/ListeImages2.php');
require_once(dirname(__FILE__) . '/../DelTk.php');
/*
restore_error_handler();
restore_exception_handler();
43,19 → 36,7
 
static $tris_possibles = array('date_observation');
// paramètres autorisés
static $parametres_autorises = array('masque', 'masque.famille', 'masque.nn', 'masque.referentiel', // taxon
'masque.genre', 'masque.espece', 'masque.ns', // nom_sel
'masque.commune', 'masque.departement', 'masque.id_zone_geo', // loc
'masque.auteur', 'masque.date', 'masque.tag', 'masque.type', // autres
// tri, offset
'navigation.depart', 'navigation.limite',
'tri', 'ordre', // TODO: 'total=[yes]', 'fields=[x,y,...]'
// TODO: masque.annee, masque.insee (!= departement)
);
 
static $default_params = array('navigation.depart' => 0, 'navigation.limite' => 10,
'tri' => 'date_transmission', 'ordre' => 'desc');
 
static $sql_fields_liaisons = array(
'dob' => array('id_observation', 'nom_sel AS `determination.ns`', 'nt AS `determination.nt`',
'nom_sel_nn AS `determination.nn`', 'famille AS `determination.famille`',
103,52 → 84,6
return $obs_merged;
}
 
// utilisée uniquement par Observation.php
static function reformateObservationSimpleIndex($obs, $url_pattern = '') {
// XXX: cf Observation.php::consulter(), nous pourriouns ici
// conserver les valeurs vides (pour les phptests notamment, ou non)
$obs = array_map('array_filter', $obs);
$obs_merged = array();
foreach($obs as $o) {
$id = $o['id_observation'];
$image = array_intersect_key($o, array_flip(array('id_image', 'date', 'hauteur' , 'largeur', 'nom_original')));
$image['binaire.href'] = sprintf($url_pattern, $image['id_image']);
unset($o['id_image'], $o['date'], $o['hauteur'], $o['largeur'], $o['nom_original']);
if(!isset($obs_merged[$id])) $obs_merged[$id] = $o;
$obs_merged[$id]['images'][$image['id_image']] = $image;
}
return $obs_merged;
}
 
 
// utilisée uniquement par ListeImages.php
static function reformateImagesDoubleIndex($obs, $url_pattern = '', $image_format = 'XL') {
// XXX: cf Observation.php::consulter(), nous pourriouns ici
// conserver les valeurs vides (pour les phptests notamment, ou non)
// $obs = array_map('array_filter', $obs);
$obs_merged = $obs_keyed_by_id_image = array();
foreach($obs as $o) {
// ceci nous complique la tâche pour le reste du processing...
$id = $o['jsonindex'];
// ainsi nous utilisons deux tableaux: le final, indexé par couple d'id(image-obs)
// et celui indexé par simple id_image qui est fort utile pour mapVotesToImages()
// mais tout deux partage leur référence à "protocole"
$image = array(
'id_image' => $o['id_image'],
'binaire.href' => sprintf($url_pattern, $o['id_image'], $image_format),
'mots_cles_texte' => @$o['i_mots_cles_texte'], // @, peut avoir été filtré par array_map() ci-dessus
);
unset($o['id_image'], $o['i_mots_cles_texte'], $o['jsonindex']);
if(!isset($obs_merged[$id])) $obs_merged[$id] = $image;
$obs_merged[$id]['observation'] = $o;
$obs_merged[$id]['protocoles_votes'] = array();
$obs_keyed_by_id_image[$image['id_image']]['protocoles_votes'] = &$obs_merged[$id]['protocoles_votes'];
}
 
return array($obs_merged,$obs_keyed_by_id_image);
}
 
/**
* Méthode principale de la classe.
* Lance la récupération des images dans la base et les place dans un objet ResultatService
167,14 → 102,15
$db = $this->bdd;
 
// filtrage de l'INPUT
$params = self::requestFilterParams($parametres, self::$parametres_autorises, $this->conteneur);
$params = DelTk::requestFilterParams($parametres, DelTk::$parametres_autorises, $this->conteneur);
 
$params['masque.tag'] = ListeImages2::buildTagsAST(@$parametres['masque.tag'], 'OR', ',');
$params['masque.tag'] = DelTk::buildTagsAST(@$parametres['masque.tag'], 'OR', ',');
 
// ... et paramètres par défaut
$params = array_merge(self::$default_params, $params);
$params = array_merge(DelTk::$default_params, $params);
 
// création des contraintes (masques)
DelTk::sqlAddConstraint($params, $db, $req);
self::sqlAddConstraint($params, $db, $req, $this->conteneur);
self::sqlAddMasqueConstraint($params, $db, $req, $this->conteneur);
 
208,7 → 144,7
 
// 6) JSON output
$resultat = new ResultatService();
$resultat->corps = array('entete' => self::makeJSONHeader($total, $params, Config::get('url_service')),
$resultat->corps = array('entete' => DelTk::makeJSONHeader($total, $params, Config::get('url_service')),
'resultats' => $observations);
return $resultat;
294,108 → 230,14
 
 
/**
* - Rempli le tableau des contraintes "where" et "join" nécessaire
* à la *recherche* des observations demandées ($req) utilisées par self::getIdObs()
* Complément à DelTk::sqlAddConstraint()
*
* Attention, cela signifie que toutes les tables ne sont pas *forcément*
* join'ées, par exemple si aucune contrainte ne le nécessite.
* $req tel qu'il est rempli ici est utile pour récupéré la seule liste des
* id d'observation qui match.
* Pour la récupération effective de "toutes" les données correspondante, il faut
* réinitialiser $req["join"] afin d'y ajouter toutes les autres tables.
*
* Note: toujours rajouter les préfixes de table (vdi,du,doi ou di), en fonction de ce que défini
* les JOIN qui sont utilisés.
* le préfix de v_del_image est "vdi" (cf: "FROM" de self::getIdObs())
* le préfix de del_utilisateur sur id_utilisateur = vdi.ce_utilisateur est "du"
*
* @param $p les paramètres (notamment de masque) passés par l'URL et déjà traités/filtrés (sauf quotes)
* @param $p les paramètres (notamment de masque) passés par l'URL et déjà traités/filtrés (sauf quotes)
* @param $req le tableau, passé par référence représentant les composants de la requête à bâtir
* @param $c conteneur, utilisé soit pour l'appel récursif à requestFilterParams() en cas de param "masque"
* soit pour la définition du type (qui utilise la variable nb_commentaires_discussion)
*/
static function sqlAddConstraint($p, $db, &$req, Conteneur $c = NULL) {
if(!empty($p['masque.auteur'])) {
// id du poster de l'obs
$req['join'][] = 'LEFT JOIN del_utilisateur AS du ON du.id_utilisateur = vdi.ce_utilisateur';
// id du poster de l'image... NON, c'est le même que le posteur de l'obs
// Cette jointure de table est ignoré ci-dessous pour les recherches d'auteurs
// $req['join'][] = 'LEFT JOIN del_utilisateur AS dui ON dui.id_utilisateur = vdi.i_ce_utilisateur';
 
if(is_numeric($p['masque.auteur'])) {
$req['where'][] = sprintf('(du.id_utilisateur = %1$d OR vdi.id_utilisateur = %1$d )', $p['masque.auteur']);
}
elseif(preg_match(';^.{5,}@[a-z0-9-.]{5,}$;i', $p['masque.auteur'])) {
$req['where'][] = sprintf('(du.courriel LIKE %1$s OR vdi.courriel LIKE %1$s )',
$db->proteger($p['masque.auteur'] . '%'));
}
else {
self::addAuteursConstraint($p['masque.auteur'], $db, $req['where']);
}
}
 
if(!empty($p['masque.date'])) {
if(is_integer($p['masque.date']) && $p['masque.date'] < 2030 && $p['masque.date'] > 1600) {
$req['where'][] = sprintf("YEAR(vdi.date_observation) = %d", $p['masque.date']);
}
else {
$req['where'][] = sprintf("DATE_FORMAT(vdi.date_observation, '%%Y-%%m-%%d') = %s",
$db->proteger(strftime('%Y-%m-%d', $p['masque.date'])));
}
}
 
// TODO: avoir des champs d'entrée distinct
if(!empty($p['masque.departement'])) {
$req['where'][] = sprintf("vdi.ce_zone_geo = %s", $db->proteger('INSEE-C:'.$p['masque.departement']));
}
if(!empty($p['masque.id_zone_geo'])) {
$req['where'][] = sprintf("vdi.ce_zone_geo = %s", $db->proteger($p['masque.id_zone_geo']));
}
if(!empty($p['masque.genre'])) {
$req['where'][] = 'vdi.nom_sel LIKE '.$db->proteger('%' . $p['masque.genre'].'% %');
}
if(!empty($p['masque.famille'])) {
$req['where'][] = 'vdi.famille = '.$db->proteger($p['masque.famille']);
}
if(!empty($p['masque.ns'])) {
$req['where'][] = 'vdi.nom_sel LIKE '.$db->proteger($p['masque.ns'].'%');
}
if(!empty($p['masque.nn'])) {
$req['where'][] = sprintf('vdi.nom_sel_nn = %1$d OR vdi.nom_ret_nn = %1$d', $p['masque.nn']);
}
if(!empty($p['masque.referentiel'])) {
$req['where'][] = sprintf('vdi.nom_referentiel LIKE %s', $db->proteger($p['masque.referentiel'].'%'));
}
if(!empty($p['masque.commune'])) {
$req['where'][] = 'vdi.zone_geo LIKE '.$db->proteger($p['masque.commune'].'%');
}
if(!empty($p['masque.tag'])) {
// TODO: remove LOWER() lorsqu'on est sur que les tags sont uniformés en minuscule
// i_mots_cles_texte provient de la VIEW v_del_image
if(isset($p['masque.tag']['AND'])) {
/* Lorsque nous interprêtons la chaîne provenant du masque général (cf: buildTagsAST($p['masque'], 'OR', ' ') dans sqlAddMasqueConstraint()),
nous sommes splittés par espace. Cependant, assurons que si une virgule à été saisie, nous n'aurons pas le motif
" AND CONCAT(mots_cles_texte, i_mots_cles_texte) REGEXP ',' " dans notre requête.
XXX: Au 12/11/2013, une recherche sur tag depuis le masque général implique un OU, donc le problème ne se pose pas ici */
$subwhere = array();
foreach($p['masque.tag']['AND'] as $tag) {
if(trim($tag) == ',') continue;
 
$subwhere[] = sprintf(
'LOWER(CONCAT(%s)) REGEXP %s',
self::sqlAddIfNullPourConcat(array('vdi.mots_cles_texte', 'vdi.i_mots_cles_texte')),
$db->proteger(strtolower($tag)));
}
$req['where'][] = '(' . implode(' AND ', $subwhere) . ')';
}
else {
$req['where'][] = sprintf(
'LOWER(CONCAT(%s)) REGEXP %s',
self::sqlAddIfNullPourConcat(array('vdi.mots_cles_texte', 'vdi.i_mots_cles_texte')),
$db->proteger(strtolower(implode('|', $p['masque.tag']['OR']))));
}
}
 
if(!empty($p['masque.type'])) {
self::addTypeConstraints($p['masque.type'], $db, $req, $c);
}
416,10 → 258,11
'masque.date' => $p['masque'],
'masque.genre' => $p['masque'],
/* milieu: TODO ? */ );
$or_masque = self::requestFilterParams($or_params, array_keys($or_params), $c);
$or_masque['masque.tag'] = ListeImages2::buildTagsAST($p['masque'], 'OR', ' ');
$or_masque = DelTk::requestFilterParams($or_params, array_keys($or_params), $c);
$or_masque['masque.tag'] = DelTk::buildTagsAST($p['masque'], 'OR', ' ');
// $or_req = array('select' => array(), 'join' => array(), 'where' => array(), 'groupby' => array(), 'having' => array());
$or_req = array('join' => array(), 'where' => array());
DelTk::sqlAddConstraint($or_masque, $db, $or_req);
self::sqlAddConstraint($or_masque, $db, $or_req);
 
if($or_req['where']) {
437,55 → 280,7
}
 
 
 
/* Lorsque l'on concatène des champs, un seul NULL prend le dessus,
Il faut donc utiliser la syntaxe IFNULL(%s, "").
(Cette fonction effectue aussi l'implode() "final" */
static function sqlAddIfNullPourConcat($tab) {
// XXX: PHP-5.3
return implode(',',array_map(create_function('$a', 'return "IFNULL($a, \"\")";'), $tab));
}
 
/*
Retourne une clausse where du style:
CONCAT(IF(du.prenom IS NULL, "", du.prenom), [...] vdi.i_nomutilisateur) REGEXP 'xxx'
Note; i_(nom|prenom_utilisateur), alias pour cel_images.(nom|prenom), n'est pas traité
car cette information est redondante dans cel_image et devrait être supprimée.
*/
static function addAuteursConstraint($val, $db, &$where) {
@list($a, $b) = explode(' ', $val, 2);
// un seul terme
$champs_n = array('du.prenom', // info user authentifié de l'obs depuis l'annuaire
'vdi.prenom_utilisateur', // info user anonyme de l'obs
/* 'vdi.i_prenom_utilisateur' */ ); // info user anonyme de l'image
$champs_p = array('du.nom', // idem pour le nom
'vdi.nom_utilisateur',
/* 'vdi.i_nom_utilisateur' */ );
 
/*
Note: pour l'heure, étant donnés:
- les CONVERT() de la VIEW del_utilisateur
- DEFAULT CHARSET=latin1 pour tela_prod_v4.annuaire_tela
- DEFAULT CHARSET=utf8 pour tb_cel.cel_obs
et l'âge du capitaine...
- REGEXP est case-sensitive, et collate les caractères accentués
- LIKE est case-insensitive, et collate les caractères accentués
*/
if(! $b) {
$where[] = sprintf('CONCAT(%s,%s) LIKE %s',
self::sqlAddIfNullPourConcat($champs_n),
self::sqlAddIfNullPourConcat($champs_p),
$db->proteger("%".$val."%"));
}
else {
$where[] = sprintf('(CONCAT(%1$s,%2$s) LIKE %3$s AND CONCAT(%1$s,%2$s) LIKE %4$s)',
self::sqlAddIfNullPourConcat($champs_n),
self::sqlAddIfNullPourConcat($champs_p),
$db->proteger("%" . $a . "%"), $db->proteger("%" . $b . "%"));
}
}
 
/*
* @param $req: la représentation de la requête MySQL complète, à amender.
*/
static function addTypeConstraints($val, $db, &$req, Conteneur $c) {
603,180 → 398,4
}
return $retour;
}
 
 
// supprime l'index du tableau des paramètres si sa valeur ne correspond pas
// au spectre passé par $values.
static function unsetIfInvalid(&$var, $index, $values) {
if(array_key_exists($index, $var)) {
if(!in_array($var[$index], $values)) unset($var[$index]);
else return $var[$index];
}
return NULL;
}
 
 
/* Filtre et valide les paramètres reconnus. Effectue *toute* la sanitization *sauf* l'escape-string
Cette fonction est appelée:
- une fois sur les champs de recherche avancées
- une fois sur le masque général si celui-ci à été spécifié. Dans ce cas,
la chaîne générale saisie est utilisée comme valeur pour chacun des champs particuliers
avec les traitements particuliers qui s'imposent
Par exemple: si l'on cherche "Languedoc", cela impliquera:
WHERE (nom_sel like "Languedoc" OR nom_ret ... OR ...) mais pas masque.date ou masque.departement
qui s'assure d'un pattern particulier */
static function requestFilterParams(Array $params, $parametres_autorises = NULL, Conteneur $c = NULL /* pour la récup des départements */ ) {
if($parametres_autorises) { // filtrage de toute clef inconnue
$params = array_intersect_key($params, array_flip($parametres_autorises));
}
 
$p['tri'] = self::unsetIfInvalid($params, 'tri', array('date_observation'));
$p['ordre'] = self::unsetIfInvalid($params, 'ordre', array('asc','desc'));
$p['masque.referentiel'] = self::unsetIfInvalid($params, 'masque.referentiel', array('bdtfx','bdtxa','isfan'));
 
// TODO: use filter_input(INPUT_GET);
// renvoie FALSE ou NULL si absent ou invalide
$p['navigation.limite'] = filter_var(@$params['navigation.limite'],
FILTER_VALIDATE_INT,
array('options' => array('default' => NULL,
'min_range' => 1,
'max_range' => _LISTE_OBS_MAX_RESULT_LIMIT)));
$p['navigation.depart'] = filter_var(@$params['navigation.depart'],
FILTER_VALIDATE_INT,
array('options' => array('default' => NULL,
'min_range' => 0,
'max_range' => _LISTE_OBS_MAX_ID_OBS)));
if(isset($params['masque.departement'])) {
// STRING: 0 -> 95, 971 -> 976, 2A + 2B (./services/configurations/config_departements_bruts.ini)
// accept leading 0 ?
// TODO; filter patterns like 555.
if(preg_match(';^(\d{2}|\d{3}|2a|2b)$;i', $params['masque.departement'])) {
$p['masque.departement'] = $params['masque.departement'];
}
// cf configurations/config_departements_bruts.ini
elseif( !is_null($c) && ( $x = $c->getParametre(
strtolower(str_replace(' ','-',iconv("UTF-8", "ASCII//TRANSLIT", $params['masque.departement'])))
))) {
$p['masque.departement'] = sprintf("INSEE-C:%02d___", $x);
}
}
 
if(isset($params['masque.date'])) {
// une année, TODO: masque.annee
if(is_numeric($params['masque.date'])) {
$p['masque.date'] = $params['masque.date'];
}
elseif(strpos($params['masque.date'], '/' !== false) &&
($x = strtotime(str_replace('/','-',$params['masque.date'])))) {
$p['masque.date'] = $x;
}
elseif(strpos($params['masque.date'], '-' !== false) &&
($x = strtotime($params['masque.date'])) ) {
$p['masque.date'] = $x;
}
}
 
$p['masque.nn'] = filter_var(@$params['masque.nn'],
FILTER_VALIDATE_INT,
array('options' => array('default' => NULL,
'min_range' => 0,
'max_range' => _LISTE_OBS_MAX_BDTFX_NN)));
 
$p['masque.nt'] = filter_var(@$params['masque.nt'],
FILTER_VALIDATE_INT,
array('options' => array('default' => NULL,
'min_range' => 0,
'max_range' => _LISTE_OBS_MAX_BDTFX_NT)));
 
 
// TODO: should we really trim() ?
 
if(isset($params['masque.ns'])) $p['masque.ns'] = trim($params['masque.ns']);
// if(isset($params['masque.texte'])) $p['masque.texte'] = trim($params['masque.texte']);
 
if(isset($params['masque.famille'])) {
// mysql -N<<<"SELECT DISTINCT famille FROM bdtfx_v1_02;"|sed -r "s/(.)/\1\n/g"|sort -u|tr -d "\n"
$p['masque.famille'] = preg_replace('/[^a-zA-Z %_]/', '', iconv("UTF-8",
"ASCII//TRANSLIT",
$params['masque.famille']));
}
 
// masque.genre est un alias pour masque.ns (nom_sel), mais permet de rajouter une clause supplémentaire
// sur nom_sel. Précédemment: WHERE nom_sel LIKE '%<masque.genre>% %'.
// Désormais masque.genre doit être intégralement spécifié, les caractères '%' et '_' seront interprétés.
// Attention toutefois car la table del_observation intègre des nom_sel contenant '_'
if(isset($params['masque.genre'])) $p['masque.genre'] = trim($params['masque.genre']);
if(isset($params['masque.ns'])) $p['masque.ns'] = trim($params['masque.ns']);
// masque.espece n'était pas déclaré dans la "where" mais utilisé via config + switch//default
if(isset($params['masque.espece'])) $p['masque.espece'] = trim($params['masque.espece']);
 
// idem pour id_zone_geo qui mappait à ce_zone_geo:
if(isset($params['masque.id_zone_geo']) && preg_match(';^(INSEE-C:\d{5}|\d{2})$;', $params['masque.id_zone_geo'])) {
$p['masque.id_zone_geo'] = $params['masque.id_zone_geo'];
}
 
// masque.commune (zone_geo)
// TODO: que faire avec des '%' en INPUT ?
// Le masque doit *permettre* une regexp et non l'imposer. Charge au client de faire son travail
if(isset($params['masque.commune'])) $p['masque.commune'] = str_replace(array('-',' '), '_', $params['masque.commune']);
 
// masque.auteur: peut-être un id, un courriel, ou un nom ou prénom, ...
if(isset($params['masque.auteur'])) $p['masque.auteur'] = trim($params['masque.auteur']);
// sera trimmé plus tard, cf sqlAddConstraint
if(isset($params['masque'])) $p['masque'] = trim($params['masque']);
 
// masque.tag, idem que pour masque.genre et masque.commune
if(isset($params['masque.tag'])) {
$x = explode(',',$params['masque.tag']);
$x = array_map('trim', $x);
$p['masque.tag'] = implode('|', array_filter($x));
}
 
// masque.type: ['adeterminer', 'aconfirmer', 'endiscussion', 'validees']
if(isset($params['masque.type'])) {
$p['masque.type'] = array_flip(array_intersect(array_filter(explode(';', $params['masque.type'])),
array('adeterminer', 'aconfirmer', 'endiscussion', 'validees')));
}
 
 
// TODO: masque (général)
 
 
// on filtre les NULL, FALSE et '', mais pas les 0, d'où le callback()
// TODO: PHP-5.3
return array_filter($p, create_function('$a','return !in_array($a, array("",false,null),true);'));
}
 
static function makeJSONHeader($total, $params, $url_service) {
$prev_url = $next_url = NULL;
$url_service_sans_slash = substr($url_service, 0, -1);
 
// aplatissons les params! - une seule couche cela dit, après débrouillez-vous
$params_a_plat = $params;
foreach ($params_a_plat as $cle_plate => $pap) {
if (is_array($pap)) {
$params_a_plat[$cle_plate] = implode(array_keys($pap), ',');
}
}
 
$next_offset = $params['navigation.depart'] + $params['navigation.limite'];
if($next_offset < $total) {
$next_url = $url_service_sans_slash . '?' . http_build_query(array_merge($params_a_plat, array('navigation.depart' => $next_offset)));
}
 
$prev_offset = $params['navigation.depart'] - $params['navigation.limite'];
if($prev_offset >= 0) {
$prev_url = $url_service_sans_slash . '?' . http_build_query(array_merge($params_a_plat, array('navigation.depart' => $prev_offset)));
}
 
return array(
'masque' => http_build_query(array_diff_key($params, array_flip(array('navigation.depart', 'navigation.limite')))),
'total' => $total,
'depart' => $params['navigation.depart'],
'limite' => $params['navigation.limite'],
'href.precedent' => $prev_url,
'href.suivant' => $next_url
);
}
}
/trunk/services/modules/0.1/observations/Observation.php
16,7 → 16,7
 
// http://localhost/del/services/0.1/observations/#id => une observation donnée et ses images, SANS LES propositions & nombre de commentaire
 
require_once(dirname(__FILE__) . '/ListeObservations.php');
require_once(dirname(__FILE__) . '/../DelTk.php');
 
class Observation {
 
157,11 → 157,11
 
// 2) réassocie les images "à plat" à leur observation (merge)
// TODO: appliquer le formattage dépendant de la configuration en fin de processus
$observations = ListeObservations::reformateObservationSimpleIndex($liaisons, $this->conteneur->getParametre('url_images'));
// bien que dans notre cas il n'y est qu'une seule observation, issu de plusieurs images
$observations = self::reformateObservationSimpleIndex($liaisons, $this->conteneur->getParametre('url_images'));
// bien que dans notre cas il n'y ait qu'une seule observation, issue de plusieurs images
// dans $liaisons, $observation est un tableau (cf reformateObservation).
// Considérons la chose comme telle au cas où le webservice doivent demain demander une paire
// d'observations (... convergence ListeObservations)
// d'observations (... convergence avec ListeObservations & DelTk)
 
$observation = array_pop($observations);
 
197,9 → 197,9
static function chargerObservation($db, $idobs) {
// prenom_utilisateur, nom_utilisateur, courriel_utilisateur sont exclus du mapping
// car nous utilisons une construction SQL élaborée pour eux (cf IFNULL ci-dessous)
$obs_fields = self::sqlFieldsToAlias(self::$mappings['observations'], NULL, 'dob');
$obs_fields = DelTk::sqlFieldsToAlias(self::$mappings['observations'], NULL, 'dob');
 
$image_fields = self::sqlFieldsToAlias(self::$mappings['images'], NULL, 'dob');
$image_fields = DelTk::sqlFieldsToAlias(self::$mappings['images'], NULL, 'dob');
 
// champs de l'annuaire (del_utilisateur): id_utilisateur prenom, nom, courriel
$annuaire_fields = implode(', ', array("IFNULL(du.prenom, prenom_utilisateur) AS `auteur.prenom`",
221,8 → 221,8
array('id_vote', 'ce_image', 'ce_protocole', 'ce_utilisateur', 'valeur', 'date', /* del_image_vote */),
'protocole' =>
array('id_protocole', 'intitule', 'descriptif', 'tag' /* del_image_protocole */ ));
$vote_fields = self::sqlFieldsToAlias(self::$mappings['votes'], $select['votes'], 'v'); // "v": cf alias dans la requête
$proto_fields = self::sqlFieldsToAlias(self::$mappings['protocoles'], $select['protocole'], 'p');
$vote_fields = DelTk::sqlFieldsToAlias(self::$mappings['votes'], $select['votes'], 'v'); // "v": cf alias dans la requête
$proto_fields = DelTk::sqlFieldsToAlias(self::$mappings['protocoles'], $select['protocole'], 'p');
$where = array();
$where[] = sprintf('v.ce_image IN (%s)',
284,8 → 284,8
'ce_utilisateur', 'utilisateur_prenom', 'utilisateur_nom', 'utilisateur_courriel',
'nom_sel', 'nom_sel_nn', 'nom_ret', 'nom_ret_nn', 'nt', 'famille', 'nom_referentiel', 'date',
'proposition_initiale'));
$vote_fields = self::sqlFieldsToAlias(self::$mappings['votes'], $select['votes'], 'cv'); // "v": cf alias dans la requête
$comment_fields = self::sqlFieldsToAlias(self::$mappings['commentaires'], $select['commentaires'], 'dc');
$vote_fields = DelTk::sqlFieldsToAlias(self::$mappings['votes'], $select['votes'], 'cv'); // "v": cf alias dans la requête
$comment_fields = DelTk::sqlFieldsToAlias(self::$mappings['commentaires'], $select['commentaires'], 'dc');
 
$commentaires = $db->recupererTous(sprintf(
"SELECT %s, %s FROM del_commentaire as dc".
326,22 → 326,22
$observation['commentaires'] = $ret;
}
 
// cf ListeObservation::reformateObservation() et ListeImages2::reformateImagesDoubleIndex()
// (trop de variétés de formatage, à unifier côté client pour unifier côté backend ...)
static function reformateObservationSimpleIndex($obs, $url_pattern = '') {
// XXX: cf Observation.php::consulter(), nous pourriouns ici
// conserver les valeurs vides (pour les phptests notamment, ou non)
$obs = array_map('array_filter', $obs);
$obs_merged = array();
foreach($obs as $o) {
$id = $o['id_observation'];
$image = array_intersect_key($o, array_flip(array('id_image', 'date', 'hauteur' , 'largeur', 'nom_original')));
$image['binaire.href'] = sprintf($url_pattern, $image['id_image']);
unset($o['id_image'], $o['date'], $o['hauteur'], $o['largeur'], $o['nom_original']);
if(!isset($obs_merged[$id])) $obs_merged[$id] = $o;
$obs_merged[$id]['images'][$image['id_image']] = $image;
}
return $obs_merged;
}
 
/* SQL helper
Converti un tableau associatif et un préfix optionnel en une chaîne de champs adéquate
à un SELECT MySQL.
$select (optionnel) restreint les champs mappés aux valeurs de $select.
Si $select n'est pas fourni, toutes les clefs présentes dans $map seront présentes dans
le SELECT en sortie */
static function sqlFieldsToAlias($map, $select = NULL, $prefix = NULL) {
if($select) $arr = array_intersect_key($map, array_flip($select));
else $arr = $map;
$keys = array_keys($arr);
 
if($prefix) array_walk($keys, create_function('&$val, $k, $prefix', '$val = sprintf("%s.`%s`", $prefix, $val);'), $prefix);
else array_walk($keys, create_function('&$val, $k', '$val = sprintf("`%s`", $val);'));
 
return implode(', ', array_map(create_function('$v, $k', 'return sprintf("%s AS `%s`", $k, $v);'), $arr, $keys));
}
}
/trunk/services/modules/0.1/DelTk.php
New file
0,0 → 1,479
<?php
/**
* DEL (Détermination en ligne [Pictoflora/Identiplante]) Toolkit
* Quelques fonctions utiles, utilisées et/ou utilisables aussi bien par images/*, observations/*
* et probablement d'autres, comme determination/*.
*
* Les domaines des fonctions tournent autour de 4 aspects:
* - gestions des paramètres d'entrée utilisateurs, valeurs par défaut et sanitization
* - génération de SQL
* - processing de tableau de pattern d'utilisation SQL assez commun
* - formattage basique de sortie (JSON)
* + quelques helpers basiques
*
* @category php 5.2
* @package del
* @author Raphaël Droz <raphael@tela-botanica.org>
* @copyright Copyright (c) 2013 Tela Botanica (accueil@tela-botanica.org)
* @license http://www.cecill.info/licences/Licence_CeCILL_V2-fr.txt Licence CECILL
* @license http://www.gnu.org/licenses/gpl.html Licence GNU-GPL
*/
 
 
define('_LISTE_OBS_MAX_RESULT_LIMIT', 1000);
define('_LISTE_OBS_MAX_ID_OBS', 10e7);
// SELECT MAX(num_taxonomique) FROM bdtfx_v2_00;
define('_LISTE_OBS_MAX_BDTFX_NT', 1000000); // 44378 + 1000
// SELECT MAX(num_nom) FROM bdtfx_v2_00;
define('_LISTE_OBS_MAX_BDTFX_NN', 1000000); // 120816 + 10000
 
class DelTk {
static $parametres_autorises = array(
'masque', 'masque.famille', 'masque.nn', 'masque.referentiel', // taxon
'masque.genre', 'masque.espece', 'masque.ns', // nom_sel
'masque.commune', 'masque.departement', 'masque.id_zone_geo', // loc
'masque.auteur', 'masque.date', 'masque.tag', 'masque.type', // autres
// tri, offset
'navigation.depart', 'navigation.limite',
'tri', 'ordre', // TODO: 'total=[yes]', 'fields=[x,y,...]'
// TODO: masque.annee, masque.insee (!= departement)
);
 
static $default_params = array(
'navigation.depart' => 0, 'navigation.limite' => 10,
'tri' => 'date_transmission', 'ordre' => 'desc');
 
 
// input filtering
 
 
/* Construit un (vulgaire) abstract syntax tree:
"AND" => [ "tag1", "tag2" ]
Idéalement (avec un parser simple comme proposé par http://hoa-project.net/Literature/Hack/Compiler.html#Langage_PP)
nous aurions:
"AND" => [ "tag1", "tag2", "OR" => [ "tag3", "tag4" ] ]
 
Ici nous devons traiter les cas suivants:
tags séparés par des "ET/AND OU/OR", séparés par des espaces ou des virgules.
Mais la chaîne peut aussi avoir été issue du "masque général" (la barre de recherche générique).
ce qui implique des comportement par défaut différents afin de préserver la compatibilité.
 
Théorie:
1) tags passés par "champ tag":
- support du ET/OU, et explode par virgule.
- si pas d'opérande détectée: "OU"
 
2) tags passés par "recherche générale":
- support du ET/OU, et explode par whitespace.
- si pas d'opérande détectée: "ET"
 
La présence de $additional_sep s'explique car ET/OU sous-entendent une séparation par des espaces.
Mais ce n'est pas toujours pertinent car: 1) la compatibilité suggère de considérer parfois
la virgule comme séparateur et 2) les tags *peuvent* contenir des espaces. Par conséquent:
* a,b,c => "a" $default_op "b" $default_op "c"
* a,b AND c => "a" AND "b" AND "c"
* a OR b AND c,d => "a" AND "b" AND "c" AND "d"
C'est à dire par ordre décroissant de priorité:
1) opérande contenu dans la chaîne
2) opérande par défaut
3) les séparateurs présents sont substitués par l'opérande déterminée par 1) ou 2)
 
// TODO: support des parenthèses, imbrications & co: "(", ")"
// http://codehackit.blogspot.fr/2011/08/expression-parser-in-php.html
// http://blog.angeloff.name/post/2012/08/05/php-recursive-patterns/
 
@param $str: la chaîne à "parser"
@param $default_op: "AND" ou "OR"
@param $additional_sep: séparateur de mots:
*/
static function buildTagsAST($str = NULL, $default_op, $additional_sep = ',') {
if(!$str) return;
$words = preg_split('/ (OR|AND|ET|OU) /', $str, -1, PREG_SPLIT_NO_EMPTY);
 
if(preg_match('/\b(ET|AND)\b/', $str)) $op = 'AND';
elseif(preg_match('/\b(OU|OR)\b/', $str)) $op = 'OR';
else $op = $default_op;
 
if($additional_sep) {
array_walk($words,
create_function('&$v, $k, $sep', '$v = preg_split("/".$sep."/", $v, -1, PREG_SPLIT_NO_EMPTY);'),
$additional_sep);
}
$words = DelTk::array_flatten($words);
$words = array_map('trim', $words);
return array($op => array_filter($words));
}
 
 
static function array_flatten($arr) {
$arr = array_values($arr);
while (list($k,$v)=each($arr)) {
if (is_array($v)) {
array_splice($arr,$k,1,$v);
next($arr);
}
}
return $arr;
}
 
// supprime l'index du tableau des paramètres si sa valeur ne correspond pas
// au spectre passé par $values.
static function unsetIfInvalid(&$var, $index, $values) {
if(array_key_exists($index, $var)) {
if(!in_array($var[$index], $values)) unset($var[$index]);
else return $var[$index];
}
return NULL;
}
 
 
 
 
/* Filtre et valide les paramètres reconnus. Effectue *toute* la sanitization *sauf* l'escape-string
Cette fonction est appelée:
- une fois sur les champs de recherche avancées
- une fois sur le masque général si celui-ci à été spécifié. Dans ce cas,
la chaîne générale saisie est utilisée comme valeur pour chacun des champs particuliers
avec les traitements particuliers qui s'imposent
Par exemple: si l'on cherche "Languedoc", cela impliquera:
WHERE (nom_sel like "Languedoc" OR nom_ret ... OR ...) mais pas masque.date ou masque.departement
qui s'assure d'un pattern particulier */
static function requestFilterParams(Array $params, $parametres_autorises = NULL, Conteneur $c = NULL /* pour la récup des départements */ ) {
if($parametres_autorises) { // filtrage de toute clef inconnue
$params = array_intersect_key($params, array_flip($parametres_autorises));
}
 
$p['tri'] = DelTK::unsetIfInvalid($params, 'tri', array('date_observation'));
$p['ordre'] = DelTK::unsetIfInvalid($params, 'ordre', array('asc','desc'));
$p['masque.referentiel'] = DelTK::unsetIfInvalid($params, 'masque.referentiel', array('bdtfx','bdtxa','isfan'));
 
// TODO: use filter_input(INPUT_GET);
// renvoie FALSE ou NULL si absent ou invalide
$p['navigation.limite'] = filter_var(@$params['navigation.limite'],
FILTER_VALIDATE_INT,
array('options' => array('default' => NULL,
'min_range' => 1,
'max_range' => _LISTE_OBS_MAX_RESULT_LIMIT)));
$p['navigation.depart'] = filter_var(@$params['navigation.depart'],
FILTER_VALIDATE_INT,
array('options' => array('default' => NULL,
'min_range' => 0,
'max_range' => _LISTE_OBS_MAX_ID_OBS)));
if(isset($params['masque.departement'])) {
// STRING: 0 -> 95, 971 -> 976, 2A + 2B (./services/configurations/config_departements_bruts.ini)
// accept leading 0 ?
// TODO; filter patterns like 555.
if(preg_match(';^(\d{2}|\d{3}|2a|2b)$;i', $params['masque.departement'])) {
$p['masque.departement'] = $params['masque.departement'];
}
// cf configurations/config_departements_bruts.ini
elseif( !is_null($c) && ( $x = $c->getParametre(
strtolower(str_replace(' ','-',iconv("UTF-8", "ASCII//TRANSLIT", $params['masque.departement'])))
))) {
$p['masque.departement'] = sprintf("INSEE-C:%02d___", $x);
}
}
 
if(isset($params['masque.date'])) {
// une année, TODO: masque.annee
if(is_numeric($params['masque.date'])) {
$p['masque.date'] = $params['masque.date'];
}
elseif(strpos($params['masque.date'], '/' !== false) &&
($x = strtotime(str_replace('/','-',$params['masque.date'])))) {
$p['masque.date'] = $x;
}
elseif(strpos($params['masque.date'], '-' !== false) &&
($x = strtotime($params['masque.date'])) ) {
$p['masque.date'] = $x;
}
}
 
$p['masque.nn'] = filter_var(@$params['masque.nn'],
FILTER_VALIDATE_INT,
array('options' => array('default' => NULL,
'min_range' => 0,
'max_range' => _LISTE_OBS_MAX_BDTFX_NN)));
 
$p['masque.nt'] = filter_var(@$params['masque.nt'],
FILTER_VALIDATE_INT,
array('options' => array('default' => NULL,
'min_range' => 0,
'max_range' => _LISTE_OBS_MAX_BDTFX_NT)));
 
 
// TODO: should we really trim() ?
 
if(isset($params['masque.ns'])) $p['masque.ns'] = trim($params['masque.ns']);
// if(isset($params['masque.texte'])) $p['masque.texte'] = trim($params['masque.texte']);
 
if(isset($params['masque.famille'])) {
// mysql -N<<<"SELECT DISTINCT famille FROM bdtfx_v1_02;"|sed -r "s/(.)/\1\n/g"|sort -u|tr -d "\n"
$p['masque.famille'] = preg_replace('/[^a-zA-Z %_]/', '', iconv("UTF-8",
"ASCII//TRANSLIT",
$params['masque.famille']));
}
 
// masque.genre est un alias pour masque.ns (nom_sel), mais permet de rajouter une clause supplémentaire
// sur nom_sel. Précédemment: WHERE nom_sel LIKE '%<masque.genre>% %'.
// Désormais masque.genre doit être intégralement spécifié, les caractères '%' et '_' seront interprétés.
// Attention toutefois car la table del_observation intègre des nom_sel contenant '_'
if(isset($params['masque.genre'])) $p['masque.genre'] = trim($params['masque.genre']);
if(isset($params['masque.ns'])) $p['masque.ns'] = trim($params['masque.ns']);
// masque.espece n'était pas déclaré dans la "where" mais utilisé via config + switch//default
if(isset($params['masque.espece'])) $p['masque.espece'] = trim($params['masque.espece']);
 
// idem pour id_zone_geo qui mappait à ce_zone_geo:
if(isset($params['masque.id_zone_geo']) && preg_match(';^(INSEE-C:\d{5}|\d{2})$;', $params['masque.id_zone_geo'])) {
$p['masque.id_zone_geo'] = $params['masque.id_zone_geo'];
}
 
// masque.commune (zone_geo)
// TODO: que faire avec des '%' en INPUT ?
// Le masque doit *permettre* une regexp et non l'imposer. Charge au client de faire son travail
if(isset($params['masque.commune'])) $p['masque.commune'] = str_replace(array('-',' '), '_', $params['masque.commune']);
 
// masque.auteur: peut-être un id, un courriel, ou un nom ou prénom, ...
if(isset($params['masque.auteur'])) $p['masque.auteur'] = trim($params['masque.auteur']);
// sera trimmé plus tard, cf sqlAddConstraint
if(isset($params['masque'])) $p['masque'] = trim($params['masque']);
 
// masque.tag, idem que pour masque.genre et masque.commune
if(isset($params['masque.tag'])) {
$x = explode(',',$params['masque.tag']);
$x = array_map('trim', $x);
$p['masque.tag'] = implode('|', array_filter($x));
}
 
// masque.type: ['adeterminer', 'aconfirmer', 'endiscussion', 'validees']
if(isset($params['masque.type'])) {
$p['masque.type'] = array_flip(array_intersect(array_filter(explode(';', $params['masque.type'])),
array('adeterminer', 'aconfirmer', 'endiscussion', 'validees')));
}
 
 
// TODO: masque (général)
 
 
// on filtre les NULL, FALSE et '', mais pas les 0, d'où le callback()
// TODO: PHP-5.3
return array_filter($p, create_function('$a','return !in_array($a, array("",false,null),true);'));
}
 
 
 
// SQL helpers
 
/* Lorsque l'on concatène des champs, un seul NULL prend le dessus,
Il faut donc utiliser la syntaxe IFNULL(%s, "").
(Cette fonction effectue aussi l'implode() "final" */
static function sqlAddIfNullPourConcat($tab) {
// XXX: PHP-5.3
return implode(',',array_map(create_function('$a', 'return "IFNULL($a, \"\")";'), $tab));
}
 
 
 
/* Converti un tableau associatif et un préfix optionnel en une chaîne de champs adéquate
à un SELECT MySQL.
$select (optionnel) restreint les champs mappés aux valeurs de $select.
Si $select n'est pas fourni, toutes les clefs présentes dans $map seront présentes dans
le SELECT en sortie */
static function sqlFieldsToAlias($map, $select = NULL, $prefix = NULL) {
if($select) $arr = array_intersect_key($map, array_flip($select));
else $arr = $map;
$keys = array_keys($arr);
 
if($prefix) array_walk($keys, create_function('&$val, $k, $prefix', '$val = sprintf("%s.`%s`", $prefix, $val);'), $prefix);
else array_walk($keys, create_function('&$val, $k', '$val = sprintf("`%s`", $val);'));
 
return implode(', ', array_map(create_function('$v, $k', 'return sprintf("%s AS `%s`", $k, $v);'), $arr, $keys));
}
 
 
 
/*
Retourne une clause where du style:
CONCAT(IF(du.prenom IS NULL, "", du.prenom), [...] vdi.i_nomutilisateur) REGEXP 'xxx'
Note; i_(nom|prenom_utilisateur), alias pour cel_images.(nom|prenom), n'est pas traité
car cette information est redondante dans cel_image et devrait être supprimée.
*/
static function addAuteursConstraint($val, $db, &$where) {
@list($a, $b) = explode(' ', $val, 2);
// un seul terme
$champs_n = array('du.prenom', // info user authentifié de l'obs depuis l'annuaire
'vdi.prenom_utilisateur', // info user anonyme de l'obs
/* 'vdi.i_prenom_utilisateur' */ ); // info user anonyme de l'image
$champs_p = array('du.nom', // idem pour le nom
'vdi.nom_utilisateur',
/* 'vdi.i_nom_utilisateur' */ );
 
/*
Note: pour l'heure, étant donnés:
- les CONVERT() de la VIEW del_utilisateur
- DEFAULT CHARSET=latin1 pour tela_prod_v4.annuaire_tela
- DEFAULT CHARSET=utf8 pour tb_cel.cel_obs
et l'âge du capitaine...
- REGEXP est case-sensitive, et collate les caractères accentués
- LIKE est case-insensitive, et collate les caractères accentués
*/
if(! $b) {
$where[] = sprintf('CONCAT(%s,%s) LIKE %s',
DelTk::sqlAddIfNullPourConcat($champs_n),
DelTk::sqlAddIfNullPourConcat($champs_p),
$db->proteger("%".$val."%"));
}
else {
$where[] = sprintf('(CONCAT(%1$s,%2$s) LIKE %3$s AND CONCAT(%1$s,%2$s) LIKE %4$s)',
DelTk::sqlAddIfNullPourConcat($champs_n),
DelTk::sqlAddIfNullPourConcat($champs_p),
$db->proteger("%" . $a . "%"), $db->proteger("%" . $b . "%"));
}
}
 
 
 
 
 
/**
* - Rempli le tableau des contraintes "where" et "join" nécessaire
* à la *recherche* des observations demandées ($req) utilisées par self::getIdObs()
*
* Attention, cela signifie que toutes les tables ne sont pas *forcément*
* join'ées, par exemple si aucune contrainte ne le nécessite.
* $req tel qu'il est rempli ici est utile pour récupéré la seule liste des
* id d'observation qui match.
* Pour la récupération effective de "toutes" les données correspondante, il faut
* réinitialiser $req["join"] afin d'y ajouter toutes les autres tables.
*
* Note: toujours rajouter les préfixes de table (vdi,du,doi ou di), en fonction de ce que défini
* les JOIN qui sont utilisés.
* le préfix de v_del_image est "vdi" (cf: "FROM" de self::getIdObs())
* le préfix de del_utilisateur sur id_utilisateur = vdi.ce_utilisateur est "du"
*
* @param $p les paramètres (notamment de masque) passés par l'URL et déjà traités/filtrés (sauf quotes)
* @param $req le tableau, passé par référence représentant les composants de la requête à bâtir
*/
static function sqlAddConstraint($p, $db, &$req) {
if(!empty($p['masque.auteur'])) {
// id du poster de l'obs
$req['join'][] = 'LEFT JOIN del_utilisateur AS du ON du.id_utilisateur = vdi.ce_utilisateur';
// id du poster de l'image... NON, c'est le même que le posteur de l'obs
// Cette jointure de table est ignoré ci-dessous pour les recherches d'auteurs
// $req['join'][] = 'LEFT JOIN del_utilisateur AS dui ON dui.id_utilisateur = vdi.i_ce_utilisateur';
 
if(is_numeric($p['masque.auteur'])) {
$req['where'][] = sprintf('(du.id_utilisateur = %1$d OR vdi.id_utilisateur = %1$d)', $p['masque.auteur']);
}
elseif(preg_match(';^.{5,}@[a-z0-9-.]{5,}$;i', $p['masque.auteur'])) {
$req['where'][] = sprintf('(du.courriel LIKE %1$s OR vdi.courriel LIKE %1$s )',
$db->proteger($p['masque.auteur'] . '%'));
}
else {
DelTk::addAuteursConstraint($p['masque.auteur'], $db, $req['where']);
}
}
 
if(!empty($p['masque.date'])) {
if(is_integer($p['masque.date']) && $p['masque.date'] < 2030 && $p['masque.date'] > 1600) {
$req['where'][] = sprintf("YEAR(vdi.date_observation) = %d", $p['masque.date']);
}
else {
$req['where'][] = sprintf("DATE_FORMAT(vdi.date_observation, '%%Y-%%m-%%d') = %s",
$db->proteger(strftime('%Y-%m-%d', $p['masque.date'])));
}
}
 
// TODO: avoir des champs d'entrée distinct
if(!empty($p['masque.departement'])) {
$req['where'][] = sprintf("vdi.ce_zone_geo = %s", $db->proteger('INSEE-C:'.$p['masque.departement']));
}
if(!empty($p['masque.id_zone_geo'])) {
$req['where'][] = sprintf("vdi.ce_zone_geo = %s", $db->proteger($p['masque.id_zone_geo']));
}
if(!empty($p['masque.genre'])) {
$req['where'][] = 'vdi.nom_sel LIKE '.$db->proteger('%' . $p['masque.genre'].'% %');
}
if(!empty($p['masque.famille'])) {
$req['where'][] = 'vdi.famille = '.$db->proteger($p['masque.famille']);
}
if(!empty($p['masque.ns'])) {
$req['where'][] = 'vdi.nom_sel LIKE '.$db->proteger($p['masque.ns'].'%');
}
if(!empty($p['masque.nn'])) {
$req['where'][] = sprintf('vdi.nom_sel_nn = %1$d OR vdi.nom_ret_nn = %1$d', $p['masque.nn']);
}
if(!empty($p['masque.referentiel'])) {
$req['where'][] = sprintf('vdi.nom_referentiel LIKE %s', $db->proteger($p['masque.referentiel'].'%'));
}
if(!empty($p['masque.commune'])) {
$req['where'][] = 'vdi.zone_geo LIKE '.$db->proteger($p['masque.commune'].'%');
}
if(!empty($p['masque.tag'])) {
// TODO: remove LOWER() lorsqu'on est sur que les tags sont uniformés en minuscule
// i_mots_cles_texte provient de la VIEW v_del_image
if(isset($p['masque.tag']['AND'])) {
/* Lorsque nous interprêtons la chaîne provenant du masque général (cf: buildTagsAST($p['masque'], 'OR', ' ') dans sqlAddMasqueConstraint()),
nous sommes splittés par espace. Cependant, assurons que si une virgule à été saisie, nous n'aurons pas le motif
" AND CONCAT(mots_cles_texte, i_mots_cles_texte) REGEXP ',' " dans notre requête.
XXX: Au 12/11/2013, une recherche sur tag depuis le masque général implique un OU, donc le problème ne se pose pas ici */
$subwhere = array();
foreach($p['masque.tag']['AND'] as $tag) {
if(trim($tag) == ',') continue;
 
$subwhere[] = sprintf(
'LOWER(CONCAT(%s)) REGEXP %s',
DelTk::sqlAddIfNullPourConcat(array('vdi.mots_cles_texte', 'vdi.i_mots_cles_texte')),
$db->proteger(strtolower($tag)));
}
$req['where'][] = '(' . implode(' AND ', $subwhere) . ')';
}
else {
$req['where'][] = sprintf(
'LOWER(CONCAT(%s)) REGEXP %s',
DelTk::sqlAddIfNullPourConcat(array('vdi.mots_cles_texte', 'vdi.i_mots_cles_texte')),
$db->proteger(strtolower(implode('|', $p['masque.tag']['OR']))));
}
}
}
 
 
 
 
 
 
 
// formatage de réponse HTTP
static function makeJSONHeader($total, $params, $url_service) {
$prev_url = $next_url = NULL;
$url_service_sans_slash = substr($url_service, 0, -1);
 
// aplatissons les params! - une seule couche cela dit, après débrouillez-vous
$params_a_plat = $params;
foreach ($params_a_plat as $cle_plate => $pap) {
if (is_array($pap)) {
$params_a_plat[$cle_plate] = implode(array_keys($pap), ',');
}
}
 
$next_offset = $params['navigation.depart'] + $params['navigation.limite'];
if($next_offset < $total) {
$next_url = $url_service_sans_slash . '?' . http_build_query(array_merge($params_a_plat, array('navigation.depart' => $next_offset)));
}
 
$prev_offset = $params['navigation.depart'] - $params['navigation.limite'];
if($prev_offset >= 0) {
$prev_url = $url_service_sans_slash . '?' . http_build_query(array_merge($params_a_plat, array('navigation.depart' => $prev_offset)));
}
 
return array(
'masque' => http_build_query(array_diff_key($params, array_flip(array('navigation.depart', 'navigation.limite')))),
'total' => $total,
'depart' => $params['navigation.depart'],
'limite' => $params['navigation.limite'],
'href.precedent' => $prev_url,
'href.suivant' => $next_url
);
}
 
}
/trunk/services/modules/0.1/images/ListeImages2.php
22,8 → 22,8
* (cf requestFilterParams())
*
* Histoire: auparavant (pré-r142x) un AVG + GROUP BY étaient utilisés pour générer on-the-fly les valeurs
* utilsées ensuite pour l'ORDER BY. La situation à base de del_image_stat
* est déjà bien meilleur sans être pour autant optimale. cf commentaire de sqlAddConstraint()
* utilisées ensuite pour l'ORDER BY. La situation à base de del_image_stat
* est déjà bien meilleure sans être pour autant optimale. cf commentaire de sqlAddConstraint()
*
*
* Tags:
47,7 → 47,7
* (http://www.mysqlperformanceblog.com/2007/04/06/using-delayed-join-to-optimize-count-and-limit-queries/)
* - éviter de dépendre d'une jointure systématique sur `cel_obs`, uniquement pour `(date_)transmission
* (cf VIEW del_image)
* - réorganiser les méthodes statiques parmis Observation, ListeObservations et ListImages2
* - poursuivre la réorganisation des méthodes statiques parmis Observation, ListeObservations et ListImages2
* - *peut-être*: passer requestFilterParams() en méthode de classe
*
*
65,6 → 65,7
*
*/
 
require_once(dirname(__FILE__) . '/../DelTk.php');
require_once(dirname(__FILE__) . '/../observations/ListeObservations.php');
require_once(dirname(__FILE__) . '/../observations/Observation.php');
restore_error_handler();
83,7 → 84,7
 
static $tri_possible = array('date_transmission', 'date_observation', 'votes', 'tags');
 
// en plus de ceux dans ListeObservations
// en plus de ceux dans DelTk
static $parametres_autorises = array('protocole', 'masque.tag_cel', 'masque.tag_pictoflora', 'masque.milieu');
 
static $default_params = array('navigation.depart' => 0, 'navigation.limite' => 10,
151,32 → 152,35
// ($parametres_autorises) sauf... masque.type qui fait des modif' de WHERE sur les mots-clefs.
// Évitons ce genre de chose pour PictoFlora et les risques de conflits avec masque.tag
// même si ceux-ci sont improbables (pas d'<input> pour cela).
$params_ip = ListeObservations::requestFilterParams($parametres,
array_diff(ListeObservations::$parametres_autorises,
$params_ip = DelTk::requestFilterParams($parametres,
array_diff(DelTk::$parametres_autorises,
array('masque.type')),
$this->conteneur);
 
// notre propre filtrage sur l'INPUT
$params_pf = self::requestFilterParams($parametres,
array_merge(ListeObservations::$parametres_autorises,
array_merge(DelTk::$parametres_autorises,
self::$parametres_autorises));
 
/* filtrage des tags + sémantique des valeurs multiples:
Lorsqu'on utilise masque.tag* pour chercher des tags, ils sont
postulés comme séparés par des virgule, et l'un au moins des tags doit matcher. */
$params_pf['masque.tag_cel'] = self::buildTagsAST(@$parametres['masque.tag_cel'], 'OR', ',');
$params_pf['masque.tag_pictoflora'] = self::buildTagsAST(@$parametres['masque.tag_pictoflora'], 'OR', ',');
$params_pf['masque.tag_cel'] = DelTk::buildTagsAST(@$parametres['masque.tag_cel'], 'OR', ',');
$params_pf['masque.tag_pictoflora'] = DelTk::buildTagsAST(@$parametres['masque.tag_pictoflora'], 'OR', ',');
 
$params = array_merge(ListeObservations::$default_params, // paramètre par défaut Identiplante
self::$default_params, // paramètres par défaut PictoFlora
$params_ip, // les paramètres passés, traités par Identiplante
$params_pf); // les paramètres passés, traités par PictoFlora
$params = array_merge(
DelTk::$default_params, // paramètre par défaut Identiplante
self::$default_params, // paramètres par défaut PictoFlora
$params_ip, // les paramètres passés, traités par Identiplante
$params_pf); // les paramètres passés, traités par PictoFlora
 
// XXX: temp tweak
/* $this->conteneur->setParametre('url_images', sprintf($this->conteneur->getParametre('url_images'),
"%09d", $params['format']));*/
 
// création des contraintes (génériques, de ListeObservations)
// création des contraintes (génériques de DelTk)
DelTk::sqlAddConstraint($params, $db, $req);
// création des contraintes héritées de Identiplante (TODO: needed ??)
ListeObservations::sqlAddConstraint($params, $db, $req, $this->conteneur);
// création des contraintes spécifiques (sur les tags essentiellement)
self::sqlAddConstraint($params, $db, $req, $this->conteneur);
192,7 → 196,7
// Ce n'est pas la peine de continuer s'il n'y a pas eu de résultats dans la table del_obs_images
if(!$idobs_tab) {
$resultat = new ResultatService();
$resultat->corps = array('entete' => ListeObservations::makeJSONHeader(0, $params, Config::get('url_service')),
$resultat->corps = array('entete' => DelTk::makeJSONHeader(0, $params, Config::get('url_service')),
'resultats' => array());
return $resultat;
/*
216,7 → 220,7
$images[$i] = $o->consulter(array($i), array('justthrow' => 1));
}
*/
list($images, $images_keyed_by_id_image) = ListeObservations::reformateImagesDoubleIndex(
list($images, $images_keyed_by_id_image) = self::reformateImagesDoubleIndex(
$liaisons,
$this->conteneur->getParametre('url_images'),
$params['format']);
237,7 → 241,7
$params_header = array_merge($params, array_filter(array('masque.tag_cel' => @$parametres['masque.tag_cel'],
'masque.tag_pictoflora' => @$parametres['masque.tag_pictoflora'])));
$resultat = new ResultatService();
$resultat->corps = array('entete' => ListeObservations::makeJSONHeader($total, $params_header, Config::get('url_service')),
$resultat->corps = array('entete' => DelTk::makeJSONHeader($total, $params_header, Config::get('url_service')),
'resultats' => $images);
return $resultat;
}
292,7 → 296,7
*
*/
static function sqlAddConstraint($p, $db, &$req, Conteneur $c = NULL) {
// TODO implement dans ListeObservations ?
// TODO implement dans DelTk ?
if(!empty($p['masque.milieu'])) {
$req['where'][] = 'vdi.milieu LIKE '.$db->proteger('%' . $p['masque.milieu'].'%');
}
440,8 → 444,8
}
 
static function chargerImages($db, $idImg) {
$obs_fields = Observation::sqlFieldsToAlias(self::$mappings['observations'], NULL);
$image_fields = Observation::sqlFieldsToAlias(self::$mappings['images'], NULL);
$obs_fields = DelTk::sqlFieldsToAlias(self::$mappings['observations'], NULL);
$image_fields = DelTk::sqlFieldsToAlias(self::$mappings['images'], NULL);
return $db->recupererTous(sprintf('SELECT '.
' CONCAT(id_image, "-", id_observation) AS jsonindex,'.
484,17 → 488,18
'ordre' => $p['ordre']);
 
$or_masque = array_merge(
ListeObservations::requestFilterParams($or_params, NULL, $c /* pour masque.departement */),
DelTk::requestFilterParams($or_params, NULL, $c /* pour masque.departement */),
self::requestFilterParams($or_params));
 
/* Lorsqu'on utilise le masque général pour chercher des tags, ils sont
postulés comme séparés par des espaces, et doivent être tous matchés. */
$or_masque['masque.tag_cel'] = self::buildTagsAST($p['masque'], 'AND', ' ');
$or_masque['masque.tag_pictoflora'] = self::buildTagsAST($p['masque'], 'AND', ' ');
$or_masque['masque.tag_cel'] = DelTk::buildTagsAST($p['masque'], 'AND', ' ');
$or_masque['masque.tag_pictoflora'] = DelTk::buildTagsAST($p['masque'], 'AND', ' ');
 
 
// pas de select, groupby & co ici: uniquement 'join' et 'where'
$or_req = array('join' => array(), 'where' => array());
DelTk::sqlAddConstraint($or_masque, $db, $or_req);
ListeObservations::sqlAddConstraint($or_masque, $db, $or_req);
self::sqlAddConstraint($or_masque, $db, $or_req);
 
506,7 → 511,39
}
}
 
// complete & override ListeObservations::requestFilterParams() (même usage)
 
// cf Observation::reformateObservationSimpleIndex() et ListeObservations::reformateObservation()
// (trop de variétés de formatage, à unifier côté client pour unifier côté backend ...)
static function reformateImagesDoubleIndex($obs, $url_pattern = '', $image_format = 'XL') {
// XXX: cf Observation.php::consulter(), nous pourriouns ici
// conserver les valeurs vides (pour les phptests notamment, ou non)
// $obs = array_map('array_filter', $obs);
$obs_merged = $obs_keyed_by_id_image = array();
foreach($obs as $o) {
// ceci nous complique la tâche pour le reste du processing...
$id = $o['jsonindex'];
// ainsi nous utilisons deux tableaux: le final, indexé par couple d'id(image-obs)
// et celui indexé par simple id_image qui est fort utile pour mapVotesToImages()
// mais tout deux partage leur référence à "protocole"
$image = array(
'id_image' => $o['id_image'],
'binaire.href' => sprintf($url_pattern, $o['id_image'], $image_format),
'mots_cles_texte' => @$o['i_mots_cles_texte'], // @, peut avoir été filtré par array_map() ci-dessus
);
unset($o['id_image'], $o['i_mots_cles_texte'], $o['jsonindex']);
if(!isset($obs_merged[$id])) $obs_merged[$id] = $image;
$obs_merged[$id]['observation'] = $o;
$obs_merged[$id]['protocoles_votes'] = array();
$obs_keyed_by_id_image[$image['id_image']]['protocoles_votes'] = &$obs_merged[$id]['protocoles_votes'];
}
 
return array($obs_merged,$obs_keyed_by_id_image);
}
 
 
 
// complete & override DelTk::requestFilterParams() (même usage)
static function requestFilterParams(Array $params, $parametres_autorises = NULL) {
if($parametres_autorises) { // filtrage de toute clef inconnue
$params = array_intersect_key($params, array_flip($parametres_autorises));
513,8 → 550,8
}
 
$p = array();
$p['tri'] = ListeObservations::unsetIfInvalid($params, 'tri', self::$tri_possible);
$p['format'] = ListeObservations::unsetIfInvalid($params, 'format', self::$format_image_possible);
$p['tri'] = DelTk::unsetIfInvalid($params, 'tri', self::$tri_possible);
$p['format'] = DelTk::unsetIfInvalid($params, 'format', self::$format_image_possible);
 
// "milieu" inutile pour IdentiPlantes ?
if(isset($params['masque.milieu'])) $p['masque.milieu'] = trim($params['masque.milieu']);
536,64 → 573,7
}
 
 
/* Construit un (vulgaire) abstract syntax tree:
"AND" => [ "tag1", "tag2" ]
Idéalement (avec un parser simple comme proposé par http://hoa-project.net/Literature/Hack/Compiler.html#Langage_PP)
nous aurions:
"AND" => [ "tag1", "tag2", "OR" => [ "tag3", "tag4" ] ]
 
Ici nous devons traiter les cas suivants:
tags séparés par des "ET/AND OU/OR", séparés par des espaces ou des virgules.
Mais la chaîne peut aussi avoir été issue du "masque général" (la barre de recherche générique).
ce qui implique des comportement par défaut différents afin de préserver la compatibilité.
 
Théorie:
1) tags passés par "champ tag":
- support du ET/OU, et explode par virgule.
- si pas d'opérande détectée: "OU"
 
2) tags passés par "recherche générale":
- support du ET/OU, et explode par whitespace.
- si pas d'opérande détectée: "ET"
 
La présence de $additional_sep s'explique car ET/OU sous-entendent une séparation par des espaces.
Mais ce n'est pas toujours pertinent car: 1) la compatibilité suggère de considérer parfois
la virgule comme séparateur et 2) les tags *peuvent* contenir des espaces. Par conséquent:
* a,b,c => "a" $default_op "b" $default_op "c"
* a,b AND c => "a" AND "b" AND "c"
* a OR b AND c,d => "a" AND "b" AND "c" AND "d"
C'est à dire par ordre décroissant de priorité:
1) opérande contenu dans la chaîne
2) opérande par défaut
3) les séparateurs présents sont substitués par l'opérande déterminée par 1) ou 2)
 
// TODO: support des parenthèses, imbrications & co: "(", ")"
// http://codehackit.blogspot.fr/2011/08/expression-parser-in-php.html
// http://blog.angeloff.name/post/2012/08/05/php-recursive-patterns/
 
@param $str: la chaîne à "parser"
@param $default_op: "AND" ou "OR"
@param $additional_sep: séparateur de mots:
*/
static function buildTagsAST($str = NULL, $default_op, $additional_sep = ',') {
if(!$str) return;
$words = preg_split('/ (OR|AND|ET|OU) /', $str, -1, PREG_SPLIT_NO_EMPTY);
 
if(preg_match('/\b(ET|AND)\b/', $str)) $op = 'AND';
elseif(preg_match('/\b(OU|OR)\b/', $str)) $op = 'OR';
else $op = $default_op;
 
if($additional_sep) {
array_walk($words,
create_function('&$v, $k, $sep', '$v = preg_split("/".$sep."/", $v, -1, PREG_SPLIT_NO_EMPTY);'),
$additional_sep);
}
$words = self::array_flatten($words);
$words = array_map('trim', $words);
return array($op => array_filter($words));
}
 
 
// met à jour *toutes* les stats de nombre de tags et de moyenne des votes
static function _update_statistics($db) {
$db->requeter("TRUNCATE TABLE del_image_stat");
616,15 → 596,4
static function revOrderBy($orderby) {
return $orderby == 'asc' ? 'desc' : 'asc';
}
 
static function array_flatten($arr) {
$arr = array_values($arr);
while (list($k,$v)=each($arr)) {
if (is_array($v)) {
array_splice($arr,$k,1,$v);
next($arr);
}
}
return $arr;
}
}