Recherche insensible aux accents sur toutes les barres de recherche (publique + backoffice).

Ajoute une fonction SQLite UDF accfold() (app/src/AccentFolding.php) qui plie les caractères accentués latin vers leur base ASCII (é→e, ç→c, …), et l'applique à chaque condition de recherche côté colonne ET côté terme (accfold(column) LIKE accfold(:term)). Comme le runtime n'a ni intl (Normalizer) ni iconv, le repli passe par une map de translitération manuelle (Western Latin-1 + Latin Extended-A, NULL-safe).

Couvert :
- recherche publique (searchTheses / countSearchResults) : titre, sous-titre, synopsis, auteurs, promoteurs, tags ;
- popover étudiant (getThesesByAuthorName / getThesesForAuthors) : a.name ;
- recherche backoffice (getThesesList / getThesesListCount) : titre, sous-titre, identifiant, auteur.

L'UDF est enregistré dans Database::registerSqliteFunctions() (appelé par le constructeur et par le harnais de test TestDatabaseInstance). Évite aussi la dépréciation PHP 8.5 de PDO::sqliteCreateFunction() : la connexion est créée via \Pdo\Sqlite quand disponible (createFunction()) avec repli sur PDO (sqliteCreateFunction()) sur les anciens runtimes ; les tests passent de @dataProvider docblock à l'attribut #[DataProvider] (fin de la dépréciation PHPUnit). Détache .php-cs-fixer.cache (gitignoré) pour ne plus polluer le working copy.

