Customização do Django ORM no exemplo do ZomboDB

Frequentemente, ao trabalhar com Django e PostgreSQL, são necessárias extensões adicionais para o banco de dados. E se, por exemplo, com hstore ou PostGIS (graças ao GeoDjango) tudo é bastante conveniente, com extensões mais raras - como pgRouting, ZomboDB, etc. - você precisa escrever no RawSQL ou personalizar o Django ORM. O que proponho neste artigo é fazê-lo usando o ZomboDB como exemplo e seu tutorial de introdução. E, ao mesmo tempo, vamos considerar como você pode conectar o ZomboDB a um projeto Django.


O PostgreSQL tem sua própria pesquisa em texto completo e funciona, a julgar pelos últimos benchmarks, muito rapidamente. Mas seus recursos em busca ainda deixam muito a desejar. Como resultado, sem as soluções baseadas em Lucene - ElasticSearch, por exemplo - é difícil. O ElasticSearch possui um banco de dados próprio, que realiza uma pesquisa. A principal solução no momento é o controle manual da consistência dos dados entre o PostgreSQL e o ElasticSearch usando sinais ou funções de retorno de chamada manual.


O ZomboDB é uma extensão que implementa seu próprio tipo de índice, transformando o valor da tabela em um ponteiro para o ElasticSearch , que permite pesquisas de tabela de texto completo usando o ElasticSearch DSL como parte da sintaxe SQL.


No momento da redação deste artigo, uma pesquisa na rede não produziu nenhum resultado. De artigos sobre Habré sobre o ZomboDB, apenas um . Não há artigos sobre a integração do ZomboDB e Django.


A descrição do ZomboDB diz que as chamadas para o Elasticsearch passam pela API RESTful; portanto, o desempenho está em dúvida, mas agora não vamos tocá-lo. Também problemas de remoção correta do ZomboDB sem perda de dados.


Em seguida, realizaremos todos os testes no Docker , para coletarmos um pequeno arquivo de composição do docker


docker-compose.yaml
version: '3' services: postgres: build: docker/postgres environment: - POSTGRES_USER=django - POSTGRES_PASSWORD=123456 - POSTGRES_DB=zombodb - PGDATA=/home/postgresql/data ports: - 5432:5432 # sudo sysctl -w vm.max_map_count=262144 elasticsearch: image: elasticsearch:6.5.4 environment: - cluster.name=zombodb - bootstrap.memory_lock=true - ES_JAVA_OPTS=-Xms512m -Xmx512m ulimits: memlock: soft: -1 hard: -1 ports: - 9200:9200 django: build: docker/python command: python3 manage.py runserver 0.0.0.0:8000 volumes: - ./:/home/ ports: - 8000:8000 depends_on: - postgres - elasticsearch 

A versão mais recente do ZomboDB funciona com a 10ª versão máxima do Postgres e requer curl de dependências (suponho que você faça consultas no ElasticSearch).


 FROM postgres:10 WORKDIR /home/ RUN apt-get -y update && apt-get -y install curl ADD https://www.zombodb.com/releases/v10-1.0.3/zombodb_jessie_pg10-10-1.0.3_amd64.deb ./ RUN dpkg -i zombodb_jessie_pg10-10-1.0.3_amd64.deb RUN rm zombodb_jessie_pg10-10-1.0.3_amd64.deb RUN apt-get -y clean 

O contêiner para o Django é típico. Nele, colocaremos apenas as versões mais recentes do Django e psycopg2.


 FROM python:stretch WORKDIR /home/ RUN pip3 install --no-cache-dir django psycopg2-binary 

O ElasticSearch no Linux não inicia com as configurações básicas de vm.max_map_count, portanto, teremos que aumentá-las um pouco (quem sabe como automatizar isso através do docker - escreva nos comentários).


 sudo sysctl -w vm.max_map_count=262144 

Portanto, o ambiente de teste está pronto. Você pode ir para o projeto no Django. Não vou dar como um todo; quem quiser pode vê-lo no repositório no GitLab . Vou me debruçar apenas sobre pontos críticos.


