SQLite
 sql >> Database >  >> RDS >> SQLite

5 modi per implementare la ricerca senza distinzione tra maiuscole e minuscole in SQLite con supporto Unicode completo

Recentemente ho avuto bisogno di una ricerca senza distinzione tra maiuscole e minuscole in SQLite per verificare se un elemento con lo stesso nome esiste già in uno dei miei progetti:listOK. All'inizio sembrava un compito semplice, ma dopo un'immersione più profonda si è rivelato facile, ma non per niente semplice, con molti colpi di scena.

Funzionalità SQLite integrate e relativi svantaggi

In SQLite puoi ottenere una ricerca senza distinzione tra maiuscole e minuscole in tre modi:

-- 1. Use a NOCASE collation
-- (we will look at other ways for applying collations later):
SELECT * 
    FROM items 
    WHERE text = "String in AnY case" COLLATE NOCASE;

-- 2. Normalize all strings to the same case,
-- does not matter lower or upper:
SELECT * 
    FROM items 
    WHERE LOWER(text) = "string in lower case";

-- 3. Use LIKE operator which is case insensitive by default:
SELECT * 
    FROM items 
    WHERE text LIKE "String in AnY case";

Se usi SQLAlchemy e il suo ORM, questi approcci appariranno come segue:

from sqlalchemy import func
from sqlalchemy.orm.query import Query

from package.models import YourModel


text_to_find = "Text in AnY case"

# NOCASE collation
Query(YourModel)
.filter(
    YourModel.field_name.collate("NOCASE") == text_to_find
)

# Normalizing text to the same case
Query(YourModel)
.filter(
    func.lower(YourModel.field_name) == text_to_find.lower()
).all()

# LIKE operator. No need to use SQLAlchemy's ilike
# since SQLite LIKE is already case-insensitive.
Query(YourModel)
.filter(YourModel.field_name.like(text_to_find))

Tutti questi approcci non sono l'ideale. Prima , senza particolari considerazioni non fanno uso di indici sul campo su cui stanno lavorando, con LIKE essere il peggiore delinquente:nella maggior parte dei casi è incapace di utilizzare gli indici. Ulteriori informazioni sull'uso degli indici per le query senza distinzione tra maiuscole e minuscole sono riportate di seguito.

Secondo e, cosa più importante, hanno una comprensione piuttosto limitata di cosa significhi la distinzione tra maiuscole e minuscole:

SQLite riconosce solo le maiuscole/minuscole per i caratteri ASCII per impostazione predefinita. L'operatore LIKE fa la distinzione tra maiuscole e minuscole per impostazione predefinita per i caratteri Unicode che sono oltre l'intervallo ASCII. Ad esempio, l'espressione 'a' LIKE 'A' è VERO ma 'æ' LIKE 'Æ' è FALSO.

Non è un problema se prevedi di lavorare con stringhe che contengono solo lettere dell'alfabeto inglese, numeri, ecc. Avevo bisogno dell'intero spettro Unicode, quindi era necessaria una soluzione migliore.

Di seguito riassumo cinque modi per ottenere la ricerca/confronto senza distinzione tra maiuscole e minuscole in SQLite per tutti i simboli Unicode. Alcune di queste soluzioni possono essere adattate ad altri database e per l'implementazione di LIKE compatibile con Unicode , REGEXP , MATCH e altre funzioni, anche se questi argomenti non rientrano nell'ambito di questo post.

Esamineremo i pro ei contro di ciascun approccio, i dettagli di implementazione e, infine, gli indici e le considerazioni sulle prestazioni.

Soluzioni

1. Estensione in terapia intensiva

La documentazione ufficiale di SQLite menziona l'estensione ICU come un modo per aggiungere il supporto completo per Unicode in SQLite. ICU sta per Componenti internazionali per Unicode.

L'ICU risolve i problemi di LIKE senza distinzione tra maiuscole e minuscole e confronto/ricerca, inoltre aggiunge il supporto per diverse regole di confronto per una buona misura. Potrebbe anche essere più veloce di alcune delle soluzioni successive poiché è scritto in C ed è più strettamente integrato con SQLite.

