Ricerca full-text in PHP con MySQL e MariaDB

by theArchitect
SHARE
Ricerca full-text in PHP con MySQL e MariaDB
© Guida-HTML5.it

Introduzione

Quando un’applicazione deve cercare parole all’interno di titoli, descrizioni o articoli, una semplice clausola LIKE può essere sufficiente per pochi dati, ma diventa poco pratica quando la tabella cresce. Una soluzione più adatta è la ricerca full-text, supportata sia da MySQL sia da MariaDB.

La ricerca full-text utilizza un indice specifico per analizzare il contenuto testuale e calcolare la rilevanza dei risultati. In questo modo è possibile cercare più parole, ordinare i record in base alla pertinenza e ignorare automaticamente termini troppo comuni o troppo brevi, secondo le impostazioni del database.

In questo tutorial realizzeremo una piccola ricerca di articoli. L’utente inserirà una frase, PHP interrogherà il database tramite PDO e i risultati saranno ordinati usando il punteggio restituito da MATCH ... AGAINST.

Codice completo

Per prima cosa creiamo una tabella con un indice full-text. L’indice deve essere applicato alle colonne sulle quali vogliamo effettuare la ricerca.

CREATE TABLE articoli (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    titolo VARCHAR(200) NOT NULL,
    contenuto TEXT NOT NULL,
    pubblicato TINYINT(1) NOT NULL DEFAULT 1,
    creato_il DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

    FULLTEXT KEY ft_articoli (titolo, contenuto)
) ENGINE=InnoDB;

Inseriamo alcuni dati di esempio:

INSERT INTO articoli (titolo, contenuto) VALUES
(
    ´Introduzione a PHP´,
    ´PHP è un linguaggio utilizzato per creare applicazioni web dinamiche.´
),
(
    ´Connessione a MariaDB´,
    ´PDO consente di collegare PHP a MariaDB in modo sicuro e portabile.´
),
(
    ´Sicurezza delle applicazioni web´,
    ´Le query parametrizzate aiutano a prevenire diversi attacchi informatici.´
);

Il seguente file PHP contiene la connessione, la validazione dell’input e l’esecuzione della ricerca:

<?php
declare(strict_types=1);

$host = ´127.0.0.1´;
$dbname = ´blog´;
$user = ´app_user´;
$password = ´password_sicura´;

$query = trim((string) ($_GET[´q´] ?? ´´));

// Una ricerca troppo breve produce spesso risultati poco utili.
$messaggio = null;
$risultati = [];

if ($query !== ´´ && mb_strlen($query) < 3) {
    $messaggio = ´Inserisci almeno tre caratteri.´;
} elseif ($query !== ´´) {
    try {
        $pdo = new PDO(
            "mysql:host={$host};dbname={$dbname};charset=utf8mb4",
            $user,
            $password,
            [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES => false
            ]
        );

        /*
         * NATURAL LANGUAGE MODE valuta la rilevanza dei documenti
         * senza interpretare operatori speciali nella frase cercata.
         */
        $sql = "
            SELECT
                id,
                titolo,
                contenuto,
                MATCH(titolo, contenuto)
                    AGAINST(:termine IN NATURAL LANGUAGE MODE) AS rilevanza
            FROM articoli
            WHERE pubblicato = 1
              AND MATCH(titolo, contenuto)
                    AGAINST(:termine_filtro IN NATURAL LANGUAGE MODE)
            ORDER BY rilevanza DESC, creato_il DESC
            LIMIT 20
        ";

        $stmt = $pdo->prepare($sql);

        /*
         * Usiamo due parametri nominati distinti perché alcuni driver
         * PDO non consentono di riutilizzare lo stesso placeholder.
         */
        $stmt->execute([
            ´termine´ => $query,
            ´termine_filtro´ => $query
        ]);

        $risultati = $stmt->fetchAll();

        if ($risultati === []) {
            $messaggio = ´Nessun articolo trovato.´;
        }
    } catch (PDOException $e) {
        // In produzione registrare l´errore, senza mostrarne i dettagli all´utente.
        $messaggio = ´Si è verificato un problema durante la ricerca.´;
    }
}
?>

<form method="get" action="">
    <label for="q">Cerca un articolo</label>
    <input
        type="search"
        id="q"
        name="q"
        value="<?= htmlspecialchars($query, ENT_QUOTES, ´UTF-8´) ?>"
        minlength="3"
        required
    >
    <button type="submit">Cerca</button>