A primeira coisa que precisamos fazer é conectar o ZomboDB como uma extensão no PostgreSQL. Obviamente, você pode se conectar ao banco de dados e ativar a extensão através do SQL CREATE EXTENSION zombodb; . Você pode até usar o docker-entrypoint-initdb.d hook no contêiner oficial do Postgres para isso. Mas como temos o Django, seguiremos o caminho dele.
Depois de criar o projeto e criar a primeira migração, adicione uma conexão de extensão a ele.


 from django.db import migrations, models from django.contrib.postgres.operations import CreateExtension class Migration(migrations.Migration): initial = True dependencies = [ ] operations = [ CreateExtension('zombodb'), ] 

Em segundo lugar, precisamos de um modelo que descreva o padrão de teste. Para fazer isso, precisamos de um campo que funcione com o tipo de dados zdb.fulltext. Bem, vamos escrever o seu . Como esse tipo de dado para django se comporta da mesma forma que o texto nativo do postgresql, quando criamos nosso campo, herdamos nossa classe de models.TextField. Além disso, duas coisas importantes precisam ser feitas: desativar a capacidade de usar o índice Btree nesse campo e restringir o back-end para o banco de dados. O resultado final é o seguinte:


 class ZomboField(models.TextField): description = "Alias for Zombodb field" def __init__(self, *args, **kwargs): kwargs['db_index'] = False super().__init__(*args, **kwargs) def db_type(self, connection): databases = [ 'django.db.backends.postgresql_psycopg2', 'django.db.backends.postgis' ] if connection.settings_dict['ENGINE'] in databases: return 'zdb.fulltext' else: raise TypeError('This database not support') 

Terceiro, explique ao ZomboDB onde procurar nosso ElasticSearch. No próprio banco de dados, um índice personalizado do ZomboDB é usado para essa finalidade. Portanto, se o endereço for alterado, o índice deverá ser alterado.
O Django nomeia tabelas de acordo com o padrão app_model: no nosso caso, o aplicativo é chamado main e o modelo é article. elasticsearch é o nome do DNS que o docker atribui pelo nome do contêiner.
No SQL, fica assim:


 CREATE INDEX idx_main_article ON main_article USING zombodb ((main_article.*)) WITH (url='elasticsearch:9200/'); 

No Django, também precisamos criar um índice personalizado. Os índices ainda não são muito flexíveis: em particular, o índice zombodb não indica uma coluna específica, mas a tabela inteira. No Django, um índice requer uma referência de campo obrigatória. Então substituí o statement.parts['columns'] por ((main_article.*)) , Mas os métodos de construção e desconstrução ainda exigem que você especifique o atributo de campos ao criar o campo. Também precisamos passar um parâmetro adicional para os parâmetros. Por que substituir o get_with_params __init__ , deconstruct e get_with_params .
Em geral, o design acabou funcionando. As migrações são aplicadas e canceladas sem problemas.


 class ZomboIndex(models.Index): def __init__(self, *, url=None, **kwargs): self.url = url super().__init__(**kwargs) def create_sql(self, model, schema_editor, using=''): statement = super().create_sql(model, schema_editor, using=' USING zombodb') statement.parts['columns'] = '(%s.*)' % model._meta.db_table with_params = self.get_with_params() if with_params: statement.parts['extra'] = " WITH (%s) %s" % ( ', '.join(with_params), statement.parts['extra'], ) print(statement) return statement def deconstruct(self): path, args, kwargs = super().deconstruct() if self.url is not None: kwargs['url'] = self.url return path, args, kwargs def get_with_params(self): with_params = [] if self.url: with_params.append("url='%s'" % self.url) return with_params 

Aqueles que não gostam dessa abordagem podem usar migrações do RunSQL adicionando diretamente um índice. Só precisa acompanhar o nome da tabela e indexar-se.


 migrations.RunSQL( sql = ( "CREATE INDEX idx_main_article " "ON main_article " "USING zombodb ((main_article.*)) " "WITH (url='elasticsearch:9200/');" ), reverse_sql='DROP INDEX idx_main_article' ) 

