Fulltext search hell, como estruturar um sistema de busca desacoplado

52
FULLTEXT SEARCH HELL , COMO ESTRUTURAR UM SISTEMA DE BUSCA DESACOPLADO JULIANA LUCENA

Transcript of Fulltext search hell, como estruturar um sistema de busca desacoplado

Page 1: Fulltext search hell, como estruturar um sistema de busca desacoplado

FULLTEXT SEARCH HELL, COMO ESTRUTURAR UM SISTEMA DE BUSCA DESACOPLADO

JULIANA LUCENA

Page 2: Fulltext search hell, como estruturar um sistema de busca desacoplado

Juliana Lucena github.com/julianalucena [email protected]

Page 3: Fulltext search hell, como estruturar um sistema de busca desacoplado
Page 4: Fulltext search hell, como estruturar um sistema de busca desacoplado

SPOILER

• Que busca é essa?

• E pra quê isso tudo?

• A armadilha do "conveniente"

• Como desarmar o alçapão

• Para por aqui?

Page 5: Fulltext search hell, como estruturar um sistema de busca desacoplado

Que busca é essa?

Page 6: Fulltext search hell, como estruturar um sistema de busca desacoplado

AQUELA QUE VOCÊ JÁ USOU VÁRIAS VEZES

Page 7: Fulltext search hell, como estruturar um sistema de busca desacoplado

AH, MUITO FÁCIL!

Aplicação

Banco de Dados

like %malala%

Não acredito que vim aqui pra isso

Page 8: Fulltext search hell, como estruturar um sistema de busca desacoplado
Page 9: Fulltext search hell, como estruturar um sistema de busca desacoplado

THE RIGHT TOOL FOR THE RIGHT JOB

Aplicação

Banco de Dados

Engenho de Busca

Page 10: Fulltext search hell, como estruturar um sistema de busca desacoplado

E pra quê tudo isso?

Page 11: Fulltext search hell, como estruturar um sistema de busca desacoplado

ENGENHO DE BUSCAFeito com o objetivo de realizar buscas e gerar estatísticas destes dados

• Otimizado para lidar com texto

• Estrutura de índice granular

• Ranking de relevância

Page 12: Fulltext search hell, como estruturar um sistema de busca desacoplado

ENGENHO DE BUSCARepresentação dos dados

Livros

Malala

123

Biografia

22.80

Index

Document

Field

Field

Field

Page 13: Fulltext search hell, como estruturar um sistema de busca desacoplado

É comum o uso de filtros e facets para facilitar a navegação.

Page 14: Fulltext search hell, como estruturar um sistema de busca desacoplado

FILTROSUsados para filtrar os resultados de acordo com alguma característica

FACETSDados agregados a partir dos resultados de uma busca

Page 15: Fulltext search hell, como estruturar um sistema de busca desacoplado

Cuidado com a

Armadilha do "Conveniente"

Page 16: Fulltext search hell, como estruturar um sistema de busca desacoplado

Book.search_fulltext('Eu sou Malala', { country: 'BR', price: { max: 50 } })

Parece conveniente manter o padrão usado em buscas simples

Page 17: Fulltext search hell, como estruturar um sistema de busca desacoplado

TÃO CONVENIENTE ACOPLAR AO MODELOEsse pessoal gosta de fazer engenharia demais.

KISS – Keep It Stupidly Simple

Book

BookSearchable

.fulltext_search

Modelo

Módulo

Método

Filtro Filtro

Facet Facet

Query

Callbacks para indexação

. . .

Page 18: Fulltext search hell, como estruturar um sistema de busca desacoplado

module BookSearchable # (...) module ClassMethods def search_fulltext(term, opts = {}) options = { page: 1, size: Rails.configuration.results_count, } options.merge!(opts)

options[:page] = options[:page].to_i options[:size] = options[:size].to_i page = options[:page] page = 1 if page == 0

options[:order] ||= {}

