SQLite
 sql >> Datenbank >  >> RDS >> SQLite

5 Möglichkeiten, die Suche ohne Berücksichtigung der Groß-/Kleinschreibung in SQLite mit vollständiger Unicode-Unterstützung zu implementieren

Kürzlich brauchte ich eine Suche ohne Berücksichtigung der Groß-/Kleinschreibung in SQLite, um zu überprüfen, ob ein Element mit demselben Namen bereits in einem meiner Projekte existiert – listOK. Zuerst sah es wie eine einfache Aufgabe aus, aber bei tieferem Eintauchen stellte sich heraus, dass es einfach, aber überhaupt nicht einfach war, mit vielen Drehungen und Wendungen.

Integrierte SQLite-Funktionen und ihre Nachteile

In SQLite können Sie auf drei Arten eine Suche ohne Berücksichtigung der Groß-/Kleinschreibung erhalten:

-- 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";

Wenn Sie SQLAlchemy und sein ORM verwenden, sehen diese Ansätze wie folgt aus:

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))

All diese Ansätze sind nicht ideal. Zuerst , ohne besondere Überlegungen verwenden sie keine Indizes für das Feld, an dem sie arbeiten, mit LIKE der schlimmste Übeltäter:In den meisten Fällen ist es nicht in der Lage, Indizes zu verwenden. Mehr zur Verwendung von Indizes für Abfragen ohne Berücksichtigung der Groß-/Kleinschreibung finden Sie weiter unten.

Zweiter , und was noch wichtiger ist, sie haben ein ziemlich begrenztes Verständnis dafür, was Groß- und Kleinschreibung bedeutet:

SQLite versteht standardmäßig nur Groß-/Kleinschreibung für ASCII-Zeichen. Der LIKE-Operator ist Groß-/Kleinschreibung standardmäßig für Unicode-Zeichen, die außerhalb des ASCII-Bereichs liegen. Beispielsweise ist der Ausdruck 'a' LIKE 'A' TRUE, aber 'æ' LIKE 'Æ' ist FALSE.

Es ist kein Problem, wenn Sie mit Zeichenfolgen arbeiten möchten, die nur Buchstaben, Zahlen usw. des englischen Alphabets enthalten. Ich brauchte das gesamte Unicode-Spektrum, also war eine bessere Lösung angesagt.

Im Folgenden fasse ich fünf Möglichkeiten zusammen, um in SQLite für alle Unicode-Symbole eine Suche/einen Vergleich ohne Berücksichtigung der Groß-/Kleinschreibung zu erreichen. Einige dieser Lösungen können an andere Datenbanken und für die Implementierung von Unicode-fähigem LIKE angepasst werden , REGEXP , MATCH , und andere Funktionen, obwohl diese Themen nicht Gegenstand dieses Beitrags sind.

Wir werden uns die Vor- und Nachteile jedes Ansatzes, Implementierungsdetails und schließlich Indizes und Leistungsüberlegungen ansehen.

Lösungen

1. Erweiterung der Intensivstation

Die offizielle SQLite-Dokumentation erwähnt die ICU-Erweiterung als Möglichkeit, vollständige Unterstützung für Unicode in SQLite hinzuzufügen. ICU steht für International Components for Unicode.

ICU löst die Probleme von sowohl Groß- als auch Kleinschreibung LIKE und Vergleich/Suche, plus Unterstützung für verschiedene Zusammenstellungen für ein gutes Maß. Es kann sogar schneller sein als einige der späteren Lösungen, da es in C geschrieben und enger in SQLite integriert ist.

Es bringt jedoch seine Herausforderungen mit sich:

  1. Es ist ein neuer Typ der Abhängigkeit:keine Python-Bibliothek, sondern eine Erweiterung, die zusammen mit der Anwendung verteilt werden sollte.

  2. ICU muss vor der Verwendung kompiliert werden, möglicherweise für verschiedene Betriebssysteme und Plattformen (nicht getestet).

  3. ICU implementiert selbst keine Unicode-Konvertierungen, sondern verlässt sich auf das unterstrichene Betriebssystem – ich habe mehrere Erwähnungen von betriebssystemspezifischen Problemen gesehen, insbesondere bei Windows und macOS.

Alle anderen Lösungen hängen von Ihrem Python-Code ab, um den Vergleich durchzuführen, daher ist es wichtig, den richtigen Ansatz zum Konvertieren und Vergleichen von Zeichenfolgen zu wählen.

Auswahl der richtigen Python-Funktion für den Vergleich ohne Berücksichtigung der Groß-/Kleinschreibung

