SQLite
 sql >> Baza danych >  >> RDS >> SQLite

5 sposobów na zaimplementowanie wyszukiwania bez rozróżniania wielkości liter w SQLite z pełną obsługą Unicode

Ostatnio potrzebowałem wyszukiwania bez rozróżniania wielkości liter w SQLite, aby sprawdzić, czy element o tej samej nazwie już istnieje w jednym z moich projektów – listOK. Początkowo wydawało się to prostym zadaniem, ale po głębszym zanurzeniu okazało się łatwe, ale wcale nie proste, z wieloma zwrotami akcji.

Wbudowane możliwości SQLite i ich wady

W SQLite możesz uzyskać wyszukiwanie bez rozróżniania wielkości liter na trzy sposoby:

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

Jeśli używasz SQLAlchemy i jego ORM, te podejścia będą wyglądać następująco:

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

Wszystkie te podejścia nie są idealne. Pierwszy , bez specjalnych względów nie korzystają z indeksów na polu, nad którym pracują, z LIKE będąc najgorszym sprawcą:w większości przypadków nie jest w stanie posługiwać się indeksami. Więcej na temat korzystania z indeksów w zapytaniach bez rozróżniania wielkości liter znajduje się poniżej.

Drugi , a co ważniejsze, mają dość ograniczoną wiedzę na temat znaczenia bez uwzględniania wielkości liter:

SQLite domyślnie rozumie tylko wielkie/małe litery znaków ASCII. Operator LIKE rozróżnia wielkość liter domyślnie dla znaków Unicode, które są poza zakresem ASCII. Na przykład wyrażenie „a” JAK „A” to PRAWDA, ale „æ” JAK „Æ” to FAŁSZ.

Nie stanowi to problemu, jeśli planujesz pracować z ciągami zawierającymi tylko litery alfabetu angielskiego, cyfry itp. Potrzebowałem pełnego spektrum Unicode, więc lepsze rozwiązanie było w porządku.

Poniżej podsumowuję pięć sposobów na osiągnięcie wyszukiwania/porównywania wielkości liter w SQLite dla wszystkich symboli Unicode. Niektóre z tych rozwiązań można dostosować do innych baz danych oraz do implementacji kodu LIKE zgodnego ze standardem Unicode , REGEXP , MATCH i inne funkcje, chociaż te tematy są poza zakresem tego posta.

Przyjrzymy się zaletom i wadom każdego podejścia, szczegółom implementacji i wreszcie indeksom i rozważaniom dotyczącym wydajności.

Rozwiązania

1. Przedłużenie OIOM

Oficjalna dokumentacja SQLite wspomina o rozszerzeniu ICU jako sposobie na dodanie pełnej obsługi Unicode w SQLite. ICU to skrót od International Components for Unicode.

ICU rozwiązuje problemy obu niewrażliwych na wielkość liter LIKE i porównania/wyszukiwania, plus dodaje obsługę różnych sortowań dla dobrej miary. Może być nawet szybszy niż niektóre późniejsze rozwiązania, ponieważ jest napisany w C i jest ściślej zintegrowany z SQLite.

Jednak wiąże się to z wyzwaniami:

  1. To nowy typ zależności:nie biblioteka Pythona, ale rozszerzenie, które powinno być rozpowszechniane razem z aplikacją.

  2. ICU musi zostać skompilowany przed użyciem, potencjalnie dla różnych systemów operacyjnych i platform (nie testowane).

  3. ICU sam nie implementuje konwersji Unicode, ale opiera się na podkreślonym systemie operacyjnym – widziałem wiele wzmianek o problemach specyficznych dla systemu operacyjnego, zwłaszcza Windows i macOS.

Wszystkie inne rozwiązania będą zależeć od kodu Pythona do przeprowadzenia porównania, dlatego ważne jest, aby wybrać właściwe podejście do konwersji i porównywania ciągów.

Wybieranie odpowiedniej funkcji Pythona do porównywania bez rozróżniania wielkości liter

Aby przeprowadzić porównywanie i wyszukiwanie bez uwzględniania wielkości liter, musimy znormalizować ciągi do jednej wielkości liter. Moim pierwszym odruchem było użycie str.lower() dla tego. To zadziała w większości przypadków, ale nie jest to właściwy sposób. Lepiej użyć str.casefold() (dokumenty):