default_facet_filter = [] default_facet_filter << BookSearchable.inactives?(false) default_facet_filter << BookSearchable.country(options[:country])

price_filter = [BookSearchable.price(options[:price])]

sellers_filter = [BookSearchable.publishers(options[:publishers])]

category_filter = [BookSearchable.category_id(options[:category_id])]

category_facet_filter = \ default_facet_filter | sellers_filter | price_filter

publishers_facet_filter = \ default_facet_filter | category_filter | price_filter

price_statistics_facet_filter = \ default_facet_filter | sellers_filter | category_filter

s = Tire.search(Offer.index_name, query: { bool: { should: [ { match: { category: { query: term, operator: "AND", boost: 20 } } }, { match: { name: { query: term, operator: "AND", boost: 5 } } }, { match: { 'name.partial' => { query: term, operator: 'AND', boost: 4 } } }, { match: { 'name.partial_middle' => { query: term, operator: 'AND', boost: 2 } } }, { match: { 'name.partial_back' => { query: term, operator: 'AND', boost: 4 } } } ] } }, filter: { and: [ BookSearchable.inactives?(false), BookSearchable.country(options[:country]), BookSearchable.category_id(options[:category_id]), BookSearchable.publishers(options[:publishers]), BookSearchable.price(options[:price]), ] }, facets: { category_id: { facet_filter: { and: category_facet_filter }, terms: { field: "categories_ids", size: 100, all_terms: false } }, publisher: { facet_filter: { and: publishers_facet_filter }, terms: { field: "seller_name", size: 10, all_terms: false } }, price_statistics: { facet_filter: { and: price_statistics_facet_filter }, statistical: { field: "price" } } }, size: options[:size], from: (page.to_i - 1) * options[:size], sort: options[:order] )

s.results end

def inactives?(status) { term: { inactive: status } } end

def country(country) { term: { country: country } } end

def category_id(category_id) if category_id.present? { term: { categories_ids: category_id } } else {} end end

def publishers(publishers) if publishers.present? { terms: { publisher: publishers } } else {} end end

def price(price) if price.present? if price[:min].present? and price[:max].present? { range: { price: { gte: price[:min], lte: price[:max] } } } elsif price[:min].present? { range: { price: { gte: price[:min] } } } elsif price[:max].present? { range: { price: { lte: price[:max] } } } else {} end else {} end end end end

Paginação e Ordenação

Query

Facets

Filtros

Facets

Paginação e Ordenação

Filtros

ARMADILHA DO "CONVENIENTE"

• Busca rebuscada ≠ Busca complexa ≠ Busca ilegível

• "Keep It Stupidly Simple” não quer dizer "simplista"

https://gist.github.com/julianalucena/5aee5bbb8fb4fe4acdd4

Page 19: Fulltext search hell, como estruturar um sistema de busca desacoplado

sim·plis·mo substantivo masculino

1. Vício de raciocínio que consiste em desprezar elementos necessários à solução.

2. Emprego de meios simples.

Page 20: Fulltext search hell, como estruturar um sistema de busca desacoplado

Vendo uma armadilha de perto

Page 21: Fulltext search hell, como estruturar um sistema de busca desacoplado

ARMADILHA DE PERTO

• Método de classe com 87 linhas

• 7 filtros

• 4 facets

• Filtros implementados em métodos de classe privados - 58 linhas

• Facets implementados inline

Exemplo real

Page 22: Fulltext search hell, como estruturar um sistema de busca desacoplado

ARMADILHA DE PERTO

• Filtros aninhados

• Manipulação dos filtros para aplicá-los aos facets correspondentes

• Lógica de paginação e ordenação

• Uso de um único índice

Exemplo real

Page 23: Fulltext search hell, como estruturar um sistema de busca desacoplado

ARMADILHA DE PERTO

• Filtros implementados em métodos de classe privados

