Můj web

Vyhledávání bez diakritiky v Djangu

Publikováno 26.1.2014

Kdo někdy programoval určitě dosvědčí, kolik problémů nám přináší čeština se svými háčku a čárkami nad písmeny, potažmo další jazyky se svými národními akcenty. Jak snadno by se nám žilo se základní sadou ASCII znaků. Ale život není peříčko. Jak si poradit s vyhledáváním bez diakritiky ve světě relačních databází nevypadá jako zvlášť obtížná věc, ale nenechte se mýlit. Databázi je potřeba poněkud přemluvit, aby část práce udělala za vás.

O co nám tedy přesně jde? Např. pro hledaný výraz „bezet“ chci dostat i fráze obsahující text „běžet“ případně „bézet“ atd. Při rychlém psaní hledaného výrazu se mohu v diakritice splést nebo u cizích jazyků nevím jak má přesně být. A platí to i naopak – pokud zadám hledaný výraz s akcenty, měly by mi do výsledků spadnout i bezakcentové tvary. Jak řešit problém v Djangu s databází PostgreSQL?

Úprava PostgreSQL databáze

Do PostgreSQL je potřeba nainstalovat rozšíření unaccent. Způsobů instalace je několik – záleží, zda jsme DB přeložili ze zdrojových kódů nebo ji máme z balíčků linuxové distribuce apod. V Debianu se jedná o balíček postgres-contrib. Dále je nutné spustit pod superuživatelem na DB, ve které budeme vyhledávat, tyto SQL příkazy:

CREATE EXTENSION unaccent;
ALTER FUNCTION unaccent(text) IMMUTABLE;

Druhý příkaz je vlastně jen nutnou úpravou nesprávně definované funkce unaccent. Tímto zajistíme, aby se ve prohledávaném sloupci DB tabulky správně indexovalo. Teď už je tedy možné funkcí unaccent převádět v SQL příkazech jak sloupce, tak hledané výrazy na tvar bez diakritiky. Např.:

SELECT name FROM user WHERE unaccent(name) = unaccent('Štěpán');

Jak jistě mnohé napadne, je lepší ještě vyhledávání vylepšit funkcí UPPER, aby nám neutekly ani neshody v malých a velkých písmenech.

Nápady na řešení v Djangu

Pojďme tedy do Djanga. Zde si v jednoduchých případech s tímto vystačíme a můžeme cpát SQL příkazy do dotazu pomocí metody extra(). Ovšem my bychom chtěli problém vyřešit nějak čistěji a hlavně použitelněji například i pro related models. Knihovna django-unaccent, která by nás zachránila, již není vyvíjena a poslední podporovaná verze je Django 1.4. Její myšlenka je ale skvělá – definujme nový operátor, který bude provádět unaccent vyhledávání. Na webu najdeme i další nápady – například speciální funkci unaccent v SQLAlchemy. Což není ale dostatečně univerzální řešení, navíc z mojí zkušenosti není v Djangu 1.5.1 funkční.

Velkou inspirací pro vlastní řešení se stal tento návod – zde je ukázáno, jak aplikovat funkci unaccent na již existující operátor icontains. Zároveň jsou zde vidět úskalí dvou přístupů.

  • Buď musíme definovat nový DB adaptér
  • nebo zasáhneme do stávajícího, ale musíme počítat s tím, že přeskakujeme aktuální metodu lookup_cast().

Definování nového operátoru

Pojmenujme ho icontains_unaccent. Aby bylo možné tento operátor použít, budeme muset provést oproti výše zmíněnému řešení další úpravy v kódu.

Nejprve si nadefinujeme nový datový typ:

from django.db import models
class UnaccentCharField(models.CharField):
  def get_prep_lookup(self, lookup_type, value):
    if lookup_type in ('icontains_unaccent',):
      return value
    return super(UnaccentCharField, self).get_prep_lookup(lookup_type, value)

  def get_db_prep_lookup(self, lookup_type, value, connection, prepared=False):
    if lookup_type in ('icontains_unaccent',):
      return ["%%%s%%" % connection.ops.prep_for_like_query(value)]
    return super(UnaccentCharField, self).get_db_prep_lookup(lookup_type, value, connection, prepared)

Tyto dvě metody je nutné přetížit neboť v nich se musí vyskytovat podporované operátory. Datovou položku prohledávanou operátorem icontains_unaccent pak musíme definovat jako typ UnaccentCharField.

Nový operátor chceme dostat do všech částí aplikace, vložíme tedy tento kód někam, kde se vždy vykoná, např. do hlavního bootstrapu urls.py

from django.db.models.sql.constants import QUERY_TERMS
from django.contrib.gis.db.backends.postgis.base import DatabaseWrapper
from django.contrib.gis.db.backends.postgis.operations import PostGISOperations

def lookup_cast(self, lookup_type):
  if lookup_type in('icontains_unaccent',):
    return "UPPER(unaccent(%s::text))"
  else:
    return super(PostGISOperations, self).lookup_cast(lookup_type)

QUERY_TERMS.update(set(['icontains_unaccent', ]))
PostGISOperations.lookup_cast = lookup_cast
DatabaseWrapper.operators.update({ 'icontains_unaccent': 'LIKE UPPER(unaccent(%s))' })

V tomto případě používá aplikace DB adaptér django.contrib.gis.db.backends.postgis – tento tedy upravujeme. Můžeme si dovolit volání předka nově podstrčené metody lookup_cast(), protože samotný adaptér postgis tuto metedu nepřetěžuje a tedy v ní nemění chování operátorů. Totéž platí pro mysql backend. Problém by nastal pro backend postgres_psycopg2. V tom případě by toto řešení nebylo použitelné např. právě kvůli špatné funkčnosti operátoru icontains. Pak by nezbývalo než se vydat cestou vytvoření vlastního DB backendu. Ten by definoval nový DatabaseWrapper jako potomka stávajícího adaptéru. A pak bychom voláním pomocí super() nic neopomněli.

Nový operátor pak použijeme ve filtru takto:

User.objects.filter(name__icontains_unaccent='Štěpán')

Pozn.: Převod hledaného výrazu na tvar bez diakritiky bychom nemuseli požadovat po databázi, ale lze jej provést přímo v Pythonu. Ǔpravu celého řešení již nechávám laskavému čtenáři.

 

Komentáře

Komentáře jsou vypnuty.