mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
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:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user