Um einen Vergleich und eine Suche ohne Berücksichtigung der Groß-/Kleinschreibung durchzuführen, müssen wir Zeichenfolgen auf einen Fall normalisieren. Mein erster Instinkt war, str.lower() zu verwenden dafür. Es wird in den meisten Fällen funktionieren, aber es ist nicht der richtige Weg. Verwenden Sie besser str.casefold() (Dokumente):

Geben Sie eine gefaltete Kopie der Zeichenfolge zurück. Casefolded-Strings können für caseless-Matching verwendet werden.

Casefolding ist ähnlich wie Kleinschreibung, aber aggressiver, da es alle Fallunterscheidungen in einer Zeichenfolge entfernen soll. Der deutsche Kleinbuchstabe „ß“ entspricht beispielsweise „ss“. Da es bereits Kleinbuchstaben sind, lower() würde nichts mit 'ß' tun; casefold() wandelt es in "ss".

um

Daher verwenden wir im Folgenden den str.casefold() Funktion für alle Konvertierungen und Vergleiche.

2. Anwendungsdefinierte Sortierung

Um eine Suche ohne Berücksichtigung der Groß-/Kleinschreibung für alle Unicode-Symbole durchzuführen, müssen wir eine neue Sortierung in der Anwendung definieren, nachdem wir uns mit der Datenbank verbunden haben (Dokumentation). Hier haben Sie die Wahl – überladen Sie das eingebaute NOCASE oder erstellen Sie Ihre eigenen – wir werden die Vor- und Nachteile weiter unten besprechen. Als Beispiel verwenden wir einen neuen Namen:

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
)

Sortierungen haben gegenüber den nächsten Lösungen mehrere Vorteile:

  1. Sie sind einfach zu bedienen. Sie können die Sortierung im Tabellenschema angeben und sie wird automatisch auf alle Abfragen und Indizes in diesem Feld angewendet, sofern Sie nichts anderes angeben:

    CREATE TABLE test (text VARCHAR COLLATE UNICODE_NOCASE);
    

    Der Vollständigkeit halber sehen wir uns zwei weitere Möglichkeiten zur Verwendung von Sortierungen an:

    -- 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. Collation bietet Sortierung ohne Berücksichtigung der Groß-/Kleinschreibung mit ORDER BY aus der Kiste. Es ist besonders einfach zu bekommen, wenn Sie die Sortierung im Tabellenschema definieren.

Leistungsbezogene Sortierungen haben einige Besonderheiten, die wir weiter besprechen werden.

3. Anwendungsdefinierte SQL-Funktion

Eine andere Möglichkeit, eine Suche ohne Berücksichtigung der Groß-/Kleinschreibung zu erreichen, besteht darin, eine anwendungsdefinierte SQL-Funktion zu erstellen (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)

In beiden Fällen create_function akzeptiert bis zu vier Argumente:

  • Name der Funktion, wie er in den SQL-Abfragen verwendet wird
  • Anzahl der Argumente, die die Funktion akzeptiert
  • die Funktion selbst
  • optional bool deterministic , standardmäßig False (hinzugefügt in Python 3.8) – es ist wichtig für Indizes, auf die wir weiter unten eingehen werden.

Wie bei Sortierungen haben Sie die Wahl – die eingebaute Funktion zu überladen (z. B. LOWER ) oder neu erstellen. Wir werden später genauer darauf eingehen.

4. In der Anwendung vergleichen

Eine andere Möglichkeit der Suche ohne Berücksichtigung der Groß-/Kleinschreibung wäre der Vergleich in der App selbst, insbesondere wenn Sie die Suche durch die Verwendung eines Indexes für andere Felder eingrenzen könnten. Beispielsweise wird in listOK ein Vergleich ohne Berücksichtigung der Groß-/Kleinschreibung für Elemente in einer bestimmten Liste benötigt. Daher konnte ich alle Elemente in der Liste auswählen, sie auf einen Fall normalisieren und sie mit dem normalisierten neuen Element vergleichen.

Abhängig von Ihren Umständen ist dies keine schlechte Lösung, insbesondere wenn die Teilmenge, mit der Sie vergleichen, klein ist. Sie können jedoch keine Datenbankindizes für den Text verwenden, sondern nur für andere Parameter, die Sie verwenden, um den Umfang einzugrenzen.