Tuttavia, ha le sue sfide:

  1. È un nuovo tipo di dipendenza:non una libreria Python, ma un'estensione che dovrebbe essere distribuita insieme all'applicazione.

  2. La terapia intensiva deve essere compilata prima dell'uso, potenzialmente per diversi sistemi operativi e piattaforme (non testati).

  3. L'ICU non implementa di per sé le conversioni Unicode, ma si basa sul sistema operativo sottolineato:ho visto più menzioni di problemi specifici del sistema operativo, in particolare con Windows e macOS.

Tutte le altre soluzioni dipenderanno dal tuo codice Python per eseguire il confronto, quindi è importante scegliere l'approccio giusto per convertire e confrontare le stringhe.

Scelta della giusta funzione Python per il confronto senza distinzione tra maiuscole e minuscole

Per eseguire il confronto e la ricerca senza distinzione tra maiuscole e minuscole, è necessario normalizzare le stringhe su un caso. Il mio primo istinto è stato quello di usare str.lower() per questo. Funzionerà nella maggior parte dei casi, ma non è il modo corretto. Meglio usare str.casefold() (documenti):

Restituire una copia maiuscola della stringa. Le stringhe maiuscole possono essere utilizzate per la corrispondenza senza maiuscole/minuscole.

Il casefolding è simile al minuscolo ma più aggressivo perché ha lo scopo di rimuovere tutte le distinzioni maiuscole in una stringa. Ad esempio, la lettera minuscola tedesca 'ß' è equivalente a "ss". Poiché è già minuscolo, lower() non farebbe nulla a 'ß'; casefold() lo converte in "ss".

Pertanto, di seguito utilizzeremo str.casefold() funzione per tutte le conversioni e confronti.

2. Fascicolazione definita dall'applicazione

Per eseguire una ricerca senza distinzione tra maiuscole e minuscole per tutti i simboli Unicode, è necessario definire una nuova confronto nell'applicazione dopo la connessione al database (documentazione). Qui hai una scelta:sovraccarica il NOCASE integrato o creane uno tuo:discuteremo i pro e i contro di seguito. Per il bene di un esempio useremo un nuovo nome:

import sqlite3

# Custom collation, maybe it is more efficient
# to store strings
def unicode_nocase_collation(a: str, b: str):
    if a.casefold() == b.casefold():
        return 0
    if a.casefold() < b.casefold():
        return -1
    return 1

connection.create_collation(
    "UNICODE_NOCASE", unicode_nocase_collation
)

# Connect to the DB and register the function
connection = sqlite3.connect("your_db_path")
connection.create_collation(
    "UNICODE_NOCASE", unicode_nocase_collation
)

# Or, if you use SQLAlchemy you need to register
# the collation via an event
@sa.event.listens_for(sa.engine.Engine, 'connect')
def sqlite_engine_connect(connection, _):
    connection.create_collation(
    "UNICODE_NOCASE", unicode_nocase_collation
)

Le regole di confronto presentano diversi vantaggi rispetto alle soluzioni successive:

  1. Sono facili da usare. Puoi specificare le regole di confronto nello schema della tabella e verrà automaticamente applicato a tutte le query e gli indici in questo campo, a meno che non specifichi diversamente:

    CREATE TABLE test (text VARCHAR COLLATE UNICODE_NOCASE);
    

    Per ragioni di completezza, esaminiamo altri due modi per utilizzare le regole di confronto:

    -- In a particular query:
    SELECT * FROM items
        WHERE text = "Text in AnY case" COLLATE UNICODE_NOCASE;
    
    -- In an index:
    CREATE INDEX IF NOT EXISTS idx1 
        ON test (text COLLATE UNICODE_NOCASE);
    
    -- Word of caution: your query and index 
    -- must match exactly,including collation, 
    -- otherwise, SQLite will perform a full table scan.
    -- More on indexes below.
    EXPLAIN QUERY PLAN
        SELECT * FROM test WHERE text = 'something';
    -- Output: SCAN TABLE test
    EXPLAIN QUERY PLAN
        SELECT * FROM test WHERE text = 'something' COLLATE NOCASE;
    -- Output: SEARCH TABLE test USING COVERING INDEX idx1 (text=?)
    
  2. Le regole di confronto forniscono l'ordinamento senza distinzione tra maiuscole e minuscole con ORDER BY fuori dalla scatola. È particolarmente facile da ottenere se si definiscono le regole di confronto nello schema della tabella.

