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
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,7 +1,7 @@
# TODO # TODO
> Last updated: 2026-08-26 > Last updated: 2026-08-26
> Context: Fix end-user admin report: unexpected line breaks in some synopsis — resolved as undetectable/unfixable for existing data; only safe normalization on new inputs > Context: Recherche insensible aux accents sur toutes les barres de recherche (publique + backoffice)
## In Progress ## In Progress
@@ -9,6 +9,8 @@
## Completed ## Completed
- [x] #normalize-synopsis-whitespace & Passages à la ligne inopinés dans les synopsis : jugés INDÉTECTABLES/non-corrigeables automatiquement (une coupure de ligne interne peut être une vraie frontière de mot → espace, ou une coupure du mot → à réjoindre ; indistinguables sans dictionnaire, toute tentative corrompt l'entrée p.ex. "poursuivantsur"). Migration 045 SUPPRIMÉE ; dites à l'admin qu'il n'y a pas de nettoyage fiable des données existantes. Conservation de la NORMALISATION SÛRE sur les nouvelles saisies : fin de ligne \r\n/\r → \n, pertes de lignes vides multiples → \n\n, espaces/tabulations/NBSP → espace unique, trim — retour à la ligne simple intra-paragraphe laissé intact. - [x] #normalize-synopsis-whitespace & Passages à la ligne inopinés dans les synopsis : jugés INDÉTECTABLES/non-corrigeables automatiquement (une coupure de ligne interne peut être une vraie frontière de mot → espace, ou une coupure du mot → à réjoindre ; indistinguables sans dictionnaire, toute tentative corrompt l'entrée p.ex. "poursuivantsur"). Migration 045 SUPPRIMÉE ; dites à l'admin qu'il n'y a pas de nettoyage fiable des données existantes. Conservation de la NORMALISATION SÛRE sur les nouvelles saisies : fin de ligne \r\n/\r → \n, pertes de lignes vides multiples → \n\n, espaces/tabulations/NBSP → espace unique, trim — retour à la ligne simple intra-paragraphe laissé intact.
- [x] #recherche-insensible-aux-accents Recherche insensible aux accents sur toutes les barres de recherche : "Théophile" et "Theophile" donnent maintenant les mêmes résultats. Implémentation via une fonction SQLite UDF `accfold()` (app/src/AccentFolding.php, UDF enregistré dans Database::registerSqliteFunctions) qui plie les caractères accentués latin (é→e, ç→c, …) vers leur base ASCII ; chaque condition de recherche compare la colonne repliée au terme replié (`accfold(column) LIKE accfold(:term)`). Appliqué à la recherche publique (searchTheses/countSearchResults sur titre/sous-titre/synopsis/auteurs/promoteurs/tags), au popover étudiant (getThesesByAuthorName/getThesesForAuthors) et à la recherche backoffice par auteur (getThesesList/getThesesListCount). NULL-safe, enregistré sur les connexions prod et de test (TestDatabase). Pas d'intl/iconv dispo → map de translitération manuelle (Western Latin-1 + Latin Extended-A). Évite aussi la dépréciation PHP 8.5 de PDO::sqliteCreateFunction() : création de la connexion via \Pdo\Sqlite (createFunction()) quand dispo, repli sur PDO (sqliteCreateFunction()) sur anciens runtimes ; tests passent de `@dataProvider` docblock à l'attribut #[DataProvider] (fin de la dépréciation PHPUnit). 294 tests verts sans dépréciation ; phpstan OK.
- [x] #untrack-build-caches Détrache .phpunit.result.cache et .php-cs-fixer.cache (déjà gitignorés) pour qu'ils ne réapparaissent plus comme modifications dans le working copy.
- [x] #audit-all-docs-and [!high] Audit all docs/ and classify accurate vs stale - [x] #audit-all-docs-and [!high] Audit all docs/ and classify accurate vs stale
- [x] #rewrite-development-md-to-match [!high] Rewrite development.md to match current just dev / app/ layout / PHPUnit - [x] #rewrite-development-md-to-match [!high] Rewrite development.md to match current just dev / app/ layout / PHPUnit
- [x] #rewrite-deployment-md-to-match [!high] Rewrite deployment.md to match just deploy / /var/www/xamxam/ / backup - [x] #rewrite-deployment-md-to-match [!high] Rewrite deployment.md to match just deploy / /var/www/xamxam/ / backup
+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); $this->dbPath = $this->determineDatabasePath($dbPath);
try { 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_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->registerSqliteFunctions($this->pdo);
// Enable foreign key constraints + performance pragmas // Enable foreign key constraints + performance pragmas
$this->pdo->exec('PRAGMA foreign_keys = ON'); $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. * Run one-off schema migrations.
*/ */
@@ -336,23 +381,31 @@ class Database
*/ */
private function buildSearchConditions(array $params): array private function buildSearchConditions(array $params): array
{ {
require_once __DIR__ . '/AccentFolding.php';
$conditions = ['vp.is_published = 1']; $conditions = ['vp.is_published = 1'];
$bindings = []; $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'])) { if (!empty($params['query'])) {
$conditions[] = "( $conditions[] = "(
vp.title LIKE :query ESCAPE '\\' OR accfold(vp.title) LIKE :query ESCAPE '\\' OR
vp.subtitle LIKE :query ESCAPE '\\' OR accfold(vp.subtitle) LIKE :query ESCAPE '\\' OR
vp.synopsis LIKE :query ESCAPE '\\' OR accfold(vp.synopsis) LIKE :query ESCAPE '\\' OR
vp.authors LIKE :query ESCAPE '\\' OR accfold(vp.authors) LIKE :query ESCAPE '\\' OR
vp.supervisors LIKE :query ESCAPE '\\' OR accfold(vp.supervisors) LIKE :query ESCAPE '\\' OR
EXISTS ( EXISTS (
SELECT 1 FROM thesis_tags tt2 SELECT 1 FROM thesis_tags tt2
JOIN tags tg2 ON tg2.id = tt2.tag_id 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'])) { if (!empty($params['year'])) {
@@ -361,37 +414,38 @@ class Database
} }
if (!empty($params['orientation'])) { if (!empty($params['orientation'])) {
$conditions[] = "vp.orientation LIKE :orientation ESCAPE '\\'"; $conditions[] = "accfold(vp.orientation) LIKE :orientation ESCAPE '\\'";
$bindings[':orientation'] = '%' . $params['orientation'] . '%'; $bindings[':orientation'] = '%' . $fold($params['orientation']) . '%';
} }
if (!empty($params['ap_program'])) { if (!empty($params['ap_program'])) {
$conditions[] = "vp.ap_program LIKE :ap_program ESCAPE '\\'"; $conditions[] = "accfold(vp.ap_program) LIKE :ap_program ESCAPE '\\'";
$bindings[':ap_program'] = '%' . $params['ap_program'] . '%'; $bindings[':ap_program'] = '%' . $fold($params['ap_program']) . '%';
} }
if (!empty($params['finality'])) { if (!empty($params['finality'])) {
$conditions[] = "vp.finality_type LIKE :finality ESCAPE '\\'"; $conditions[] = "accfold(vp.finality_type) LIKE :finality ESCAPE '\\'";
$bindings[':finality'] = '%' . $params['finality'] . '%'; $bindings[':finality'] = '%' . $fold($params['finality']) . '%';
} }
if (!empty($params['keyword'])) { if (!empty($params['keyword'])) {
$conditions[] = "EXISTS ( $conditions[] = "EXISTS (
SELECT 1 FROM thesis_tags tt_kw SELECT 1 FROM thesis_tags tt_kw
JOIN tags tg_kw ON tg_kw.id = tt_kw.tag_id 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'])) { if (!empty($params['format'])) {
$conditions[] = "vp.formats LIKE :format ESCAPE '\\'"; $conditions[] = "accfold(vp.formats) LIKE :format ESCAPE '\\'";
$bindings[':format'] = '%' . $params['format'] . '%'; $bindings[':format'] = '%' . $fold($params['format']) . '%';
} }
if (!empty($params['language'])) { if (!empty($params['language'])) {
$conditions[] = "vp.languages LIKE :language ESCAPE '\\'"; $conditions[] = "accfold(vp.languages) LIKE :language ESCAPE '\\'";
$bindings[':language'] = '%' . $params['language'] . '%'; $bindings[':language'] = '%' . $fold($params['language']) . '%';
} }
if (isset($params['is_doctoral'])) { if (isset($params['is_doctoral'])) {
@@ -498,16 +552,18 @@ class Database
*/ */
public function getThesesByAuthorName(string $name): array public function getThesesByAuthorName(string $name): array
{ {
require_once __DIR__ . '/AccentFolding.php';
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis, 'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
vp.orientation, vp.finality_type, vp.authors vp.orientation, vp.finality_type, vp.authors
FROM v_theses_public vp FROM v_theses_public vp
JOIN thesis_authors ta ON ta.thesis_id = vp.id JOIN thesis_authors ta ON ta.thesis_id = vp.id
JOIN authors a ON a.id = ta.author_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' ORDER BY vp.year DESC, vp.title ASC'
); );
$stmt->execute([$name]); $stmt->execute([AccentFolding::fold($name)]);
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
@@ -524,6 +580,9 @@ class Database
return []; 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), '?')); $placeholders = implode(',', array_fill(0, count($names), '?'));
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
"SELECT a.name AS author_name, "SELECT a.name AS author_name,
@@ -532,10 +591,10 @@ class Database
FROM v_theses_public vp FROM v_theses_public vp
JOIN thesis_authors ta ON ta.thesis_id = vp.id JOIN thesis_authors ta ON ta.thesis_id = vp.id
JOIN authors a ON a.id = ta.author_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" ORDER BY a.name ASC, vp.year DESC, vp.title ASC"
); );
$stmt->execute($names); $stmt->execute($folded);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
$grouped = []; $grouped = [];
@@ -856,6 +915,8 @@ class Database
*/ */
public function getThesesListCount(array $filters = []): int public function getThesesListCount(array $filters = []): int
{ {
require_once __DIR__ . '/AccentFolding.php';
$sql = 'SELECT COUNT(DISTINCT t.id) $sql = 'SELECT COUNT(DISTINCT t.id)
FROM theses t FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id LEFT JOIN orientations o ON t.orientation_id = o.id
@@ -867,8 +928,8 @@ class Database
$params = []; $params = [];
if (!empty($filters['search'])) { if (!empty($filters['search'])) {
$sql .= ' AND (t.title LIKE ? OR t.subtitle LIKE ? OR t.identifier LIKE ? OR a.name LIKE ?)'; $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 = '%' . $filters['search'] . '%'; $searchParam = '%' . AccentFolding::fold($filters['search']) . '%';
$params[] = $searchParam; $params[] = $searchParam;
$params[] = $searchParam; $params[] = $searchParam;
$params[] = $searchParam; $params[] = $searchParam;
@@ -897,6 +958,8 @@ class Database
public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array
{ {
require_once __DIR__ . '/AccentFolding.php';
$sql = 'SELECT $sql = 'SELECT
t.id, t.identifier, t.title, t.subtitle, t.year, t.id, t.identifier, t.title, t.subtitle, t.year,
o.name as orientation, o.name as orientation,
@@ -916,8 +979,8 @@ class Database
$params = []; $params = [];
if (!empty($filters['search'])) { if (!empty($filters['search'])) {
$sql .= ' AND (t.title LIKE ? OR t.subtitle LIKE ? OR t.identifier LIKE ? OR a.name LIKE ?)'; $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 = '%' . $filters['search'] . '%'; $searchParam = '%' . AccentFolding::fold($filters['search']) . '%';
$params[] = $searchParam; $params[] = $searchParam;
$params[] = $searchParam; $params[] = $searchParam;
$params[] = $searchParam; $params[] = $searchParam;
+20
View File
@@ -77,6 +77,26 @@ Queries operate on `v_theses_public`; keyword matching joins the `thesis_tags` /
All queries use PDO prepared statements and escape `%`/`_` for `LIKE` All queries use PDO prepared statements and escape `%`/`_` for `LIKE`
(`Database::escapeLikeString`) to prevent wildcard injection. (`Database::escapeLikeString`) to prevent wildcard injection.
### Accent-insensitive matching
Text searches on the public **search bar**, the **student-preview** popover, and
the **admin list** search are all **diacritic-insensitive**: `Théophile` and
`Theophile`, `sévère` and `severe` return the same results.
This is implemented via a SQLite user-defined function `accfold()`
(`app/src/AccentFolding.php`, registered in `Database::registerSqliteFunctions()`), which
strips precomposed accented Latin characters to their ASCII base (`é` → `e`,
`ç` → `c`, …). Search conditions compare the folded column against the folded
term (`accfold(column) LIKE accfold(:term)`), so both sides are normalised the
same way. The function is NULL-safe and registered on the production and test
connections alike; the public path uses `searchTheses()`/`countSearchResults()`,
the student popover `getThesesByAuthorName()`, and the admin list
`getThesesList()`/`getThesesListCount()`.
Note: `intl`/`Normalizer` and `iconv` are **not** available on the runtime, so
folding uses a hand-maintained transliteration map (Western Latin-1 +
Latin Extended-A).
--- ---
## Performance notes ## Performance notes
+12 -2
View File
@@ -15,7 +15,11 @@ class TestDatabaseInstance extends Database
{ {
public function __construct(PDO $pdo) public function __construct(PDO $pdo)
{ {
// Inject PDO directly via reflection, then flag as ready // Inject PDO directly via reflection, then flag as ready.
// Because we bypass the parent constructor, we must also register the
// custom SQLite functions (e.g. accfold) it would otherwise install.
Database::registerSqliteFunctions($pdo);
$ref = new ReflectionProperty(Database::class, 'pdo'); $ref = new ReflectionProperty(Database::class, 'pdo');
$ref->setValue($this, $pdo); $ref->setValue($this, $pdo);
@@ -36,7 +40,13 @@ class TestDatabase
public static function getInstance(): Database public static function getInstance(): Database
{ {
if (self::$db === null) { if (self::$db === null) {
self::$pdo = new PDO('sqlite::memory:'); // Since PHP 8.5, `PDO::sqliteCreateFunction()` is deprecated and the
// registration in Database::registerSqliteFunctions() uses
// `Pdo\Sqlite::createFunction()` when the connection is a `Pdo\Sqlite`.
// Build the same connection type here so the new API path is exercised.
self::$pdo = class_exists('Pdo\Sqlite')
? new Pdo\Sqlite('sqlite::memory:')
: new PDO('sqlite::memory:');
self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
self::$pdo->exec('PRAGMA foreign_keys = ON'); self::$pdo->exec('PRAGMA foreign_keys = ON');
@@ -0,0 +1,110 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* AccentInsensitiveSearchTest — regression tests ensuring all search paths
* (public search, student preview, admin list) treat accented and
* non-accented queries as equivalent. "Théophile" must match "Theophile",
* "sévère" must match "severe", etc.
*/
class AccentInsensitiveSearchTest extends TestCase
{
protected function setUp(): void
{
TestDatabase::resetData();
}
#[DataProvider('foldProvider')]
public function testFoldStripsDiacritics(string $input, string $expected): void
{
$this->assertSame($expected, AccentFolding::fold($input));
}
public static function foldProvider(): array
{
return [
'french name' => ['Théophile', 'Theophile'],
'lowercase + mixed' => ['sévère où ça', 'severe ou ca'],
// Upper diacritics fold to lower base letters (LIKE is case-insensitive in SQLite,
// so this is fine for matching; the point is É and é both fold to e).
'upper diacritics' => ['ÉCOLE À ö', 'eCOLE a o'],
'cedilla' => ['garçon', 'garcon'],
'n tilde' => ['señor', 'senor'],
'umlauts' => ['üöä', 'uoa'],
'apex/apostrophe' => ['l\'erg', "l'erg"],
'empty' => ['', ''],
'no diacritics' => ['plain text', 'plain text'],
];
}
public function testFoldReturnsNullForNull(): void
{
$this->assertNull(AccentFolding::fold(null));
}
public function testPublicSearchMatchedWithoutAccent(): void
{
TestDatabase::seedBasicThesis('TFE ac', 'Théophile Dupont', 2024);
$db = TestDatabase::getInstance();
$res = $db->searchTheses(['query' => 'theophile']);
$this->assertCount(1, $res);
$this->assertStringContainsString('Théophile', $res[0]['authors']);
}
public function testPublicSearchMatchedWithAccent(): void
{
TestDatabase::seedBasicThesis('TFE ac', 'Théophile Dupont', 2024);
$db = TestDatabase::getInstance();
$res = $db->searchTheses(['query' => 'Théophile']);
$this->assertCount(1, $res);
}
public function testTitleSearchIgnoresAccent(): void
{
TestDatabase::seedBasicThesis('Sévère étude', 'Auteur', 2024);
$db = TestDatabase::getInstance();
$res = $db->searchTheses(['query' => 'severe']);
$this->assertCount(1, $res);
}
public function testSearchReturnsNothingForMismatch(): void
{
TestDatabase::seedBasicThesis('Distinct Title', 'Autre Auteur', 2024);
$db = TestDatabase::getInstance();
$this->assertCount(0, $db->searchTheses(['query' => 'xyzzy-no-match']));
}
public function testAuthorNamePreviewIgnoresAccent(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('P', 'Théophile Dupont', 2024);
$db = TestDatabase::getInstance();
$this->assertCount(1, $db->getThesesByAuthorName('Theophile Dupont'));
$this->assertCount(1, $db->getThesesByAuthorName('Théophile Dupont'));
$this->assertCount(0, $db->getThesesByAuthorName('Jean-Claude'));
}
public function testAdminListIgnoresAccent(): void
{
TestDatabase::seedBasicThesis('Étude de cas', 'Théophile Martin', 2025);
$db = TestDatabase::getInstance();
$accented = $db->getThesesList(['search' => 'Théophile'], 0, 0);
$plain = $db->getThesesList(['search' => 'theophile'], 0, 0);
$this->assertCount(1, $accented);
$this->assertEquals($accented, $plain);
$this->assertSame(0, $db->getThesesListCount(['search' => 'zz-nope']));
}
public function testAdminIdentifierSearchStillMatchesAscii(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('X', 'Auteur', 2024);
$db = TestDatabase::getInstance();
// seedBasicThesis uses identifier "$year-001" → "2024-001"
$pdo = TestDatabase::getPDO();
$id = $pdo->query("SELECT identifier FROM theses WHERE id = $thesisId")->fetchColumn();
$this->assertCount(1, $db->getThesesList(['search' => $id], 0, 0));
}
}