SQLite
 sql >> Base de Dados >  >> RDS >> SQLite

5 maneiras de implementar a pesquisa que não diferencia maiúsculas de minúsculas no SQLite com suporte total a Unicode


Recentemente precisei de uma pesquisa que não diferencia maiúsculas de minúsculas no SQLite para verificar se já existe um item com o mesmo nome em um dos meus projetos – listOK. A princípio, parecia uma tarefa simples, mas ao mergulhar mais fundo, acabou sendo fácil, mas nada simples, com muitas reviravoltas.

Recursos SQLite integrados e suas desvantagens


No SQLite, você pode obter uma pesquisa que não diferencia maiúsculas de minúsculas de três maneiras:

-- 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 você usar SQLAlchemy e seu ORM, essas abordagens terão a seguinte aparência:

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

Todas essas abordagens não são ideais. Primeiro , sem considerações especiais eles não fazem uso de índices no campo em que estão trabalhando, com LIKE sendo o pior infrator:na maioria dos casos, é incapaz de usar índices. Mais informações sobre o uso de índices para consultas que não diferenciam maiúsculas de minúsculas estão abaixo.

Segundo , e mais importante, eles têm uma compreensão bastante limitada do que significa que não diferencia maiúsculas de minúsculas:

SQLite só entende maiúsculas/minúsculas para caracteres ASCII por padrão. O operador LIKE é diferencia maiúsculas de minúsculas por padrão para caracteres unicode que estão além do intervalo ASCII. Por exemplo, a expressão 'a' LIKE 'A' é TRUE mas 'æ' LIKE 'Æ' é FALSE.

Não é um problema se você planeja trabalhar com strings que contêm apenas letras do alfabeto inglês, números, etc. Eu precisava do espectro Unicode completo, então uma solução melhor estava em ordem.

Abaixo, resumi cinco maneiras de obter pesquisa/comparação insensível a maiúsculas e minúsculas no SQLite para todos os símbolos Unicode. Algumas dessas soluções podem ser adaptadas a outros bancos de dados e para implementar LIKE com reconhecimento de Unicode , REGEXP , MATCH , e outras funções, embora esses tópicos estejam fora do escopo deste post.

Veremos os prós e contras de cada abordagem, detalhes de implementação e, finalmente, índices e considerações de desempenho.

Soluções

1. Extensão UTI


A documentação oficial do SQLite menciona a extensão ICU como uma forma de adicionar suporte completo para Unicode no SQLite. ICU significa Componentes Internacionais para Unicode.

O ICU resolve os problemas de LIKE que não diferenciam maiúsculas de minúsculas e comparação/pesquisa, além de adicionar suporte para diferentes agrupamentos para uma boa medida. Pode até ser mais rápido do que algumas das soluções posteriores, pois é escrito em C e está mais integrado ao SQLite.

No entanto, ele vem com seus desafios:

  1. É um novo tipo de dependência:não uma biblioteca Python, mas uma extensão que deve ser distribuída junto com a aplicação.

  2. O ICU precisa ser compilado antes do uso, potencialmente para diferentes sistemas operacionais e plataformas (não testado).

  3. O ICU não implementa conversões Unicode, mas depende do sistema operacional sublinhado – já vi várias menções a problemas específicos do sistema operacional, especialmente com Windows e macOS.

Todas as outras soluções dependerão do seu código Python para realizar a comparação, por isso é importante escolher a abordagem correta para converter e comparar strings.

Escolhendo a função python correta para comparação que não diferencia maiúsculas de minúsculas


Para realizar comparação e pesquisa sem distinção entre maiúsculas e minúsculas, precisamos normalizar as strings para um caso. Meu primeiro instinto foi usar str.lower() por esta. Funcionará na maioria das circunstâncias, mas não é a maneira correta. Melhor usar str.casefold() (documentos):

Retorne uma cópia em maiúsculas e minúsculas da string. Strings dobradas em maiúsculas podem ser usadas para correspondência sem maiúsculas e minúsculas.

A dobragem de maiúsculas e minúsculas é semelhante a letras minúsculas, mas mais agressiva porque se destina a remover todas as distinções de maiúsculas e minúsculas em uma string. Por exemplo, a letra minúscula alemã 'ß' é equivalente a "ss". Como já está em minúsculas, lower() não faria nada para 'ß'; casefold() converte para "ss".

Portanto, abaixo usaremos o str.casefold() função para todas as conversões e comparações.

2. Agrupamento definido pelo aplicativo


Para realizar uma pesquisa sem distinção entre maiúsculas e minúsculas para todos os símbolos Unicode, precisamos definir um novo agrupamento no aplicativo após a conexão com o banco de dados (documentação). Aqui você tem uma escolha - sobrecarregue o NOCASE embutido ou crie o seu próprio - discutiremos os prós e contras abaixo. A título de exemplo, usaremos um novo 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
)