Le regole di confronto in termini di prestazioni presentano alcune particolarità, di cui parleremo ulteriormente.

3. Funzione SQL definita dall'applicazione

Un altro modo per ottenere la ricerca senza distinzione tra maiuscole e minuscole consiste nel creare una funzione SQL definita dall'applicazione (documentazione):

import sqlite3

# Custom function
def casefold(s: str):
    return s.casefold()

# Connect to the DB and register the function
connection = sqlite3.connect("your_db_path")
connection.create_function("CASEFOLD", 1, casefold)

# Or, if you use SQLAlchemy you need to register 
# the function via an event
@sa.event.listens_for(sa.engine.Engine, 'connect')
def sqlite_engine_connect(connection, _):
    connection.create_function("CASEFOLD", 1, casefold)

In entrambi i casi create_function accetta fino a quattro argomenti:

  • nome della funzione come verrà utilizzata nelle query SQL
  • numero di argomenti accettati dalla funzione
  • la funzione stessa
  • opzionale bool deterministic , predefinito False (aggiunto in Python 3.8) – è importante per gli indici, di cui parleremo di seguito.

Come per le regole di confronto, hai una scelta:sovraccaricare la funzione incorporata (ad esempio, LOWER ) o crearne di nuovi. Lo esamineremo più in dettaglio in seguito.

4. Confronta nell'applicazione

Un altro modo di ricerca senza distinzione tra maiuscole e minuscole sarebbe il confronto nell'app stessa, soprattutto se puoi restringere la ricerca utilizzando un indice su altri campi. Ad esempio, in listOK è necessario il confronto senza distinzione tra maiuscole e minuscole per gli elementi in un elenco particolare. Pertanto, potrei selezionare tutti gli elementi nell'elenco, normalizzarli in un caso e confrontarli con il nuovo elemento normalizzato.

A seconda delle circostanze, non è una cattiva soluzione, soprattutto se il sottoinsieme con cui ti confronterai è piccolo. Tuttavia, non sarai in grado di utilizzare gli indici del database sul testo, solo su altri parametri che utilizzerai per restringere l'ambito.

Il vantaggio di questo approccio è la sua flessibilità:nell'applicazione è possibile verificare non solo l'uguaglianza ma, ad esempio, implementare il confronto "fuzzy" per tenere conto di possibili errori di stampa, forme singolari/plurali, ecc. Questo è il percorso che ho scelto per listOK poiché il bot aveva bisogno di un confronto sfocato per la creazione dell'elemento "intelligente".

Inoltre, elimina qualsiasi accoppiamento con il database:è un semplice archivio che non sa nulla dei dati.

5. Memorizza il campo normalizzato separatamente

C'è un'altra soluzione:creare una colonna separata nel database e mantenere il testo normalizzato su cui cercherai. Ad esempio, la tabella può avere questa struttura (solo campi rilevanti):

id nome_normalizzato
1 Composizione maiuscola della frase Composizione maiuscola della frase
2 LETTERE MAIUSCOLE lettere maiuscole
3 Simboli non ASCII:Найди Меня simboli non ascii:найди меня

All'inizio può sembrare eccessivo:devi sempre mantenere aggiornata la versione normalizzata e raddoppiare efficacemente le dimensioni del name campo. Tuttavia, con gli ORM o anche manualmente è facile da fare e lo spazio su disco più la RAM è relativamente economico.

Vantaggi di questo approccio:

  • Disaccoppia completamente l'applicazione e il database:puoi passare facilmente.

  • Puoi pre-elaborare file normalizzati se le tue query lo richiedono (taglia, rimuovi punteggiatura o spazi, ecc.).

Dovresti sovraccaricare le funzioni e le regole di confronto integrate?

