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:
-
É 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.
-
O ICU precisa ser compilado antes do uso, potencialmente para diferentes sistemas operacionais e plataformas (não testado).
-
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:
-
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=?)
-
O agrupamento fornece classificação sem distinção entre maiúsculas e minúsculas comORDER 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ãoFalse
(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:
-
Simplicidade – quão difícil é implementá-lo e mantê-lo
-
Desempenho – quão rápido suas consultas serão
-
Espaço extra – quanto espaço de banco de dados adicional a solução requer
-
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.