Der Vorteil dieses Ansatzes liegt in der Flexibilität:In der Anwendung kann man nicht nur die Gleichheit prüfen, sondern zum Beispiel einen „Fuzzy“-Vergleich implementieren, um mögliche Druckfehler, Singular/Plural-Formen etc. zu berücksichtigen. Diesen Weg habe ich für listOK gewählt da der Bot einen Fuzzy-Vergleich für die "intelligente" Artikelerstellung benötigte.

Außerdem entfällt jede Kopplung mit der Datenbank – es ist ein einfacher Speicher, der nichts von den Daten weiß.

5. Normalisiertes Feld separat speichern

Es gibt noch eine weitere Lösung:Erstellen Sie eine separate Spalte in der Datenbank und behalten Sie dort den normalisierten Text bei, nach dem Sie suchen werden. Die Tabelle kann beispielsweise diese Struktur haben (nur relevante Felder):

id Name name_normalisiert
1 Großschreibung von Sätzen Großschreibung von Sätzen
2 GROSSBUCHSTABEN Großbuchstaben
3 Nicht-ASCII-Symbole:Найди Меня Nicht-ASCII-Symbole:найди меня

Dies mag auf den ersten Blick übertrieben erscheinen:Sie müssen die normalisierte Version immer auf dem neuesten Stand halten und die Größe des name effektiv verdoppeln Feld. Mit ORMs oder sogar manuell ist dies jedoch einfach und der Speicherplatz plus RAM ist relativ günstig.

Vorteile dieses Ansatzes:

  • Es entkoppelt Anwendung und Datenbank vollständig – Sie können ganz einfach wechseln.

  • Sie können normalisierte Dateien vorverarbeiten, wenn Ihre Abfragen dies erfordern (Trimmen, Entfernen von Satzzeichen oder Leerzeichen usw.).

Sollten Sie eingebaute Funktionen und Sortierungen überladen?

Bei der Verwendung von anwendungsdefinierten SQL-Funktionen und Sortierungen haben Sie häufig die Wahl:Verwenden Sie einen eindeutigen Namen oder überlasten Sie die integrierte Funktionalität. Beide Ansätze haben ihre Vor- und Nachteile in zwei Hauptdimensionen:

Erstens, Zuverlässigkeit/Vorhersagbarkeit wenn Sie aus irgendeinem Grund (ein einmaliger Fehler, Fehler oder absichtlich) diese Funktionen oder Sortierungen nicht registrieren:

  • Überladen:Die Datenbank funktioniert immer noch, aber die Ergebnisse sind möglicherweise nicht korrekt:

    • Die integrierte Funktion/Sortierung verhält sich anders als ihre benutzerdefinierten Gegenstücke;
    • Wenn Sie die jetzt fehlende Sortierung in einem Index verwendet haben, scheint es zu funktionieren, aber die Ergebnisse können sogar beim Lesen falsch sein;
    • Wenn die Tabelle mit Index und Index mit benutzerdefinierter Funktion/Sortierung aktualisiert wird, wird der Index möglicherweise beschädigt (wird mit integrierter Implementierung aktualisiert), funktioniert aber weiter, als ob nichts passiert wäre.
  • Nicht überladen:Die Datenbank wird in keiner Hinsicht funktionieren, wenn die fehlenden Funktionen oder Sortierungen verwendet werden:

    • Wenn Sie einen Index für eine fehlende Funktion verwenden, können Sie ihn zum Lesen verwenden, aber nicht für Aktualisierungen;
    • Indizes mit anwendungsdefinierter Sortierung funktionieren überhaupt nicht, da sie die Sortierung beim Suchen im Index verwenden.

Zweitens Zugänglichkeit außerhalb der Hauptanwendung:Migrationen, Analysen usw.:

  • Überlastung:Sie können die Datenbank problemlos ändern, wobei Sie das Risiko einer Beschädigung der Indizes berücksichtigen.

  • Nicht überladen:In vielen Fällen müssen Sie diese Funktionen oder Sortierungen registrieren oder zusätzliche Schritte unternehmen, um Teile der Datenbank zu vermeiden, die davon abhängen.

Wenn Sie sich für eine Überladung entscheiden, kann es eine gute Idee sein, Indizes basierend auf benutzerdefinierten Funktionen oder Sortierungen neu zu erstellen, falls dort falsche Daten aufgezeichnet werden, zum Beispiel:

-- Rebuild all indexes using this collation
REINDEX YOUR_COLLATION_NAME;

-- Rebuild particular index
REINDEX index_name;

-- Rebuild all indexes
REINDEX;

Ausführung von anwendungsdefinierten Funktionen und Sortierungen