</form>

<?php if ($messaggio !== null): ?>
    <p><?= htmlspecialchars($messaggio, ENT_QUOTES, ´UTF-8´) ?></p>
<?php endif; ?>

<?php foreach ($risultati as $articolo): ?>
    <article>
        <h2>
            <?= htmlspecialchars($articolo[´titolo´], ENT_QUOTES, ´UTF-8´) ?>
        </h2>

        <p>
            <?= htmlspecialchars($articolo[´contenuto´], ENT_QUOTES, ´UTF-8´) ?>
        </p>

        <small>
            Rilevanza:
            <?= number_format((float) $articolo[´rilevanza´], 3) ?>
        </small>
    </article>
<?php endforeach; ?>

Spiegazione

Creazione dell’indice

La clausola FULLTEXT KEY crea una struttura indicizzata sulle colonne titolo e contenuto. La definizione delle colonne nella query deve corrispondere a quella dell’indice: se l’indice usa due colonne, anche MATCH deve riferirsi alle stesse colonne e nello stesso ordine.

MATCH e AGAINST

MATCH(titolo, contenuto) AGAINST(...) restituisce un valore numerico chiamato rilevanza. Un valore maggiore indica, in generale, una corrispondenza più significativa. La stessa espressione viene usata nel filtro WHERE per eliminare i documenti che non contengono risultati pertinenti.

La modalità NATURAL LANGUAGE MODE interpreta il testo come una frase normale. Esiste anche BOOLEAN MODE, utile quando si vogliono utilizzare operatori come + per richiedere una parola, - per escluderla o * per una ricerca con prefisso.

SELECT id, titolo
FROM articoli
WHERE MATCH(titolo, contenuto)
      AGAINST(´+PHP -sicurezza´ IN BOOLEAN MODE);

Nel codice PHP il valore dell’utente viene passato tramite un prepared statement. Questo evita di concatenare direttamente l’input nella query. Tuttavia, i prepared statements non risolvono automaticamente ogni problema: il nome della modalità di ricerca, i nomi delle colonne e l’ordinamento devono rimanere elementi controllati dal programma.

Best practice

  • Usare un indice FULLTEXT: senza indice, la ricerca full-text non funzionerà correttamente nelle configurazioni standard.
  • Validare la lunghezza dell’input: ricerche di uno o due caratteri producono spesso risultati poco significativi.
  • Limitare i risultati: un LIMIT protegge l’applicazione dal caricamento di migliaia di righe.
  • Visualizzare l’output in sicurezza: usare htmlspecialchars per impedire che testo proveniente dal database venga interpretato come HTML.
  • Controllare la lingua e la configurazione: stopword, lunghezza minima delle parole e algoritmo di indicizzazione possono cambiare tra MySQL e MariaDB.
  • Valutare BOOLEAN MODE: è utile per filtri avanzati, ma deve essere documentato chiaramente nell’interfaccia perché gli operatori modificano il significato della ricerca.
  • Non mostrare dettagli delle eccezioni: i messaggi PDO possono contenere informazioni sulla struttura del database e devono essere registrati lato server.
  • Analizzare le query reali: per dataset molto grandi è opportuno verificare il piano di esecuzione e misurare i tempi con dati realistici.

Riepilogo

La ricerca full-text rappresenta una soluzione efficace per trovare parole all’interno di contenuti testuali. MySQL e MariaDB forniscono questa funzionalità attraverso un indice FULLTEXT e le espressioni MATCH ... AGAINST.

Un’implementazione robusta combina indice, prepared statements, validazione dell’input, limite dei risultati e corretta codifica dell’output HTML. La modalità naturale è adatta alla ricerca libera, mentre la modalità booleana consente di costruire filtri più precisi.

Approfondisci con risorse ufficiali

  • Documentazione MySQL sulla ricerca full-text: https://dev.mysql.com/doc/refman/en/fulltext-search.html
  • Documentazione MariaDB sugli indici FULLTEXT: https://mariadb.com/kb/en/full-text-indexes/
  • Documentazione PHP su PDO: https://www.php.net/manual/en/book.pdo.php
  • Documentazione PHP su htmlspecialchars: https://www.php.net/manual/en/function htmlspecialchars.php

SHARE