module BookElasticsearch # (...) # (...) filter: { and: [ BookSearchable.inactives?(false), BookSearchable.country(options[:country]), BookSearchable.category_id(options[:category_id]), BookSearchable.publishers(options[:publishers]), BookSearchable.price(options[:price]), ] }, # (...) # (...) def inactives?(status) { term: { inactive: status } } end

def country(country) { term: { country: country } } end

def category_id(category_id) if category_id.present? { term: { categories_ids: category_id } } else {} end end

def publishers(publishers) if publishers.present? { terms: { publisher: publishers } } else {} end end # (...) https://gist.github.com/julianalucena/

5aee5bbb8fb4fe4acdd4

Page 24: Fulltext search hell, como estruturar um sistema de busca desacoplado

ARMADILHA DE PERTO

• Manipulação dos filtros para aplicá-los aos facets correspondentes

• Facets implementados inline

module BookSearchable # (...) def search_fulltext(term, opts = {}) (...) default_facet_filter = [] default_facet_filter << BookSearchable.inactives?(false) default_facet_filter << \ BookSearchable.country(options[:country])

price_filter = [ BookSearchable.price(options[:price]) ]

sellers_filter = [ BookSearchable.publishers(options[:publishers]) ]

category_filter = [ BookSearchable.category_id(options[:category_id]) ]

category_facet_filter = \ default_facet_filter | sellers_filter | price_filter