Benutzerdefinierte Funktionen oder Sortierungen sind viel langsamer als integrierte Funktionen:SQLite kehrt jedes Mal zu Ihrer Anwendung zurück, wenn es die Funktion aufruft. Sie können dies leicht überprüfen, indem Sie der Funktion einen globalen Zähler hinzufügen:

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

Wenn Sie selten abfragen oder Ihre Datenbank klein ist, werden Sie keinen bedeutenden Unterschied feststellen. Wenn Sie jedoch keinen Index für diese Funktion/Sortierung verwenden, führt die Datenbank möglicherweise einen vollständigen Tabellenscan durch und wendet die Funktion/Sortierung auf jede Zeile an. Abhängig von der Größe der Tabelle, der Hardware und der Anzahl der Anfragen kann die geringe Leistung überraschen. Später werde ich einen Überblick über anwendungsdefinierte Funktionen und Kollationsleistung veröffentlichen.

Streng genommen sind Sortierungen etwas langsamer als SQL-Funktionen, da sie für jeden Vergleich zwei Strings statt einen casefolden müssen. Obwohl dieser Unterschied sehr gering ist:In meinen Tests war die Casefold-Funktion um etwa 25 % schneller als eine ähnliche Sortierung, was einem Unterschied von 10 Sekunden nach 100 Millionen Iterationen entspricht.

Indizes und Suche ohne Berücksichtigung der Groß-/Kleinschreibung

Indizes und Funktionen

Beginnen wir mit den Grundlagen:Wenn Sie einen Index für ein beliebiges Feld definieren, wird er nicht in Abfragen für eine Funktion verwendet, die auf dieses Feld angewendet wird:

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

Für solche Abfragen benötigen Sie einen eigenen Index mit der Funktion selbst:

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 kann dies auch für eine benutzerdefinierte Funktion durchgeführt werden, muss jedoch als deterministisch gekennzeichnet sein (was bedeutet, dass es mit denselben Eingaben dasselbe Ergebnis zurückgibt):

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

Danach können Sie einen Index für eine benutzerdefinierte SQL-Funktion erstellen:

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>=?)

Indizes und Sortierungen

Die Situation bei Sortierungen und Indizes ist ähnlich:Damit eine Abfrage einen Index verwenden kann, muss dieselbe Sortierung verwendet werden (implizit oder ausdrücklich bereitgestellt), andernfalls funktioniert es nicht.

-- 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=?)

Wie oben erwähnt, kann die Sortierung für eine Spalte im Tabellenschema angegeben werden. Dies ist der bequemste Weg – er wird automatisch auf alle Abfragen und Indizes für das entsprechende Feld angewendet, sofern Sie nichts anderes angeben:

-- 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=?)

Welche Lösung wählen?

Um eine Lösung auszuwählen, benötigen wir einige Vergleichskriterien:

  1. Einfachheit – wie schwierig es ist, es zu implementieren und zu warten

  2. Leistung – wie schnell Ihre Abfragen sein werden

  3. Zusätzlicher Platz – wie viel zusätzlicher Datenbankspeicherplatz die Lösung benötigt

  4. Kupplung – wie sehr Ihre Lösung den Code und die Speicherung miteinander verknüpft

Lösung Einfachheit Leistung (relativ, ohne Index) Zusätzlicher Platz Kupplung
ICU-Erweiterung Schwierig:erfordert eine neue Art von Abhängigkeit und Kompilierung Mittel bis hoch Nein Ja
Benutzerdefinierte Sortierung Einfach:ermöglicht die Sortierung im Tabellenschema festzulegen und automatisch auf jede Abfrage im Feld anzuwenden Niedrig Nein Ja
Benutzerdefinierte SQL-Funktion Mittel:erfordert entweder den Aufbau eines darauf basierenden Indexes oder die Verwendung in allen relevanten Abfragen Niedrig Nein Ja
Vergleich in der App Einfach Hängt vom Anwendungsfall ab Nein Nein
Normalisierten String speichern Mittel:Sie müssen die normalisierte Zeichenfolge auf dem neuesten Stand halten Niedrig bis Mittel x2 Nein

Wie üblich hängt die Wahl der Lösung von Ihrem Anwendungsfall und Ihren Leistungsanforderungen ab. Persönlich würde ich entweder eine benutzerdefinierte Sortierung verwenden, in der App vergleichen oder eine normalisierte Zeichenfolge speichern. Zum Beispiel habe ich in listOK zuerst eine Sortierung verwendet und bin zum Vergleichen in der App übergegangen, als ich die Fuzzy-Suche hinzugefügt habe.