Quando si utilizzano le regole di confronto e le funzioni SQL definite dall'applicazione, spesso è possibile scegliere:utilizzare un nome univoco o sovraccaricare le funzionalità integrate. Entrambi gli approcci hanno i loro pro e contro in due dimensioni principali:

Innanzitutto, affidabilità/prevedibilità quando per qualche motivo (un errore occasionale, un bug o intenzionalmente) non registri queste funzioni o confronti:

  • Sovraccarico:il database continuerà a funzionare, ma i risultati potrebbero non essere corretti:

    • la funzione/collation incorporata si comporterà in modo diverso rispetto alle loro controparti personalizzate;
    • se hai utilizzato le regole di confronto ora assenti in un indice sembrerà funzionare, ma i risultati potrebbero essere errati anche durante la lettura;
    • se la tabella con l'indice e l'indice che utilizza funzioni/confronti personalizzate viene aggiornata, l'indice potrebbe essere danneggiato (aggiornato utilizzando l'implementazione integrata), ma continuare a funzionare come se nulla fosse.
  • Nessun sovraccarico:il database non funzionerà in alcun modo quando vengono utilizzate le funzioni o le regole di confronto assenti:

    • se utilizzi un indice su una funzione assente potrai usarlo per la lettura, ma non per gli aggiornamenti;
    • Gli indici con regole di confronto definite dall'applicazione non funzioneranno affatto, poiché utilizzano le regole di confronto durante la ricerca nell'indice.

Secondo, accessibilità al di fuori dell'applicazione principale:migrazioni, analisi, ecc.:

  • Sovraccarico:potrai modificare il database senza problemi, tenendo presente il rischio di corrompere gli indici.

  • Non sovraccaricare:in molti casi sarà necessario registrare queste funzioni o regole di confronto o compiere ulteriori passaggi per evitare parti del database che dipendono da esso.

Se decidi di sovraccaricare, potrebbe essere una buona idea ricostruire gli indici in base a funzioni o regole di confronto personalizzate nel caso in cui ottengano dati errati registrati lì, ad esempio:

-- Rebuild all indexes using this collation
REINDEX YOUR_COLLATION_NAME;

-- Rebuild particular index
REINDEX index_name;

-- Rebuild all indexes
REINDEX;

Prestazioni di funzioni e confronti definiti dall'applicazione

Le funzioni personalizzate o le regole di confronto sono molto più lente delle funzioni integrate:SQLite "ritorna" all'applicazione ogni volta che chiama la funzione. Puoi facilmente verificarlo aggiungendo un contatore globale alla funzione:

counter = 0

def casefold(a: str):
    global counter
    counter += 1
    return a.casefold()

# Work with the database

print(counter)
# Number of times the function has been called

Se esegui query raramente o il tuo database è piccolo, non vedrai alcuna differenza significativa. Tuttavia, se non si utilizza un indice su questa funzione/confronto, il database può eseguire un'analisi completa della tabella applicando la funzione/confronto su ciascuna riga. A seconda delle dimensioni del tavolo, dell'hardware e del numero di richieste, le basse prestazioni potrebbero sorprendere. Successivamente pubblicherò una revisione delle funzioni definite dall'applicazione e delle prestazioni delle regole di confronto.

A rigor di termini, le regole di confronto sono un po' più lente delle funzioni SQL poiché per ogni confronto devono piegare due stringhe invece di una. Anche se questa differenza è molto piccola:nei miei test, la funzione casefold è stata più veloce di confronti simili per circa il 25%, il che equivaleva a una differenza di 10 secondi dopo 100 milioni di iterazioni.

Indici e ricerca senza distinzione tra maiuscole e minuscole

Indici e funzioni

Partiamo dalle basi:se si definisce un indice su un qualsiasi campo, non verrà utilizzato nelle query su una funzione applicata a questo campo:

CREATE TABLE table_name (id INTEGER, name VARCHAR);
CREATE INDEX idx1 ON table_name (name);
EXPLAIN QUERY PLAN
    SELECT id, name FROM table_name WHERE LOWER(name) = 'test';
-- Output: SCAN TABLE table_name