s = Tire.search(Offer.index_name, (...) facets: { category_id: { facet_filter: { and: category_facet_filter }, terms: { field: "categories_ids", size: 100, all_terms: false } }, }, (...)

https://gist.github.com/julianalucena/5aee5bbb8fb4fe4acdd4

Page 25: Fulltext search hell, como estruturar um sistema de busca desacoplado

ARMADILHA DE PERTO

• Impossibilidade de isolar os testes

• Um filtro sempre pode alterar o retorno da busca e influenciar no teste de outro

require 'spec_helper'

describe Offer do escribe '#search_str', 'should accept an options hash with these options', elasticsearch: true do before(:each) do reset_index_for Book Rails.configuration.results_count = 10 end

describe 'publishers_names' do let(:query) { Faker::Lorem.word } let(:publisher1) { FactoryGirl.create(:active_publisher) } let(:publisher2) { FactoryGirl.create(:active_publisher) }

before do FactoryGirl.create(:book, name: query) FactoryGirl.create(:book, name: "my #{query}", publisher: publisher1) FactoryGirl.create(:book, name: "his #{query}", publisher: publisher2) Book.index.refresh end

it 'filters by multiple publishers' do expect(Book.search_str(query).total).to eq 3 expect(Book.search_str(query, \ {publishers_names: [publisher1.name, publisher2.name]}).total).to eq 2 end end

it 'page' do Rails.configuration.results_count = 1 FactoryGirl.create(:book, name: 'nonsolid one') FactoryGirl.create(:book, name: 'nonsolid two') Book.index.refresh Book.search_str('nonsolid').total.should eq 2 Book.search_str('nonsolid').count.should eq 1 Book.search_str('nonsolid', {page: 1}).count.should eq 1 Book.search_str('nonsolid', {page: 2}).count.should eq 1 end

it 'size' do FactoryGirl.create(:book, name: 'incinerator') FactoryGirl.create(:book, name: 'incinerator clayton') Book.index.refresh Book.search_str('incinerator').total_pages.should eq 1 Book.search_str('incinerator', {size: 1}).total_pages.should eq 2 end end

describe '#search_str', elasticsearch: true do

before(:each) do reset_index_for Book Rails.configuration.results_count = 10

inactive_publisher = FactoryGirl.create(:publisher, inactive: true)

FactoryGirl.create(:book, name: 'Ventoinha pblica') FactoryGirl.create(:book, name: 'Ventoinha do fornecedor inativo', publisher: inactive_publisher)

Book.index.refresh end

describe 'filters' do describe "price filter" do let!(:book) { FactoryGirl.create(:book, price: 20) }

it "returns books that price belongs to the searched range" do FactoryGirl.create(:book, name: book.name, price: 10) FactoryGirl.create(:book, name: book.name, price: 40) Book.index.refresh

results = Book.search_str(book.name, price: { min: 20, max: 30 }) expect(results.total).to eq(1) expect(results.first.id).to eq(book.id.to_s) end end

describe "category filter" do let(:category) { FactoryGirl.create(:category) } let(:child_category) { FactoryGirl.create(:category, parent: category) } let!(:book) { FactoryGirl.create(:book, category: child_category) }

before do FactoryGirl.create(:book, name: book.name) Book.index.refresh end

it "returns books that belongs to specified category" do results = described_class.search_str(book.name, { category_id: child_category.id })

expect(results.first.id).to eq(book.id.to_s) end end end

describe 'facets' do shared_examples_for 'facet with price range' do |facet, info| let(:search_attrs) { {} } let(:book) { FactoryGirl.create(:book, price: 10) }

it 'count only books that price belongs to price range' do FactoryGirl.create(:book, name: book.name, price: 40) Book.index.refresh

conditions = { price: { min: 10, max: 20 } } results = Book.search_str(book.name, conditions.merge(search_attrs)) expect(results.facets[facet.to_s][info.to_s]).to eq 1 end end

shared_examples_for 'facet with category filter' do |facet, info| let(:search_attrs) { {} } let(:book) { FactoryGirl.create(:book, category: child_category) } let(:category) { FactoryGirl.create(:category) } let(:child_category) { FactoryGirl.create(:category, parent: category) }

it 'count only books that belongs to category down tree' do FactoryGirl.create(:book, name: book.name) Book.index.refresh

conditions = { category_id: category.id } results = Book.search_str(book.name, conditions.merge(search_attrs)) expect(results.facets[facet.to_s][info.to_s]).to eq 1 end end

shared_examples_for 'facet with publisher filter' do |facet, info| let(:search_attrs) { {} } let(:publisher) { FactoryGirl.create(:valid_publisher) } let(:book) { FactoryGirl.create(:book, publisher: publisher) }

before do FactoryGirl.create(:book, name: book.name) Book.index.refresh end

it 'counts only books that belongs to publisher' do conditions = { publisher_name: [publisher.name] } results = Book.search_str(book.name, conditions.merge(search_attrs)) expect(results.facets[facet.to_s][info.to_s]).to eq 1 end end

it_should_behave_like 'facet with price range', :publisher_name, :total it_should_behave_like 'facet with category filter', :publisher_name, :total it_should_behave_like 'facet with price range', :category_id, :total

describe "facet price_statistics" do let(:facets) { Book.search_str('keyboard').facets }

before do Rails.configuration.results_count = 10 end

it "has price_statistics facet" do expect(facets).to have_key('price_statistics') end it_should_behave_like 'facet with category filter', \ :price_statistics, :count

context do let(:facet) { facets['price_statistics'] }

it "has min statistics" do expect(facet).to have_key('min') end

it "has max statistics" do expect(facet).to have_key('max') end end end

describe 'facet category_id' do let(:facet) do described_class.search_str(book.name).facets['category_id'] end let!(:book) { FactoryGirl.create(:book, category: child_category) } let(:child_category) do FactoryGirl.create(:category, parent: category) end let(:category) { FactoryGirl.create(:category) }

it "has qty of books per category from hierarchy" do Book.index.refresh

expect(facet['terms']).to have(2).items categories_ids = facet['terms'].map { |f| f['term'] } expect(categories_ids).to \ match_array([category.id, child_category.id])

quantities = facet['terms'].map { |f| f['count'] } expect(quantities).to match_array([1, 1]) end end end

describe "ordering" do let(:results) { described_class.search_str('Aa', order: order_params) }

describe "by any attribute" do before do FactoryGirl.create(:book, name: 'Aaz') FactoryGirl.create(:book, name: 'Aaa') Book.index.refresh end

context do let(:order_params) { { name: 'asc' } }

it "returns in ascending order" do expect(results.first.name).to eq('Aaa') end end

context do let(:order_params) { { name: 'desc' } }

it "returns in ascending order" do expect(results.first.name).to eq('Aaz') end end end end end end

Testa Filtro A

Testa Paginação

Setup para vários testes

Testa Filtro B

Testa Filtro C

Testa Facets

Testa Facet A

Testa Facet B

Testa Ordenação

Testa Facets

Page 26: Fulltext search hell, como estruturar um sistema de busca desacoplado

Tudo isso feito

E isso é responsabilidade dele?

NO MODELO

Page 27: Fulltext search hell, como estruturar um sistema de busca desacoplado

LISTINHA

• Busca complexa de entender

• Baixa legibilidade

• Impossibilidade de isolar os testes

• Uma classe sabe como construir todos os filtros e facets

• Replicação de código ao precisar de filtros e facets em buscas distintas

Page 28: Fulltext search hell, como estruturar um sistema de busca desacoplado

Nova estrutura para busca

Como desarmar o alçapão?

Page 29: Fulltext search hell, como estruturar um sistema de busca desacoplado

NECESSIDADES DO SISTEMA DE BUSCA

• Definir a query de busca

• Definir filtros

• Definir facets

• Aplicar filtros por padrão

• Aplicar filtros opcionais

• Aplicar facets

• Aplicar paginação e ordenação

Page 30: Fulltext search hell, como estruturar um sistema de busca desacoplado

ESTRUTURA DO SISTEMA DE BUSCAKISS – Keep It Simple, Stupid

A busca só precisa saber: • Definir a query • Quais filtros e facets aplicar

BookSearch

CategoryFilter

Query

PublisherFilter

CategoryFacet PriceStatisticsFacet

Apenas Plain Old Ruby Objects

Page 31: Fulltext search hell, como estruturar um sistema de busca desacoplado

BookSearch

CategoryFilter

Query

PublisherFilter

CategoryFacet PriceStatisticsFacet

Apenas Plain Old Ruby Objects

Book

BookSearchable

.fulltext_search

Modelo

Módulo

Método

Filtro Filtro

Facet Facet

Query

Callbacks para indexação

. . .

Antes

Depois

Page 32: Fulltext search hell, como estruturar um sistema de busca desacoplado

E QUEM VAI DEFINIR OS FILTROS E

FACETS?

Eles mesmos.

Page 33: Fulltext search hell, como estruturar um sistema de busca desacoplado

• Define interface similar a do Tire

• Aplica paginação e ordenaçãoBaseSearch

BookSearch

CountryFilter PriceFilter

PriceStatisticsFacet PublishersFacet

HqSearch• Define a query

• Aplica filtros padrão e opcionais

• Aplica facets

• Define filtro reusável

• Define facet reusável

RESPONSABILIDADES

Page 34: Fulltext search hell, como estruturar um sistema de busca desacoplado

SUGESTÃO DE ORGANIZAÇÃO DO SISTEMA DE BUSCA

bookstore (master) > tree app/services/text_search/ app/services/text_search/

base_search.rb book_search.rb hq_search.rb facets

   category_facet.rb    price_statistics_facet.rb    publisher_facet.rb

filters    active_filter.rb    country_filter.rb    category_filter.rb    price_filter.rb    publisher_filter.rb

Page 35: Fulltext search hell, como estruturar um sistema de busca desacoplado

TextSearch::BookSearch.search('Eu sou Malala'). filter( country: ‘BR', price: { max: 50 } ).with_facets. order('price ASC’). per_page(20).page(2)

Nova interface para buscar livros • É possível fazer uma busca sem aplicar os filtros opcionais • É possível fazer uma busca sem calcular os facets • A ordenação e paginação são manipuladas de forma

similar ao kaminari

Page 36: Fulltext search hell, como estruturar um sistema de busca desacoplado

NOVA ESTRUTURA – FILTRO

• Sabe como construir o filtro por Categoria

class CategoryFilter # (...) def apply! return if category_id.blank?

filters[:categories_ids] = { term: { categories_ids: category_id } } end # (...) end

https://gist.github.com/julianalucena/34246b0c837fd163cc0f

Page 37: Fulltext search hell, como estruturar um sistema de busca desacoplado

NOVA ESTRUTURA – FACET

• Sabe como definir facet de Categorias

• Sabe qual filtro deve ser ignorado no facet de Categorias

class CategoryFacet # (...) def apply! facet_filters = filters.except(:categories_ids)

search.facet :category_id do terms :categories_ids, size: 100, all_terms: false unless facet_filters.empty? facet_filter :and, facet_filters.values end end end # (...) end

https://gist.github.com/julianalucena/34246b0c837fd163cc0f

Page 38: Fulltext search hell, como estruturar um sistema de busca desacoplado

• Sabe como fazer a query

• Sabe quais filtros devem ser aplicados

NOVA ESTRUTURA – BUSCAclass BookSearch < BaseSearch # (...) def search(term, country: 'BR', **options) @search = Tire.search(search_indexes) do |s| s.query do boolean do should do match :description, term, operator: "AND", boost: 5 end # (...) end end end

Filters::ActiveFilter.apply!(filters, true) Filters::CountryFilter.apply!(filters, country)

self end

def filter(conditions) Filters::PriceFilter.apply!(filters, conditions) Filters::CategoryFilter.apply!(filters, conditions) Filters::PublisherFilter.apply!(filters, conditions)

self end # (...) end

https://gist.github.com/julianalucena/34246b0c837fd163cc0f

Page 39: Fulltext search hell, como estruturar um sistema de busca desacoplado

• Sabe quais facets devem ser aplicados

• Sabe o índice a ser usado

NOVA ESTRUTURA – BUSCA

class BookSearch < BaseSearch # (...) def with_facets Facets::PriceStatisticsFacet.apply!(@search, filters) Facets::CategoryFacet.apply!(@search, filters) Facets::PublisherFacet.apply!(@search, filters)

self end

private

def search_indexes [Book.index_name] end end

https://gist.github.com/julianalucena/34246b0c837fd163cc0f

Page 40: Fulltext search hell, como estruturar um sistema de busca desacoplado

AGORA DÁ PRA ISOLAR OS TESTES

Page 41: Fulltext search hell, como estruturar um sistema de busca desacoplado

O QUE É NECESSÁRIO NO TESTE?

• Permitir conexões ao Elasticsearch

• Popular índice com documentos

• Atualizar índice

• Testar 😱

• Resetar índice

Page 42: Fulltext search hell, como estruturar um sistema de busca desacoplado

TESTE ISOLADO – FACTORY DE BUSCA GENÉRICA

• Busca genérica que retorna todos os documentos do índice

• Aplica filtros e facets

GenericSearch

Query

all documents index

CategoryFilter

Filtro a ser testado Isolado

Page 43: Fulltext search hell, como estruturar um sistema de busca desacoplado

TESTE ISOLADO – FILTRO

• Busca genérica no índice de Book

• Apenas o filtro influencia nos itens retornados

describe TextSearch::Filters::CategoryFilter do include TextSearchHelpers subject do text_search_for(Book).add_filters do |filters, conditions| described_class.apply!(filters, conditions) end end

after { reset_index_for Book }

let(:category) { FactoryGirl.create(:category) } let!(:book) { FactoryGirl.create(:book, category: category) }

before do FactoryGirl.create(:book) refresh_index_for Book end

it "returns books that belongs to specified category" do results = subject.filter(category_id: category.id).results

expect(results.count).to eq(1) expect(results.first.id).to eq(book.id.to_s) end end

https://gist.github.com/julianalucena/34246b0c837fd163cc0f

Page 44: Fulltext search hell, como estruturar um sistema de busca desacoplado

TESTE ISOLADO – FACET

• Busca genérica no índice de Book

• Apenas o facet influencia nos resultados agregados

describe TextSearch::Facets::CategoryFacet do include TextSearchHelpers subject do text_search_for(Book).add_facets do |search, filters| described_class.apply!(search, filters) end end

after { reset_index_for Book }

let(:facets) { subject.with_facets.results.facets } let(:facet) { facets['category_id'] } let!(:book) { FactoryGirl.create(:book, category: category) } let(:category) { FactoryGirl.create(:category) }

before { refresh_index_for Book }

it "has category_id facet" do expect(facets).to have_key('category_id') end

it "has qty of books per category" do expect(facet['terms']).to have(1).items categories_ids = facet['terms'].map { |f| f['term'] } expect(categories_ids).to match_array([category.id])

quantities = facet['terms'].map { |f| f['count'] } expect(quantities).to match_array([1]) end #(...) end

https://gist.github.com/julianalucena/34246b0c837fd163cc0f

Page 45: Fulltext search hell, como estruturar um sistema de busca desacoplado

TESTE ISOLADO – SEARCH

• Verifica se a query retorna os itens corretos

describe TextSearch::BookSearch do describe "#search" do it "return self" do expect(subject.search('term')).to eq(subject) end

context do let!(:book) { FactoryGirl.create(:book) }

before { refresh_index_for Book } after { reset_index_for Book}

it "matches with book's name" do results = subject.search(book.name).results expect(results).to have(1).item expect(results.first.name).to eq(book.name) end # (...) end # (...) end # (...)

https://gist.github.com/julianalucena/34246b0c837fd163cc0f

Page 46: Fulltext search hell, como estruturar um sistema de busca desacoplado

TESTE ISOLADO – SEARCH

• Verifica se os filtros e facets são aplicados

describe TextSearch::BookSearch do describe "#search" do describe "default filters" do it "applies InactiveFilter with flag: true" do expect(TextSearch::Filters::ActiveFilter).to \ receive(:apply!). with(an_instance_of(Hash), true) subject.search('term') end # (...) end end

describe "#filter" do let(:conditions) { double(Hash, :[] => nil) }

it "applies PriceFilter with passed conditions" do expect(TextSearch::Filters::PriceFilter).to \ receive(:apply!). with(an_instance_of(Hash), conditions) subject.search('term').filter(conditions) end # (...) end describe '#with_facets', elasticsearch: true do describe "publisher facet" do it "applies PublisherFacet" do expect(TextSearch::Facets::PublisherFacet).to \ receive(:apply!) subject.search('term').with_facets end end end # (...)

https://gist.github.com/julianalucena/34246b0c837fd163cc0f

Page 47: Fulltext search hell, como estruturar um sistema de busca desacoplado

O QUE MELHOROU?

• Baixa complexidade

• Melhor legibilidade

• Filtros e facets reusáveis

• Testes direcionados e isolados

• Possibilidade de usar mais de um índice sem ficar confuso

• Busca 99% desacoplada do modelo

Page 48: Fulltext search hell, como estruturar um sistema de busca desacoplado

Para por aqui?

Page 49: Fulltext search hell, como estruturar um sistema de busca desacoplado

PARA POR AQUI?

• Remover menção aos modelos nos testes e buscas (usar nome do índice)

• Inserir direto no Elasticsearch ao invés de usar o FactoryGirl + indexação feita pelo callback do modelo

•💡 FactoryDocument

Page 50: Fulltext search hell, como estruturar um sistema de busca desacoplado

PARA POR AQUI?

• Desacoplar indexação do modelo

•💡

• Estrutura com suporte a diversos backends de busca

• Lógica de indexação desacoplada do modelo

Page 51: Fulltext search hell, como estruturar um sistema de busca desacoplado

O QUE VOCÊS ME DIZEM?

Look icon created by Sebastian Langer from the Noun Project