O resultado é esse modelo. O ZomboField aceita os mesmos argumentos que o TextField, com uma exceção - index_db não afeta nada, assim como o atributo de campos no ZomboIndex.


 class Article(models.Model): text = ZomboField() class Meta: indexes = [ ZomboIndex(url='elasticsearch:9200/', name='zombo_idx', fields=['text']) ] 

Por fim, o arquivo de migração deve ficar assim:


 from django.db import migrations, models from django.contrib.postgres.operations import CreateExtension import main.models class Migration(migrations.Migration): initial = True dependencies = [ ] operations = [ CreateExtension('zombodb'), migrations.CreateModel( name='Article', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('text', main.models.ZomboField()), ], ), migrations.AddIndex( model_name='article', index=main.models.ZomboIndex(fields=['text'], name='zombo_idx', url='elasticsearch:9200/'), ) ] 

Para os interessados, eu incluo o SQL que produz o Django ORM (você pode examinar o sqlmigrate , bem, ou levando em consideração o docker: sudo docker-compose exec django python3 manage.py sqlmigrate main 0001 )


 BEGIN; -- -- Creates extension zombodb -- CREATE EXTENSION IF NOT EXISTS "zombodb"; -- -- Create model Article -- CREATE TABLE "main_article" ("id" serial NOT NULL PRIMARY KEY, "text" zdb.fulltext NOT NULL); -- -- Create index zombo_idx on field(s) text of model article -- CREATE INDEX "zombo_idx" ON "main_article" USING zombodb ((main_article.*)) WITH (url='elasticsearch:9200/') ; COMMIT; 

Então, nós temos um modelo. Resta agora fazer uma pesquisa através do filtro. Para fazer isso, descreva sua pesquisa e registre-a.


 @ZomboField.register_lookup class ZomboSearch(models.Lookup): lookup_name = 'zombo_search' def as_sql(self, compiler, connection): lhs, lhs_params = self.process_lhs(compiler, connection) rhs, rhs_params = self.process_rhs(compiler, connection) params = lhs_params + rhs_params return "%s ==> %s" % (lhs.split('.')[0], rhs), params 

A pesquisa neste caso terá a seguinte aparência:


 Article.objects.filter(text__zombo_search='(call OR box)') 

Mas geralmente uma pesquisa não é suficiente. Também requer a classificação do resultado e o destaque das palavras encontradas.
Bem, o ranking é bem direto. Nós escrevemos nossa própria função :


 from django.db.models import FloatField, Func class ZomboScore(Func): lookup_name = 'score' function = 'zdb.score' template = "%(function)s(ctid)" arity = 0 @property def output_field(self): return FloatField() 

Agora você pode criar consultas bastante complexas sem problemas.


 scores = (Article.objects .filter(text__zombo_search='delete') .annotate(score=ZomboScore()) .values_list(F('score')) .order_by('-score')) 

Destacar o resultado (destaque) acabou sendo um pouco mais complicado, não deu certo. O back-end do Django psycopg2 em qualquer situação converte nome da _ em _ . Se houver text , haverá "main_article"."text" , que o ZomboDB categoricamente não aceita. A designação da coluna deve ser exclusivamente o nome textual da coluna. Mas aqui o RawSQL vem em socorro.


 from django.db.models.expressions import RawSQL highlighted = (Article.objects .filter(text__zombo_search='delete') .values(highlight_text=RawSQL("zdb.highlight(ctid, %s)", ('text',)))) 

A versão completa do projeto com testes pode ser visualizada no repositório . Todos os exemplos do artigo são escritos lá na forma de testes. Espero que este artigo seja útil e incentive você a não escrever uma bicicleta em sinais, com a capacidade de capturar toda a consistência e usar uma solução pronta sem perder todos os aspectos positivos do ORM. Adições e correções também são bem-vindas.


UPD: A biblioteca django-zombodb apareceu

Source: https://habr.com/ru/post/pt442512/


All Articles