Per tali query è necessario un indice separato con la funzione stessa:

CREATE INDEX idx1 ON table_name (LOWER(name));
EXPLAIN QUERY PLAN
    SELECT id, name 
        FROM table_name WHERE LOWER(name) = 'test';
-- Output: SEARCH TABLE table_name USING INDEX idx1 (<expr>=?)

In SQLite, può essere eseguito anche su una funzione personalizzata, ma deve essere contrassegnato come deterministico (il che significa che con gli stessi input restituisce lo stesso risultato):

connection.create_function(
    "CASEFOLD", 1, casefold, deterministic=True
)

Successivamente puoi creare un indice su una funzione SQL personalizzata:

CREATE INDEX idx1 
    ON table_name (CASEFOLD(name));
EXPLAIN QUERY PLAN
    SELECT id, name 
        FROM table_name WHERE CASEFOLD(name) = 'test';
-- Output: SEARCH TABLE table_name USING INDEX idx1 (<expr>=?)

Indici e regole di confronto

La situazione con le regole di confronto e gli indici è simile:affinché una query utilizzi un indice, è necessario che utilizzi la stessa confronto (implicita o fornita espressamente), altrimenti non funzionerà.

-- Table without specified collation will use BINARY
CREATE TABLE test (id INTEGER, text VARCHAR);

-- Create an index with a different collation
CREATE INDEX IF NOT EXISTS idx1 ON test (text COLLATE NOCASE);


-- Query will use default column collation -- BINARY
-- and the index will not be used
EXPLAIN QUERY PLAN
    SELECT * FROM test WHERE text = 'test';
-- Output: SCAN TABLE test


-- Now collations match and index is used
EXPLAIN QUERY PLAN
    SELECT * FROM test WHERE text = 'test' COLLATE NOCASE;
-- Output: SEARCH TABLE test USING INDEX idx1 (text=?)

Come indicato in precedenza, è possibile specificare le regole di confronto per una colonna nello schema della tabella. Questo è il modo più conveniente:verrà applicato automaticamente a tutte le query e gli indici nel rispettivo campo, a meno che non specifichi diversamente:

-- Using application defined collation UNICODE_NOCASE from above
CREATE TABLE test (text VARCHAR COLLATE UNICODE_NOCASE);

-- Index will be built using the collation
CREATE INDEX idx1 ON test (text);

-- Query will utilize index and collation automatically
EXPLAIN QUERY PLAN
    SELECT * FROM test WHERE text = 'something';
-- Output: SEARCH TABLE test USING COVERING INDEX idx1 (text=?)

Quale soluzione scegliere?

Per scegliere una soluzione abbiamo bisogno di alcuni criteri di confronto:

  1. Semplicità – quanto sia difficile implementarlo e mantenerlo

  2. Prestazioni – quanto saranno veloci le tue richieste

  3. Spazio extra – quanto spazio aggiuntivo nel database richiede la soluzione

  4. Accoppiamento – quanto la tua soluzione intreccia il codice e lo storage

Soluzione Semplicità Rendimento (relativo, senza indice) Spazio extra Accoppiamento
Estensione in terapia intensiva Difficile:richiede un nuovo tipo di dipendenza e compilazione Medio ad alto No
Fascicolazione personalizzata Semplice:consente di impostare le regole di confronto nello schema della tabella e applicarlo automaticamente a qualsiasi query sul campo Basso No
Funzione SQL personalizzata Medio:richiede la creazione di un indice basato su di esso o l'utilizzo in tutte le query pertinenti Basso No
Confronto nell'app Semplice Dipende dal caso d'uso No No
Memorizzazione di una stringa normalizzata Medio:devi mantenere aggiornata la stringa normalizzata Da basso a medio x2 No

Come al solito, la scelta della soluzione dipenderà dal caso d'uso e dalle richieste di prestazioni. Personalmente, andrei con le regole di confronto personalizzate, il confronto nell'app o la memorizzazione di una stringa normalizzata. Ad esempio, in listOK, ho usato prima una confronto e sono passato al confronto nell'app quando ho aggiunto la ricerca fuzzy.