294 tests PHPUnit verts sans dépréciation, phpstan niveau 5 OK.
This commit is contained in:
Pontoporeia
2026-09-18 16:26:36 +02:00
parent 0896c4b8c8
commit a31e4ab307
7 changed files with 341 additions and 32 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
/**
* AccentFolding — strips diacritical marks from UTF-8 strings so that
* accent-insensitive matching works (e.g. "Théophile" ≈ "Theophile",
* "é" ≈ "e", "ç" ≈ "c").
*
* The test/production servers do not have the `intl` extension (Normalizer)
* or `iconv`, so we fold precomposed accented characters through a lookup
* map rather than a Unicode normalizer.
*
* Usage:
* AccentFolding::fold('Théophile') // ⇒ "Theophile"
* AccentFolding::fold('sévère ça où') // ⇒ "severe ca ou"
*
* And on the SQL side the same folding is exposed as a SQLite user-defined
* function `accfold()` (registered in Database::__construct), so queries can
* match folded columns against folded terms.
*/
final class AccentFolding
{
/**
* Map of precomposed accented characters → base ASCII letter.
* Covers the accented Latin ranges used in French and Western European
* names (Western Latin-1 + Latin Extended-A). Upper-case accented letters
* fold to their lower-case base (e.g. É → e) — matching is case-
* insensitive at the SQL level via LIKE.
*/
private const MAP = [
// a
'à' => 'a','á' => 'a','â' => 'a','ã' => 'a','ä' => 'a','å' => 'a','ā' => 'a','ă' => 'a','ą' => 'a',
'À' => 'a','Á' => 'a','Â' => 'a','Ã' => 'a','Ä' => 'a','Å' => 'a','Ā' => 'a','Ă' => 'a','Ą' => 'a',
// c
'ç' => 'c','ć' => 'c','č' => 'c','ĉ' => 'c','ċ' => 'c',
'Ç' => 'c','Ć' => 'c','Č' => 'c','Ĉ' => 'c','Ċ' => 'c',
// d
'ď' => 'd','đ' => 'd','Ď' => 'd','Đ' => 'd',
// e
'è' => 'e','é' => 'e','ê' => 'e','ë' => 'e','ē' => 'e','ĕ' => 'e','ė' => 'e','ę' => 'e','ě' => 'e',
'È' => 'e','É' => 'e','Ê' => 'e','Ë' => 'e','Ē' => 'e','Ĕ' => 'e','Ė' => 'e','Ę' => 'e','Ě' => 'e',
// g
'ğ' => 'g','ĝ' => 'g','ģ' => 'g','ġ' => 'g','Ğ' => 'g','Ĝ' => 'g','Ģ' => 'g','Ġ' => 'g',
// h
'ĥ' => 'h','Ħ' => 'h','Ĥ' => 'h',
// i
'ì' => 'i','í' => 'i','î' => 'i','ï' => 'i','ī' => 'i','ĭ' => 'i','į' => 'i','ı' => 'i',
'Ì' => 'i','Í' => 'i','Î' => 'i','Ï' => 'i','Ī' => 'i','Ĭ' => 'i','Į' => 'i','İ' => 'i',
// j
'ĵ' => 'j','Ĵ' => 'j',
// k
'ķ' => 'k','Ķ' => 'k',
// l
'ĺ' => 'l','ļ' => 'l','ľ' => 'l','ł' => 'l','Ŀ' => 'l','Ĺ' => 'l','Ļ' => 'l','Ľ' => 'l','Ł' => 'l',
// n
'ñ' => 'n','ń' => 'n','ň' => 'n','ņ' => 'n','ʼn' => 'n','Ñ' => 'n','Ń' => 'n','Ň' => 'n','Ņ' => 'n',
// o
'ò' => 'o','ó' => 'o','ô' => 'o','õ' => 'o','ö' => 'o','ø' => 'o','ō' => 'o','ŏ' => 'o','ő' => 'o',
'Ò' => 'o','Ó' => 'o','Ô' => 'o','Õ' => 'o','Ö' => 'o','Ø' => 'o','Ō' => 'o','Ŏ' => 'o','Ő' => 'o',
// r
'ŕ' => 'r','ř' => 'r','ŗ' => 'r','Ŕ' => 'r','Ř' => 'r','Ŗ' => 'r',
// s
'ś' => 's','š' => 's','ŝ' => 's','ş' => 's','Ś' => 's','Š' => 's','Ŝ' => 's','Ş' => 's',
// t
'ţ' => 't','ť' => 't','ŧ' => 't','Ţ' => 't','Ť' => 't','Ŧ' => 't',
// u
'ù' => 'u','ú' => 'u','û' => 'u','ü' => 'u','ū' => 'u','ŭ' => 'u','ů' => 'u','ű' => 'u','ų' => 'u',
'Ù' => 'u','Ú' => 'u','Û' => 'u','Ü' => 'u','Ū' => 'u','Ŭ' => 'u','Ů' => 'u','Ű' => 'u','Ų' => 'u',
// w
'ŵ' => 'w','ẁ' => 'w','ẃ' => 'w','ẅ' => 'w','Ŵ' => 'w','Ẁ' => 'w','Ẃ' => 'w','Ẅ' => 'w',
// y
'ý' => 'y','ÿ' => 'y','ŷ' => 'y','ỳ' => 'y','ỵ' => 'y','ỷ' => 'y','ỹ' => 'y',
'Ý' => 'y','Ÿ' => 'y','Ŷ' => 'y','Ỳ' => 'y','Ỵ' => 'y','Ỷ' => 'y','Ỹ' => 'y',
// z
'ź' => 'z','ž' => 'z','ż' => 'z','Ź' => 'z','Ž' => 'z','Ż' => 'z',
];
/** @var array<string,string>|null */
private static ?array $map = null;
/**
* Fold diacritics out of a UTF-8 string (lowercasing is NOT performed —
* callers opt-in if they want case-insensitivity).
*
* @param string|null $value
* @return string|null NULL passes through unchanged (mirrors SQL NULL).
*/
public static function fold(?string $value): ?string
{
if ($value === null || $value === '') {
return $value;
}
self::$map ??= self::MAP;
// Any non-NFC bytes that happen to be combining marks separated from
// their base letter are also dropped, so decomposed input works too.
$decomposed = preg_replace('/[\x{0300}-\x{036f}]/u', '', $value);
if ($decomposed === null) {
// Not valid UTF-8 — fall back to the raw value.
return $value;
}
return strtr($decomposed, self::$map);
}
}
+91 -28
View File
@@ -20,9 +20,10 @@ class Database
$this->dbPath = $this->determineDatabasePath($dbPath);
try {
$this->pdo = new PDO('sqlite:' . $this->dbPath);
$this->pdo = self::newConnection($this->dbPath);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->registerSqliteFunctions($this->pdo);
// Enable foreign key constraints + performance pragmas
$this->pdo->exec('PRAGMA foreign_keys = ON');
@@ -36,6 +37,50 @@ class Database
}
}
/**
* Create a new SQLite PDO connection.
*
* Since PHP 8.5, `PDO::sqliteCreateFunction()` (used to register the
* `accfold` UDF) is deprecated in favour of the `Pdo\Sqlite::createFunction()`
* method. `Pdo\Sqlite` extends `PDO` and keeps the full query API, so we use
* it directly when available and fall back to a plain `PDO` otherwise
* (older runtimes rely on the named argument there).
*/
private static function newConnection(string $dbPath): PDO
{
// `Pdo\Sqlite` exists since PHP 8.5; on older versions class_exists() is false.
if (class_exists('Pdo\Sqlite')) {
return new Pdo\Sqlite('sqlite:' . $dbPath);
}
return new PDO('sqlite:' . $dbPath);
}
/**
* Register user-defined SQLite functions used by search queries.
*
* Currently exposes `accfold()` — accent folding — so SQL can match
* diacritic-insensitively (accfold(column) LIKE accfold(term)).
*
* Also called for the in-memory test connection (see TestDatabase).
*/
public static function registerSqliteFunctions(
PDO $pdo
): void {
require_once __DIR__ . '/AccentFolding.php';
$callback = static fn (?string $value): ?string => AccentFolding::fold($value);
// Since PHP 8.5, `PDO::sqliteCreateFunction()` is deprecated: use the
// `Pdo\Sqlite::createFunction()` method instead. `PDO::sqliteCreateFunction()`
// remains the non-deprecated API on older versions where Pdo\Sqlite doesn't exist.
if ($pdo instanceof \Pdo\Sqlite) {
$pdo->createFunction('accfold', $callback, 1);
} else {
$pdo->sqliteCreateFunction('accfold', $callback, 1);
}
}
/**
* Run one-off schema migrations.
*/
@@ -336,23 +381,31 @@ class Database
*/
private function buildSearchConditions(array $params): array
{
require_once __DIR__ . '/AccentFolding.php';
$conditions = ['vp.is_published = 1'];
$bindings = [];
// Fold diacritics off a validated (already LIKE-escaped) term so both
// sides of the comparison are accent-insensitive. Escape characters
// (\, %, _) introduced by escapeLikeString are untouched by folding.
$fold = static fn (string $term): string => AccentFolding::fold($term) ?? $term;
if (!empty($params['query'])) {
$conditions[] = "(
vp.title LIKE :query ESCAPE '\\' OR
vp.subtitle LIKE :query ESCAPE '\\' OR
vp.synopsis LIKE :query ESCAPE '\\' OR
vp.authors LIKE :query ESCAPE '\\' OR
vp.supervisors LIKE :query ESCAPE '\\' OR
accfold(vp.title) LIKE :query ESCAPE '\\' OR
accfold(vp.subtitle) LIKE :query ESCAPE '\\' OR
accfold(vp.synopsis) LIKE :query ESCAPE '\\' OR
accfold(vp.authors) LIKE :query ESCAPE '\\' OR
accfold(vp.supervisors) LIKE :query ESCAPE '\\' OR
EXISTS (
SELECT 1 FROM thesis_tags tt2
JOIN tags tg2 ON tg2.id = tt2.tag_id
WHERE tt2.thesis_id = vp.id AND tg2.name LIKE :query ESCAPE '\\'
WHERE tt2.thesis_id = vp.id
AND accfold(tg2.name) LIKE :query ESCAPE '\\'
)
)";
$bindings[':query'] = '%' . $params['query'] . '%';
$bindings[':query'] = '%' . $fold($params['query']) . '%';
}
if (!empty($params['year'])) {
@@ -361,37 +414,38 @@ class Database
}
if (!empty($params['orientation'])) {
$conditions[] = "vp.orientation LIKE :orientation ESCAPE '\\'";
$bindings[':orientation'] = '%' . $params['orientation'] . '%';
$conditions[] = "accfold(vp.orientation) LIKE :orientation ESCAPE '\\'";
$bindings[':orientation'] = '%' . $fold($params['orientation']) . '%';
}
if (!empty($params['ap_program'])) {
$conditions[] = "vp.ap_program LIKE :ap_program ESCAPE '\\'";
$bindings[':ap_program'] = '%' . $params['ap_program'] . '%';
$conditions[] = "accfold(vp.ap_program) LIKE :ap_program ESCAPE '\\'";
$bindings[':ap_program'] = '%' . $fold($params['ap_program']) . '%';
}
if (!empty($params['finality'])) {
$conditions[] = "vp.finality_type LIKE :finality ESCAPE '\\'";
$bindings[':finality'] = '%' . $params['finality'] . '%';
$conditions[] = "accfold(vp.finality_type) LIKE :finality ESCAPE '\\'";
$bindings[':finality'] = '%' . $fold($params['finality']) . '%';
}
if (!empty($params['keyword'])) {
$conditions[] = "EXISTS (
SELECT 1 FROM thesis_tags tt_kw
JOIN tags tg_kw ON tg_kw.id = tt_kw.tag_id
WHERE tt_kw.thesis_id = vp.id AND tg_kw.name LIKE :keyword ESCAPE '\\'
WHERE tt_kw.thesis_id = vp.id
AND accfold(tg_kw.name) LIKE :keyword ESCAPE '\\'
)";
$bindings[':keyword'] = '%' . $params['keyword'] . '%';
$bindings[':keyword'] = '%' . $fold($params['keyword']) . '%';
}
if (!empty($params['format'])) {
$conditions[] = "vp.formats LIKE :format ESCAPE '\\'";
$bindings[':format'] = '%' . $params['format'] . '%';
$conditions[] = "accfold(vp.formats) LIKE :format ESCAPE '\\'";
$bindings[':format'] = '%' . $fold($params['format']) . '%';
}
if (!empty($params['language'])) {
$conditions[] = "vp.languages LIKE :language ESCAPE '\\'";
$bindings[':language'] = '%' . $params['language'] . '%';
$conditions[] = "accfold(vp.languages) LIKE :language ESCAPE '\\'";
$bindings[':language'] = '%' . $fold($params['language']) . '%';
}
if (isset($params['is_doctoral'])) {
@@ -498,16 +552,18 @@ class Database
*/
public function getThesesByAuthorName(string $name): array
{
require_once __DIR__ . '/AccentFolding.php';
$stmt = $this->pdo->prepare(
'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
vp.orientation, vp.finality_type, vp.authors
FROM v_theses_public vp
JOIN thesis_authors ta ON ta.thesis_id = vp.id
JOIN authors a ON a.id = ta.author_id
WHERE a.name = ?
WHERE accfold(a.name) = accfold(?)
ORDER BY vp.year DESC, vp.title ASC'
);
$stmt->execute([$name]);
$stmt->execute([AccentFolding::fold($name)]);
return $stmt->fetchAll();
}
@@ -524,6 +580,9 @@ class Database
return [];
}
require_once __DIR__ . '/AccentFolding.php';
$folded = array_map(static fn (string $n) => AccentFolding::fold($n) ?? $n, $names);
$placeholders = implode(',', array_fill(0, count($names), '?'));
$stmt = $this->pdo->prepare(
"SELECT a.name AS author_name,
@@ -532,10 +591,10 @@ class Database
FROM v_theses_public vp
JOIN thesis_authors ta ON ta.thesis_id = vp.id
JOIN authors a ON a.id = ta.author_id
WHERE a.name IN ($placeholders)
WHERE accfold(a.name) IN ($placeholders)
ORDER BY a.name ASC, vp.year DESC, vp.title ASC"
);
$stmt->execute($names);
$stmt->execute($folded);
$rows = $stmt->fetchAll();
$grouped = [];
@@ -856,6 +915,8 @@ class Database
*/
public function getThesesListCount(array $filters = []): int
{
require_once __DIR__ . '/AccentFolding.php';
$sql = 'SELECT COUNT(DISTINCT t.id)
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
@@ -867,8 +928,8 @@ class Database
$params = [];
if (!empty($filters['search'])) {
$sql .= ' AND (t.title LIKE ? OR t.subtitle LIKE ? OR t.identifier LIKE ? OR a.name LIKE ?)';
$searchParam = '%' . $filters['search'] . '%';
$sql .= ' AND (accfold(t.title) LIKE ? ESCAPE \'\\\' OR accfold(t.subtitle) LIKE ? ESCAPE \'\\\' OR accfold(t.identifier) LIKE ? ESCAPE \'\\\' OR accfold(a.name) LIKE ? ESCAPE \'\\\')';
$searchParam = '%' . AccentFolding::fold($filters['search']) . '%';
$params[] = $searchParam;
$params[] = $searchParam;
$params[] = $searchParam;
@@ -897,6 +958,8 @@ class Database
public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array
{
require_once __DIR__ . '/AccentFolding.php';
$sql = 'SELECT
t.id, t.identifier, t.title, t.subtitle, t.year,
o.name as orientation,
@@ -916,8 +979,8 @@ class Database
$params = [];
if (!empty($filters['search'])) {
$sql .= ' AND (t.title LIKE ? OR t.subtitle LIKE ? OR t.identifier LIKE ? OR a.name LIKE ?)';
$searchParam = '%' . $filters['search'] . '%';
$sql .= ' AND (accfold(t.title) LIKE ? ESCAPE \'\\\' OR accfold(t.subtitle) LIKE ? ESCAPE \'\\\' OR accfold(t.identifier) LIKE ? ESCAPE \'\\\' OR accfold(a.name) LIKE ? ESCAPE \'\\\')';
$searchParam = '%' . AccentFolding::fold($filters['search']) . '%';
$params[] = $searchParam;
$params[] = $searchParam;
$params[] = $searchParam;