Os agrupamentos têm várias vantagens em comparação com as próximas soluções:

  1. Eles são fáceis de usar. Você pode especificar o agrupamento no esquema da tabela e ele será aplicado automaticamente a todas as consultas e índices neste campo, a menos que você especifique o contrário:

    CREATE TABLE test (text VARCHAR COLLATE UNICODE_NOCASE);
    

    Por uma questão de completude, vamos ver mais duas maneiras de usar agrupamentos:

    -- 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. O agrupamento fornece classificação sem distinção entre maiúsculas e minúsculas com ORDER BY sai da caixa. É especialmente fácil de obter se você definir o agrupamento no esquema da tabela.

Os agrupamentos de desempenho têm algumas peculiaridades, que discutiremos mais adiante.

3. Função SQL definida pelo aplicativo


Outra maneira de obter uma pesquisa que não diferencia maiúsculas de minúsculas é criar uma função SQL definida pelo aplicativo (documentação):

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)

Em ambos os casos create_function aceita até quatro argumentos:
  • nome da função como ela será usada nas consultas SQL
  • número de argumentos que a função aceita
  • a própria função
  • bool opcional deterministic , padrão False (adicionado no Python 3.8) – é importante para índices, que discutiremos abaixo.

Tal como acontece com os agrupamentos, você tem uma escolha – sobrecarregar a função interna (por exemplo, LOWER ) ou crie um novo. Veremos isso com mais detalhes posteriormente.

4. Compare no aplicativo


Outra forma de pesquisa que não diferencia maiúsculas de minúsculas seria comparar no próprio aplicativo, especialmente se você puder restringir a pesquisa usando um índice em outros campos. Por exemplo, em listOK, a comparação que não diferencia maiúsculas de minúsculas é necessária para itens em uma lista específica. Portanto, eu poderia selecionar todos os itens da lista, normalizá-los para um caso e compará-los com o novo item normalizado.

Dependendo de suas circunstâncias, não é uma solução ruim, especialmente se o subconjunto com o qual você comparará for pequeno. No entanto, você não poderá utilizar índices de banco de dados no texto, apenas em outros parâmetros que você usará para restringir o escopo.

A vantagem desta abordagem é a sua flexibilidade:na aplicação pode verificar não só a igualdade mas, por exemplo, implementar a comparação "fuzzy" para ter em conta possíveis erros de impressão, formas singular/plural, etc. Esta é a rota que escolhi para listOK já que o bot precisava de comparação difusa para a criação do item "inteligente".

Além disso, elimina qualquer acoplamento com o banco de dados – é um armazenamento simples que não sabe nada sobre os dados.

5. Armazene o campo normalizado separadamente


Há mais uma solução:crie uma coluna separada no banco de dados e mantenha o texto normalizado no qual você estará pesquisando. Por exemplo, a tabela pode ter esta estrutura (apenas campos relevantes):
id nome name_normalized
1 Maiúsculas da frase maiúsculas da frase
2 LETRAS MAIÚSCULAS letras maiúsculas
3 Símbolos não ASCII:Найди Меня símbolos não-ascii:найди меня

Isso pode parecer excessivo no início:você sempre precisa manter a versão normalizada atualizada e efetivamente dobrar o tamanho do name campo. No entanto, com ORMs ou mesmo manualmente é fácil de fazer e o espaço em disco mais RAM é relativamente barato.

Vantagens desta abordagem:

  • Ele dissocia completamente o aplicativo e o banco de dados – você pode alternar facilmente.

  • Você pode pré-processar o arquivo normalizado se suas consultas exigirem (recortar, remover pontuação ou espaços, etc.).

Você deve sobrecarregar funções e agrupamentos internos?


Ao usar funções e agrupamentos SQL definidos pelo aplicativo, você geralmente tem uma opção:usar um nome exclusivo ou sobrecarregar a funcionalidade interna. Ambas as abordagens têm seus prós e contras em duas dimensões principais:

Primeiro, confiabilidade/previsibilidade quando por algum motivo (um erro pontual, bug ou intencionalmente) você não registra essas funções ou agrupamentos:

  • Sobrecarga:o banco de dados ainda funcionará, mas os resultados podem não estar corretos:
    • a função/agrupamento integrado se comportará de maneira diferente de suas contrapartes personalizadas;
    • se você usou o agrupamento agora ausente em um índice, ele parecerá funcionar, mas os resultados podem estar errados mesmo durante a leitura;
    • se a tabela com índice e índice usando a função/agrupamento personalizado for atualizada, o índice poderá ficar corrompido (atualizado usando a implementação integrada), mas continue funcionando como se nada tivesse acontecido.

  • Não sobrecarregar:o banco de dados não funcionará em nenhum aspecto onde as funções ou agrupamentos ausentes forem usados:
    • se você usar um índice em uma função ausente, poderá usá-lo para leitura, mas não para atualizações;
    • índices com ordenação definida pelo aplicativo não funcionarão, pois eles usam a ordenação ao pesquisar no índice.