Zwróć złożoną kopię ciągu. Łańcuchy złożone z wielkości liter mogą być używane do dopasowywania bez wielkości liter.

Składanie liter jest podobne do małych liter, ale jest bardziej agresywne, ponieważ ma na celu usunięcie wszystkich różnic w wielkości liter w ciągu. Na przykład niemiecka mała litera „ß” jest odpowiednikiem „ss”. Ponieważ jest już małymi literami, lower() nie zrobi nic 'ß'; casefold() konwertuje go na "ss".

Dlatego poniżej użyjemy str.casefold() funkcja dla wszystkich konwersji i porównań.

2. Sortowanie zdefiniowane przez aplikację

Aby przeprowadzić wyszukiwanie bez rozróżniania wielkości liter dla wszystkich symboli Unicode, musimy zdefiniować nowe sortowanie w aplikacji po połączeniu się z bazą danych (dokumentacja). Tutaj masz wybór – przeciąż wbudowany NOCASE lub stwórz własną – omówimy zalety i wady poniżej. Dla przykładu użyjemy nowej nazwy:

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
)

Zestawienia mają kilka zalet w porównaniu z kolejnymi rozwiązaniami:

  1. Są łatwe w użyciu. Możesz określić sortowanie w schemacie tabeli i będzie ono automatycznie stosowane do wszystkich zapytań i indeksów w tym polu, chyba że określisz inaczej:

    CREATE TABLE test (text VARCHAR COLLATE UNICODE_NOCASE);
    

    Dla kompletności przyjrzyjmy się jeszcze dwóm sposobom używania sortowania:

    -- 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. Sortowanie zapewnia sortowanie bez rozróżniania wielkości liter za pomocą ORDER BY po wyjęciu z pudełka. Jest to szczególnie łatwe do uzyskania, jeśli zdefiniujesz sortowanie w schemacie tabeli.

Zestawienia pod kątem wydajności mają pewne cechy szczególne, które omówimy dalej.

3. Funkcja SQL zdefiniowana przez aplikację

Innym sposobem uzyskania wyszukiwania bez rozróżniania wielkości liter jest utworzenie zdefiniowanej przez aplikację funkcji SQL (dokumentacja):

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)

W obu przypadkach create_function akceptuje do czterech argumentów:

  • nazwa funkcji, jaka będzie używana w zapytaniach SQL
  • liczba argumentów, które funkcja akceptuje
  • sama funkcja
  • opcjonalny bool deterministic , domyślnie False (dodane w Pythonie 3.8) – ważne dla indeksów, które omówimy poniżej.

Podobnie jak w przypadku sortowania, masz wybór – przeciąż wbudowaną funkcję (np. LOWER ) lub utwórz nowy. Przyjrzymy się temu bardziej szczegółowo później.

4. Porównaj w aplikacji

Innym sposobem wyszukiwania bez uwzględniania wielkości liter byłoby porównywanie w samej aplikacji, zwłaszcza jeśli można zawęzić wyszukiwanie za pomocą indeksu w innych polach. Na przykład, w listOK potrzebne jest porównanie bez rozróżniania wielkości liter dla pozycji na określonej liście. Dlatego mogłem wybrać wszystkie pozycje z listy, znormalizować je do jednego przypadku i porównać je ze znormalizowanym nowym elementem.

W zależności od okoliczności nie jest to złe rozwiązanie, zwłaszcza jeśli podzbiór, z którym będziesz porównywać, jest niewielki. Jednak nie będziesz w stanie wykorzystać indeksów bazy danych na tekście, a jedynie na innych parametrach, których będziesz używać do zawężenia zakresu.

Zaletą tego podejścia jest jego elastyczność:w aplikacji można sprawdzić nie tylko równość, ale np. zaimplementować „rozmyte” porównanie uwzględniające ewentualne błędy drukarskie, formy liczby pojedynczej/mnogiej itp. Taką drogę wybrałem dla listOK ponieważ bot potrzebował rozmytych porównań do tworzenia "inteligentnych" przedmiotów.

