For nylig havde jeg brug for en søgning uden store og små bogstaver i SQLite for at kontrollere, om et element med samme navn allerede eksisterer i et af mine projekter - listOK. Til at begynde med lignede det en simpel opgave, men ved dybere dyk viste det sig at være let, men slet ikke simpelt, med mange drejninger og drejninger.
Indbyggede SQLite-funktioner og deres ulemper
I SQLite kan du få en søgning uden store og små bogstaver på tre måder:
-- 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";
Hvis du bruger SQLAlchemy og dets ORM, vil disse tilgange se ud som følger:
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))
Alle disse tilgange er ikke ideelle. Først , uden særlige hensyn gør de ikke brug af indekser på det felt, de arbejder på, med LIKE
at være den værste gerningsmand:i de fleste tilfælde er den ude af stand til at bruge indekser. Mere om brugen af indekser til case-ufølsomme forespørgsler er nedenfor.
Anden , og endnu vigtigere, de har en ret begrænset forståelse af, hvad der ikke er følsomme for store og små bogstaver:
SQLite forstår kun store/små bogstaver for ASCII-tegn som standard. LIKE-operatoren skifter mellem store og små bogstaver som standard for unicode-tegn, der er uden for ASCII-området. For eksempel er udtrykket 'a' LIKE 'A' SAND, men 'æ' LIKE 'Æ' er FALSK.
Det er ikke et problem, hvis du planlægger at arbejde med strenge, der kun indeholder bogstaver i det engelske alfabet, tal osv. Jeg havde brug for hele Unicode-spektret, så en bedre løsning var på sin plads.
Nedenfor opsummerer jeg fem måder at opnå versalsufølsom søgning/sammenligning i SQLite for alle Unicode-symboler. Nogle af disse løsninger kan tilpasses til andre databaser og til implementering af Unicode-bevidst LIKE
, REGEXP
, MATCH
, og andre funktioner, selvom disse emner er uden for dette indlægs omfang.
Vi vil se på fordele og ulemper ved hver tilgang, implementeringsdetaljer og endelig på indekser og præstationsovervejelser.
Løsninger
1. ICU forlængelse
Officiel SQLite-dokumentation nævner ICU-udvidelsen som en måde at tilføje komplet support til Unicode i SQLite. ICU står for International Components for Unicode.
ICU løser problemerne med både store og små bogstaver LIKE
og sammenligning/søgning, plus tilføjer understøttelse af forskellige sorteringer for en god foranstaltning. Det kan endda være hurtigere end nogle af de senere løsninger, da det er skrevet i C og er tættere integreret med SQLite.
Det kommer dog med sine udfordringer:
-
Det er en ny type af afhængighed:ikke et Python-bibliotek, men en udvidelse, der skal distribueres sammen med applikationen.
-
ICU skal kompileres før brug, muligvis for forskellige OS og platforme (ikke testet).
-
ICU implementerer ikke selv Unicode-konverteringer, men er afhængig af det understregede operativsystem – jeg har set flere omtaler af OS-specifikke problemer, især med Windows og macOS.
Alle andre løsninger vil afhænge af din Python-kode for at udføre sammenligningen, så det er vigtigt at vælge den rigtige tilgang til konvertering og sammenligning af strenge.
Valg af den rigtige python-funktion til sammenligning uden forskel på store og små bogstaver
For at udføre kasus-ufølsom sammenligning og søgning er vi nødt til at normalisere strenge til ét tilfælde. Mit første instinkt var at bruge str.lower()
for det. Det vil virke i de fleste tilfælde, men det er ikke den rigtige måde. Bedre at bruge str.casefold()
(dokumenter):
Returner en casefoldet kopi af strengen. Casefoldede strenge kan bruges til kasseløs matchning.
Casefolding ligner små bogstaver, men mere aggressiv, fordi den er beregnet til at fjerne alle case-forskelle i en streng. For eksempel svarer det tyske lille bogstav 'ß' til "ss". Da det allerede er små bogstaver, lower()
ville ikke gøre noget ved 'ß'; casefold()
konverterer det til "ss".
Derfor vil vi nedenfor bruge str.casefold()
funktion for alle konverteringer og sammenligninger.
2. Applikationsdefineret sortering
For at udføre en søgning uden store og små bogstaver efter alle Unicode-symboler skal vi definere en ny sortering i applikationen efter at have oprettet forbindelse til databasen (dokumentation). Her har du et valg – overbelast den indbyggede NOCASE
eller lav din egen – vi vil diskutere fordele og ulemper nedenfor. For et eksempel vil vi bruge et nyt navn:
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
)
Kollationer har flere fordele i forhold til de næste løsninger:
-
De er nemme at bruge. Du kan angive sortering i tabelskemaet, og det vil automatisk blive anvendt på alle forespørgsler og indekser i dette felt, medmindre du angiver andet:
CREATE TABLE test (text VARCHAR COLLATE UNICODE_NOCASE);
Lad os for fuldstændighedens skyld se på yderligere to måder at bruge sammenstillinger på:
-- 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=?)
-
Sortering giver uafhængig sortering med
ORDER BY
ud af boksen. Det er især nemt at få, hvis du definerer sorteringen i tabelskemaet.
Præstationsmæssige sammenstillinger har nogle ejendommeligheder, som vi vil diskutere yderligere.
3. Applikationsdefineret SQL-funktion
En anden måde at opnå case-usensitiv søgning på er at oprette en applikationsdefineret SQL-funktion (dokumentation):
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)
I begge tilfælde create_function
accepterer op til fire argumenter:
- navnet på funktionen, som den vil blive brugt i SQL-forespørgslerne
- antal argumenter, som funktionen accepterer
- selve funktionen
- valgfri bool
deterministic
, standardFalse
(tilføjet i Python 3.8) – det er vigtigt for indekser, som vi vil diskutere nedenfor.
Som med sorteringer har du et valg – overbelast den indbyggede funktion (f.eks. LOWER
) eller opret ny. Vi vil se nærmere på det senere.
4. Sammenlign i applikationen
En anden måde at søge uafhængig af store og små bogstaver på ville være at sammenligne i selve appen, især hvis du kunne indsnævre søgningen ved at bruge et indeks på andre felter. For eksempel, i listOK kræves en sammenligning uden forskel på store og små bogstaver for elementer på en bestemt liste. Derfor kunne jeg vælge alle elementer på listen, normalisere dem til én sag og sammenligne dem med det normaliserede nye element.
Afhængigt af dine omstændigheder er det ikke en dårlig løsning, især hvis den delmængde, du vil sammenligne med, er lille. Du vil dog ikke være i stand til at bruge databaseindekser på teksten, kun på andre parametre, du vil bruge til at indsnævre omfanget.
Fordelen ved denne tilgang er dens fleksibilitet:I applikationen kan du ikke kun kontrollere lighed, men for eksempel implementere "fuzzy" sammenligning for at tage højde for mulige trykfejl, entals-/pluralformer osv. Dette er den rute, jeg valgte til listOK da botten havde brug for uklar sammenligning for at skabe den "smarte" genstand.
Derudover eliminerer det enhver kobling med databasen – det er simpel lagring, der ikke ved noget om dataene.
5. Gem normaliseret felt separat
Der er en løsning mere:Opret en separat kolonne i databasen og behold den normaliserede tekst, du vil søge på. Tabellen kan f.eks. have denne struktur (kun relevante felter):
id | navn | navn_normaliseret |
---|---|---|
1 | Sætning med store bogstaver | bogstaver i sætning |
2 | STORE BOGSTAVER | store bogstaver |
3 | Ikke-ASCII-symboler:Найди Меня | ikke-ascii-symboler:найди меня |
Dette kan se overdrevent ud i starten:du skal altid holde den normaliserede version opdateret og effektivt fordoble størrelsen af name
Mark. Men med ORM'er eller endda manuelt er det nemt at gøre, og diskpladsen plus RAM er relativ billig.
Fordele ved denne tilgang:
-
Det adskiller applikationen og databasen fuldstændigt – du kan nemt skifte.
-
Du kan forbehandle normaliserede filer, hvis dine forespørgsler kræver det (trimme, fjerne tegnsætning eller mellemrum osv.).
Skal du overbelaste indbyggede funktioner og sorteringer?
Når du bruger applikationsdefinerede SQL-funktioner og kollationer, har du ofte et valg:Brug et unikt navn eller overbelaste indbygget funktionalitet. Begge tilgange har deres fordele og ulemper i to hoveddimensioner:
For det første pålidelighed/forudsigelighed når du af en eller anden grund (en engangsfejl, fejl eller med vilje) ikke registrerer disse funktioner eller sammenstillinger:
-
Overbelastning:databasen vil stadig fungere, men resultaterne er muligvis ikke korrekte:
- den indbyggede funktion/sortering vil opføre sig anderledes end deres tilpassede modparter;
- hvis du brugte nu fraværende sortering i et indeks, ser det ud til at virke, men resultaterne kan være forkerte, selv når du læser;
- hvis tabellen med indeks og indeks ved hjælp af tilpasset funktion/sortering bliver opdateret, kan indekset blive beskadiget (opdateret ved hjælp af indbygget implementering), men fortsætte med at arbejde, som om intet var hændt.
-
Overbelaster ikke:Databasen fungerer ikke på nogen måde, hvor de fraværende funktioner eller sammenstillinger bruges:
- hvis du bruger et indeks på en fraværende funktion, vil du kunne bruge det til læsning, men ikke til opdateringer;
- indekser med applikationsdefineret sortering virker slet ikke, da de bruger sorteringen, mens de søger i indekset.
For det andet tilgængelighed uden for hovedapplikationen:migreringer, analyser osv.:
-
Overbelastning:du vil være i stand til at ændre databasen uden problemer, mens du husker på risikoen for korrupte indekser.
-
Overbelaster ikke:I mange tilfælde bliver du nødt til at registrere disse funktioner eller sammenstillinger eller tage ekstra skridt for at undgå dele af databasen, der afhænger af den.
Hvis du beslutter dig for at overbelaste, kan det være en god idé at genopbygge indekser baseret på brugerdefinerede funktioner eller sorteringer, hvis de får forkerte data registreret der, for eksempel:
-- Rebuild all indexes using this collation
REINDEX YOUR_COLLATION_NAME;
-- Rebuild particular index
REINDEX index_name;
-- Rebuild all indexes
REINDEX;
Ydelse af applikationsdefinerede funktioner og sorteringer
Brugerdefinerede funktioner eller sortering er meget langsommere end indbyggede funktioner:SQLite "vender tilbage" til din applikation, hver gang den kalder funktionen. Du kan nemt tjekke det ved at tilføje en global tæller til funktionen:
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
Hvis du sjældent spørger, eller din database er lille, vil du ikke se nogen meningsfuld forskel. Men hvis du ikke bruger et indeks på denne funktion/sortering, kan databasen udføre en komplet tabelscanning ved at anvende funktionen/sorteringen på hver række. Afhængigt af bordets størrelse, hardware og antallet af anmodninger kan den lave ydeevne være overraskende. Senere vil jeg udgive en gennemgang af applikationsdefinerede funktioner og sammenstillingsydelse.
Strengt taget er kollationer en smule langsommere end SQL-funktioner, da de for hver sammenligning skal folde to strenge med små bogstaver i stedet for én. Selvom denne forskel er meget lille:i mine test var casefold-funktionen hurtigere end tilsvarende sortering med omkring 25 %, hvilket svarede til en forskel på 10 sekunder efter 100 millioner iterationer.
Indekser og ufølsom søgning
Indekser og funktioner
Lad os starte med det grundlæggende:Hvis du definerer et indeks på et hvilket som helst felt, vil det ikke blive brugt i forespørgsler på en funktion, der anvendes til dette felt:
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
Til sådanne forespørgsler har du brug for et separat indeks med selve funktionen:
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>=?)
I SQLite kan det også gøres på en brugerdefineret funktion, men den skal markeres som deterministisk (hvilket betyder, at den med de samme input returnerer det samme resultat):
connection.create_function(
"CASEFOLD", 1, casefold, deterministic=True
)
Derefter kan du oprette et indeks på en brugerdefineret SQL-funktion:
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>=?)
Indekser og sammenstillinger
Situationen med sorteringer og indekser er den samme:for at en forespørgsel kan bruge et indeks, skal de bruge den samme sortering (underforstået eller udtrykkeligt angivet), ellers vil det ikke fungere.
-- 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=?)
Som nævnt ovenfor kan sortering angives for en kolonne i tabelskemaet. Dette er den mest bekvemme måde – den vil automatisk blive anvendt på alle forespørgsler og indekser på det respektive felt, medmindre du angiver andet:
-- 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=?)
Hvilken løsning skal man vælge?
For at vælge en løsning har vi brug for nogle kriterier til sammenligning:
-
Enkelhed – hvor svært det er at implementere og vedligeholde det
-
Ydeevne – hvor hurtige dine forespørgsler vil være
-
Ekstra plads – hvor meget ekstra databaseplads løsningen kræver
-
Kobling – hvor meget din løsning fletter koden og lageret sammen
Løsning | Enkelhed | Ydeevne (relativ, uden indeks) | Ekstra plads | Kobling |
---|---|---|---|---|
ICU-udvidelse | Svært:kræver en ny type afhængighed og kompilering | Middel til høj | Nej | Ja |
Tilpasset sortering | Simpelt:gør det muligt at indstille sortering i tabelskemaet og anvende det automatisk på enhver forespørgsel i feltet | Lav | Nej | Ja |
Tilpasset SQL-funktion | Medium:kræver enten opbygning af et indeks baseret på det eller brug i alle relevante forespørgsler | Lav | Nej | Ja |
Sammenligning i appen | Simpelt | Afhænger af use case | Nej | Nej |
Gemmer normaliseret streng | Medium:du skal holde den normaliserede streng opdateret | Lav til medium | x2 | Nej |
Som sædvanligt vil valget af løsning afhænge af din brugssituation og ydeevnekrav. Personligt ville jeg enten gå med tilpasset sortering, sammenligne i appen eller gemme en normaliseret streng. I listOK brugte jeg f.eks. først en sortering og gik over til sammenligning i appen, da jeg tilføjede fuzzy søgning.