Segundo, acessibilidade fora do aplicativo principal:migrações, análises, etc.:

  • Sobrecarga:você poderá modificar o banco de dados sem problemas, tendo em mente o risco de corromper os índices.

  • Sem sobrecarga:em muitos casos, você precisará registrar essas funções ou agrupamentos ou realizar etapas extras para evitar partes do banco de dados que dependem dele.

Se você decidir sobrecarregar, pode ser uma boa ideia reconstruir índices com base em funções personalizadas ou agrupamentos, caso eles obtenham dados incorretos registrados lá, por exemplo:

-- Rebuild all indexes using this collation
REINDEX YOUR_COLLATION_NAME;

-- Rebuild particular index
REINDEX index_name;

-- Rebuild all indexes
REINDEX;

Desempenho de funções e agrupamentos definidos pelo aplicativo


Funções personalizadas ou agrupamento são muito mais lentas do que funções internas:SQLite "retorna" ao seu aplicativo cada vez que chama a função. Você pode verificar facilmente adicionando um contador global à função:

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 você estiver consultando raramente ou seu banco de dados for pequeno, você não verá nenhuma diferença significativa. No entanto, se você não usar um índice nesta função/agrupação, o banco de dados poderá executar uma varredura completa da tabela aplicando a função/agrupação em cada linha. Dependendo do tamanho da tabela, do hardware e do número de solicitações, o baixo desempenho pode ser surpreendente. Mais tarde, publicarei uma revisão das funções definidas pelo aplicativo e do desempenho de agrupamentos.

Estritamente falando, os agrupamentos são um pouco mais lentos que as funções SQL, pois para cada comparação eles precisam dobrar duas strings, em vez de uma. Embora essa diferença seja muito pequena:em meus testes, a função casefold foi mais rápida que o agrupamento semelhante por cerca de 25%, o que representou uma diferença de 10 segundos após 100 milhões de iterações.

Índices e pesquisa que não diferencia maiúsculas de minúsculas

Índices e funções


Vamos começar com o básico:se você definir um índice em qualquer campo, ele não será usado em consultas em uma função aplicada a este 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

Para essas consultas, você precisa de um índice separado com a própria função:

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

No SQLite, isso também pode ser feito em uma função personalizada, mas deve ser marcado como determinístico (ou seja, com as mesmas entradas retorna o mesmo resultado):

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

Depois disso, você pode criar um índice em uma função SQL personalizada:

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

Índices e agrupamentos


A situação com collations e índices é semelhante:para que uma consulta utilize um índice, ela deve usar a mesma collation (implícita ou fornecida expressamente), caso contrário, não funcionará.

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

Conforme observado acima, o agrupamento pode ser especificado para uma coluna no esquema da tabela. Essa é a maneira mais conveniente - ela será aplicada a todas as consultas e índices no respectivo campo automaticamente, a menos que você especifique o contrário:

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

Qual solução escolher?


Para escolher uma solução precisamos de alguns critérios de comparação:

  1. Simplicidade – quão difícil é implementá-lo e mantê-lo

  2. Desempenho – quão rápido suas consultas serão

  3. Espaço extra – quanto espaço de banco de dados adicional a solução requer

  4. Acoplamento – o quanto sua solução entrelaça o código e o armazenamento
Solução Simplicidade Desempenho (relativo, sem índice) Espaço extra Acoplamento
Extensão UTI Difícil:requer um novo tipo de dependência e compilação Médio a alto Não Sim
Agrupamento personalizado Simples:permite definir o agrupamento no esquema da tabela e aplicá-lo automaticamente a qualquer consulta no campo Baixo Não Sim
Função SQL personalizada Médio:requer a construção de um índice com base nele ou o uso em todas as consultas relevantes Baixo Não Sim
Comparação no aplicativo Simples Depende do caso de uso Não Não
Armazenando string normalizada Médio:você precisa manter a string normalizada atualizada Baixo a Médio x2 Não

Como de costume, a escolha da solução dependerá do seu caso de uso e das demandas de desempenho. Pessoalmente, eu usaria o agrupamento personalizado, comparando no aplicativo ou armazenando uma string normalizada. Por exemplo, em listOK, usei primeiro um agrupamento e mudei para comparar no aplicativo quando adicionei a pesquisa difusa.