Ponadto eliminuje wszelkie sprzężenia z bazą danych – jest to proste przechowywanie, które nie wie nic o danych.

5. Przechowuj znormalizowane pole osobno

Jest jeszcze jedno rozwiązanie:utwórz osobną kolumnę w bazie danych i trzymaj tam znormalizowany tekst, którego będziesz szukać. Na przykład tabela może mieć taką strukturę (tylko odpowiednie pola):

id imię name_normalized
1 Wielkość zdania wielkość zdań
2 WIELKIE LITERY wielkimi literami
3 Symbole spoza zestawu ASCII:Найди Меня symbole inne niż ASCII:найди меня

Na pierwszy rzut oka może się to wydawać przesadne:zawsze musisz aktualizować znormalizowaną wersję i skutecznie podwajać rozmiar name pole. Jednak za pomocą ORM lub nawet ręcznie jest to łatwe, a miejsce na dysku i pamięć RAM są tanie.

Zalety tego podejścia:

  • Całkowicie oddziela aplikację i bazę danych – możesz łatwo się przełączać.

  • Możesz wstępnie przetworzyć znormalizowane pliki, jeśli wymagają tego Twoje zapytania (przycinanie, usuwanie znaków interpunkcyjnych lub spacji itp.).

Czy należy przeciążać wbudowane funkcje i sortowanie?

Używając zdefiniowanych przez aplikację funkcji SQL i sortowania, często masz wybór:użyj unikalnej nazwy lub przeciąż wbudowaną funkcjonalność. Oba podejścia mają swoje zalety i wady w dwóch głównych wymiarach:

Po pierwsze, niezawodność/przewidywalność gdy z jakiegoś powodu (jednorazowy błąd, błąd lub celowo) nie zarejestrujesz tych funkcji lub zestawień:

  • Przeciążenie:baza danych będzie nadal działać, ale wyniki mogą nie być poprawne:

    • wbudowana funkcja/porównanie będzie zachowywać się inaczej niż ich niestandardowe odpowiedniki;
    • jeśli użyłeś teraz nieobecnego sortowania w indeksie, będzie wyglądało na to, że działa, ale wyniki mogą być błędne nawet podczas czytania;
    • Jeśli tabela z indeksem i indeksem przy użyciu funkcji niestandardowej/porównania zostanie zaktualizowana, indeks może ulec uszkodzeniu (zaktualizowany przy użyciu wbudowanej implementacji), ale kontynuuj pracę tak, jakby nic się nie stało.
  • Nie przeciążanie:baza danych nie będzie działać pod żadnym względem, gdy używane są nieobecne funkcje lub sortowania:

    • jeśli użyjesz indeksu dla nieobecnej funkcji, będziesz mógł użyć go do czytania, ale nie do aktualizacji;
    • indeksy z sortowaniem zdefiniowanym przez aplikację w ogóle nie będą działać, ponieważ używają one sortowania podczas wyszukiwania w indeksie.

Po drugie, dostępność poza główną aplikacją:migracje, analityka itp.:

  • Przeciążenie:będziesz mógł bez problemu modyfikować bazę danych, pamiętając o ryzyku uszkodzenia indeksów.

  • Nie przeciążanie:w wielu przypadkach będziesz musiał zarejestrować te funkcje lub sortowanie lub podjąć dodatkowe kroki, aby uniknąć części bazy danych, które od nich zależą.

Jeśli zdecydujesz się na przeciążenie, dobrym pomysłem może być przebudowanie indeksów na podstawie niestandardowych funkcji lub sortowania na wypadek, gdyby zostały tam zapisane nieprawidłowe dane, na przykład:

-- Rebuild all indexes using this collation
REINDEX YOUR_COLLATION_NAME;

-- Rebuild particular index
REINDEX index_name;

-- Rebuild all indexes
REINDEX;

Wydajność funkcji i zestawień zdefiniowanych przez aplikację

Funkcje niestandardowe lub sortowanie są znacznie wolniejsze niż funkcje wbudowane:SQLite "powraca" do aplikacji za każdym razem, gdy wywołuje funkcję. Możesz to łatwo sprawdzić, dodając do funkcji licznik globalny:

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

