Recientemente, necesité una búsqueda sin distinción de mayúsculas y minúsculas en SQLite para verificar si ya existe un elemento con el mismo nombre en uno de mis proyectos:listOK. Al principio, parecía una tarea sencilla, pero luego de una inmersión más profunda, resultó ser fácil, pero nada simple, con muchos giros y vueltas.
Capacidades de SQLite integradas y sus inconvenientes
En SQLite puede obtener una búsqueda que no distingue entre mayúsculas y minúsculas de tres maneras:
-- 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";
Si usa SQLAlchemy y su ORM, estos enfoques se verán de la siguiente manera:
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))
Todos estos enfoques no son ideales. Primero , sin consideraciones especiales no hacen uso de índices sobre el campo en el que están trabajando, con LIKE
siendo el peor infractor:en la mayoría de los casos es incapaz de utilizar índices. A continuación encontrará más información sobre el uso de índices para consultas que no distinguen entre mayúsculas y minúsculas.
Segundo y, lo que es más importante, tienen una comprensión bastante limitada de lo que significa que no se distingue entre mayúsculas y minúsculas:
SQLite solo entiende mayúsculas y minúsculas para caracteres ASCII de forma predeterminada. El operador LIKE es sensible a mayúsculas y minúsculas por defecto para caracteres Unicode que están más allá del rango ASCII. Por ejemplo, la expresión 'a' COMO 'A' es VERDADERO pero 'æ' COMO 'Æ' es FALSO.
No es un problema si planea trabajar con cadenas que contienen solo letras del alfabeto inglés, números, etc. Necesitaba el espectro completo de Unicode, por lo que necesitaba una mejor solución.
A continuación, resumo cinco formas de lograr una búsqueda/comparación insensible a mayúsculas y minúsculas en SQLite para todos los símbolos Unicode. Algunas de estas soluciones se pueden adaptar a otras bases de datos y para implementar LIKE
compatible con Unicode , REGEXP
, MATCH
, y otras funciones, aunque estos temas están fuera del alcance de esta publicación.
Analizaremos los pros y los contras de cada enfoque, los detalles de implementación y, finalmente, los índices y las consideraciones de rendimiento.
Soluciones
1. Ampliación de la UCI
La documentación oficial de SQLite menciona la extensión ICU como una forma de agregar soporte completo para Unicode en SQLite. ICU significa Componentes Internacionales para Unicode.
ICU resuelve los problemas de LIKE
que no distingue entre mayúsculas y minúsculas y comparación/búsqueda, además agrega soporte para diferentes intercalaciones para una buena medida. Incluso puede ser más rápido que algunas de las soluciones posteriores, ya que está escrito en C y está más integrado con SQLite.
Sin embargo, viene con sus desafíos:
-
Es un nuevo tipo de dependencia:no una biblioteca de Python, sino una extensión que debe distribuirse junto con la aplicación.
-
ICU debe compilarse antes de su uso, potencialmente para diferentes sistemas operativos y plataformas (no probado).
-
ICU no implementa conversiones Unicode, sino que se basa en el sistema operativo subrayado. He visto varias menciones de problemas específicos del sistema operativo, especialmente con Windows y macOS.
Todas las demás soluciones dependerán de su código Python para realizar la comparación, por lo que es importante elegir el enfoque correcto para convertir y comparar cadenas.
Elegir la función de python correcta para la comparación sin distinción entre mayúsculas y minúsculas
Para realizar comparaciones y búsquedas que no distinguen entre mayúsculas y minúsculas, necesitamos normalizar las cadenas a un caso. Mi primer instinto fue usar str.lower()
para esto. Funcionará en la mayoría de las circunstancias, pero no es la forma adecuada. Mejor usar str.casefold()
(documentos):
Devuelve una copia plegada de la cadena. Las cadenas con mayúsculas y minúsculas se pueden usar para coincidencias sin mayúsculas y minúsculas.
Casefolding es similar a minúsculas pero más agresivo porque está diseñado para eliminar todas las distinciones de mayúsculas y minúsculas en una cadena. Por ejemplo, la letra minúscula alemana 'ß' es equivalente a "ss". Como ya está en minúsculas, lower()
no le haría nada a 'ß'; casefold()
lo convierte a "ss".
Por lo tanto, a continuación usaremos el str.casefold()
función para todas las conversiones y comparaciones.
2. Intercalación definida por la aplicación
Para realizar una búsqueda sin distinción entre mayúsculas y minúsculas para todos los símbolos Unicode, debemos definir una nueva intercalación en la aplicación después de conectarnos a la base de datos (documentación). Aquí tiene una opción:sobrecargue el NOCASE
incorporado o cree el suyo propio; discutiremos los pros y los contras a continuación. Por el bien de un ejemplo, usaremos un nuevo nombre:
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
)
Las intercalaciones tienen varias ventajas en comparación con las siguientes soluciones:
-
Son fáciles de usar. Puede especificar la intercalación en el esquema de la tabla y se aplicará automáticamente a todas las consultas e índices en este campo a menos que especifique lo contrario:
CREATE TABLE test (text VARCHAR COLLATE UNICODE_NOCASE);
En aras de la exhaustividad, veamos dos formas más de usar intercalaciones:
-- 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=?)
-
La intercalación proporciona una clasificación que no distingue entre mayúsculas y minúsculas con
ORDER BY
fuera de la caja. Es especialmente fácil de obtener si define la intercalación en el esquema de la tabla.
Las intercalaciones de rendimiento tienen algunas peculiaridades, de las que hablaremos más adelante.
3. Función SQL definida por la aplicación
Otra forma de lograr una búsqueda que no distinga entre mayúsculas y minúsculas es crear una función SQL definida por la aplicación (documentación):
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)
En ambos casos create_function
acepta hasta cuatro argumentos:
- nombre de la función tal como se usará en las consultas SQL
- número de argumentos que acepta la función
- la función en sí
- bool opcional
deterministic
, predeterminadoFalse
(agregado en Python 3.8):es importante para los índices, que discutiremos a continuación.
Al igual que con las intercalaciones, tiene una opción:sobrecargar la función integrada (por ejemplo, LOWER
) o crear uno nuevo. Lo veremos con más detalle más adelante.
4. Comparar en la aplicación
Otra forma de búsqueda que no distingue entre mayúsculas y minúsculas sería comparar en la propia aplicación, especialmente si pudiera reducir la búsqueda mediante el uso de un índice en otros campos. Por ejemplo, en listOK se necesita una comparación que no distinga entre mayúsculas y minúsculas para los elementos de una lista en particular. Por lo tanto, pude seleccionar todos los elementos de la lista, normalizarlos a un caso y compararlos con el nuevo elemento normalizado.
Dependiendo de sus circunstancias, no es una mala solución, especialmente si el subconjunto con el que comparará es pequeño. Sin embargo, no podrá utilizar índices de bases de datos en el texto, solo en otros parámetros que usará para reducir el alcance.
La ventaja de este enfoque es su flexibilidad:en la aplicación puede verificar no solo la igualdad sino, por ejemplo, implementar una comparación "difusa" para tener en cuenta posibles errores tipográficos, formas singulares/plurales, etc. Esta es la ruta que elegí para listOK ya que el bot necesitaba una comparación aproximada para la creación de elementos "inteligentes".
Además, elimina cualquier acoplamiento con la base de datos:es un almacenamiento simple que no sabe nada sobre los datos.
5. Almacene el campo normalizado por separado
Hay una solución más:cree una columna separada en la base de datos y mantenga allí el texto normalizado que buscará. Por ejemplo, la tabla puede tener esta estructura (solo campos relevantes):
id | nombre | nombre_normalizado |
---|---|---|
1 | Enunciado en mayúsculas | mayúsculas de oraciones |
2 | LETRAS MAYÚSCULAS | letras mayúsculas |
3 | Símbolos no ASCII:Найди Меня | símbolos no ascii:найди меня |
Esto puede parecer excesivo al principio:siempre debe mantener actualizada la versión normalizada y duplicar efectivamente el tamaño del name
campo. Sin embargo, con ORM o incluso manualmente, es fácil de hacer y el espacio en disco más RAM es relativamente barato.
Ventajas de este enfoque:
-
Desvincula por completo la aplicación y la base de datos; puede cambiar fácilmente.
-
Puede preprocesar el archivo normalizado si sus consultas lo requieren (recortar, eliminar puntuación o espacios, etc.).
¿Debería sobrecargar las intercalaciones y las funciones integradas?
Cuando se usan intercalaciones y funciones SQL definidas por la aplicación, a menudo tiene una opción:usar un nombre único o sobrecargar la funcionalidad integrada. Ambos enfoques tienen sus pros y sus contras en dos dimensiones principales:
Primero, confiabilidad/previsibilidad cuando por alguna razón (un error puntual, bug o intencionalmente) no registre estas funciones o cotejos:
-
Sobrecarga:la base de datos seguirá funcionando, pero los resultados pueden no ser correctos:
- la intercalación/función integrada se comportará de manera diferente a sus contrapartes personalizadas;
- si usó la intercalación ahora ausente en un índice, parecerá que funciona, pero los resultados pueden ser incorrectos incluso durante la lectura;
- si se actualiza la tabla con el índice y el índice que usa la intercalación/función personalizada, el índice puede corromperse (actualizarse usando la implementación integrada), pero seguirá funcionando como si nada.
-
Sin sobrecarga:la base de datos no funcionará en ningún aspecto donde se utilicen las funciones o intercalaciones ausentes:
- si usa un índice en una función ausente, podrá usarlo para leer, pero no para actualizar;
- los índices con intercalación definida por la aplicación no funcionarán en absoluto, ya que usan la intercalación mientras buscan en el índice.
Segundo, accesibilidad fuera de la aplicación principal:migraciones, análisis, etc.:
-
Sobrecarga:podrás modificar la base de datos sin problema, teniendo en cuenta el riesgo de corromper los índices.
-
No sobrecargar:en muchos casos, deberá registrar estas funciones o intercalaciones o tomar medidas adicionales para evitar partes de la base de datos que dependan de ellas.
Si decide sobrecargar, puede ser una buena idea reconstruir los índices en función de funciones personalizadas o intercalaciones en caso de que se registren datos incorrectos allí, por ejemplo:
-- Rebuild all indexes using this collation
REINDEX YOUR_COLLATION_NAME;
-- Rebuild particular index
REINDEX index_name;
-- Rebuild all indexes
REINDEX;
Rendimiento de intercalaciones y funciones definidas por la aplicación
Las funciones personalizadas o la intercalación son mucho más lentas que las funciones integradas:SQLite "regresa" a su aplicación cada vez que llama a la función. Puede verificarlo fácilmente agregando un contador global a la función:
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
Si consulta con poca frecuencia o su base de datos es pequeña, no verá ninguna diferencia significativa. Sin embargo, si no usa un índice en esta función/intercalación, la base de datos puede realizar un escaneo completo de la tabla aplicando la función/intercalación en cada fila. Según el tamaño de la tabla, el hardware y la cantidad de solicitudes, el bajo rendimiento puede sorprender. Más adelante publicaré una revisión de las funciones definidas por la aplicación y el rendimiento de las intercalaciones.
Estrictamente hablando, las intercalaciones son un poco más lentas que las funciones de SQL, ya que para cada comparación necesitan plegar dos cadenas, en lugar de una. Aunque esta diferencia es muy pequeña:en mis pruebas, la función casefold fue más rápida que una intercalación similar en alrededor del 25 %, lo que equivalió a una diferencia de 10 segundos después de 100 millones de iteraciones.
Índices y búsqueda que no distingue entre mayúsculas y minúsculas
Índices y funciones
Comencemos con lo básico:si define un índice en cualquier campo, no se utilizará en consultas sobre una función 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 tales consultas, necesita un índice separado con la función en sí:
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>=?)
En SQLite, también se puede hacer en una función personalizada, pero debe marcarse como determinista (lo que significa que con las mismas entradas devuelve el mismo resultado):
connection.create_function(
"CASEFOLD", 1, casefold, deterministic=True
)
Después de eso, puede crear un índice en una función 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 y colaciones
La situación con intercalaciones e índices es similar:para que una consulta utilice un índice, debe usar la misma intercalación (implícita o proporcionada expresamente), de lo contrario, no 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=?)
Como se indicó anteriormente, la intercalación se puede especificar para una columna en el esquema de la tabla. Esta es la forma más conveniente:se aplicará automáticamente a todas las consultas e índices en el campo respectivo, a menos que especifique lo contrario:
-- 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=?)
¿Qué solución elegir?
Para elegir una solución necesitamos algunos criterios de comparación:
-
Simplicidad – lo difícil que es implementarlo y mantenerlo
-
Rendimiento – qué tan rápido serán sus consultas
-
Espacio adicional – cuánto espacio de base de datos adicional requiere la solución
-
Acoplamiento – cuánto entrelaza su solución el código y el almacenamiento
Solución | Simplicidad | Rendimiento (relativo, sin índice) | Espacio adicional | Acoplamiento |
---|---|---|---|---|
Extensión de UCI | Difícil:requiere un nuevo tipo de dependencia y compilación | Medio a alto | No | Sí |
Intercalación personalizada | Simple:permite establecer la intercalación en el esquema de la tabla y aplicarlo automáticamente a cualquier consulta en el campo | Bajo | No | Sí |
Función SQL personalizada | Medio:requiere crear un índice basado en él o usarlo en todas las consultas relevantes | Bajo | No | Sí |
Comparación en la aplicación | Simple | Depende del caso de uso | No | No |
Almacenamiento de cadenas normalizadas | Medio:debe mantener actualizada la cadena normalizada | Baja a media | x2 | No |
Como de costumbre, la elección de la solución dependerá de su caso de uso y de las demandas de rendimiento. Personalmente, iría con una intercalación personalizada, comparando en la aplicación o almacenando una cadena normalizada. Por ejemplo, en listOK, primero usé una intercalación y pasé a comparar en la aplicación cuando agregué la búsqueda aproximada.