Jeśli rzadko wysyłasz zapytania lub Twoja baza danych jest mała, nie zobaczysz żadnej znaczącej różnicy. Jeśli jednak nie użyjesz indeksu dla tej funkcji/porównania, baza danych może wykonać pełne skanowanie tabeli, stosując funkcję/porównanie w każdym wierszu. W zależności od wielkości tabeli, sprzętu i liczby żądań, niska wydajność może być zaskoczeniem. Później opublikuję przegląd funkcji zdefiniowanych przez aplikację i wydajności sortowania.

Ściśle mówiąc, sortowanie jest nieco wolniejsze niż funkcje SQL, ponieważ dla każdego porównania muszą składać dwa łańcuchy zamiast jednego. Chociaż ta różnica jest bardzo mała:w moich testach funkcja casefold była szybsza niż podobne zestawienie dla około 25%, co stanowiło różnicę 10 sekund po 100 milionach iteracji.

Indeksy i wyszukiwanie bez rozróżniania wielkości liter

Indeksy i funkcje

Zacznijmy od podstaw:jeśli zdefiniujesz indeks w dowolnym polu, nie będzie on używany w zapytaniach dotyczących funkcji zastosowanej do tego pola:

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

Do takich zapytań potrzebny jest osobny indeks z samą funkcją:

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

W SQLite można to zrobić również na funkcji niestandardowej, ale musi być ona oznaczona jako deterministyczna (co oznacza, że ​​przy tych samych danych wejściowych zwraca ten sam wynik):

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

Następnie możesz utworzyć indeks dla niestandardowej funkcji SQL:

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

Indeksy i zestawienia

Sytuacja z sortowaniem i indeksami jest podobna:aby zapytanie korzystało z indeksu, musi używać tego samego sortowania (dorozumianego lub podanego wprost), w przeciwnym razie nie zadziała.

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

Jak wspomniano powyżej, sortowanie można określić dla kolumny w schemacie tabeli. To najwygodniejszy sposób – zostanie automatycznie zastosowany do wszystkich zapytań i indeksów w odpowiednim polu, chyba że określisz inaczej:

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

Które rozwiązanie wybrać?

Aby wybrać rozwiązanie, potrzebujemy kilku kryteriów porównawczych:

  1. Prostota – jak trudno jest go wdrożyć i utrzymać

  2. Wydajność – jak szybko będą Twoje zapytania

  3. Dodatkowa przestrzeń – ile dodatkowej przestrzeni bazy danych wymaga rozwiązanie

  4. Sprzęganie – jak bardzo Twoje rozwiązanie splata kod i pamięć

Rozwiązanie Prostota Wydajność (względna, bez indeksu) Dodatkowa przestrzeń Sprzęganie
Rozszerzenie OIOM Trudne:wymaga nowego typu zależności i kompilacji Średni do wysokiego Nie Tak
Sortowanie niestandardowe Proste:pozwala ustawić sortowanie w schemacie tabeli i zastosować je automatycznie do dowolnego zapytania w polu Niski Nie Tak
Niestandardowa funkcja SQL Średni:wymaga zbudowania indeksu na jego podstawie lub użycia we wszystkich odpowiednich zapytaniach Niski Nie Tak
Porównywanie w aplikacji Proste Zależy od przypadku użycia Nie Nie
Przechowywanie znormalizowanego ciągu Średni:musisz aktualizować znormalizowany ciąg Niski do średniego x2 Nie

Jak zwykle wybór rozwiązania będzie zależał od przypadku użycia i wymagań dotyczących wydajności. Osobiście wybrałbym niestandardowe sortowanie, porównywanie w aplikacji lub przechowywanie znormalizowanego ciągu. Na przykład w listOK najpierw użyłem sortowania i przeszedłem do porównywania w aplikacji po dodaniu wyszukiwania rozmytego.


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. SQLite Listview onclick filtruje bazę danych, aby otworzyć wynik w nowej aktywności

  2. Jak wybrać konkretną kolumnę z bazy danych pokoi podając konkretny parametr w zapytaniu o pokój?

  3. Instalacja SQLite

  4. SQLite JSON_ARRAY()

  5. Jak dodać miesiąc do daty w SQLite