Otimizando um campo de Autocomplete com Postgres e Rails
Tags: webdev, braziliandevs, rails, postgres
Published: 2022-11-14 17:49:42

Sabe quando você e seu time erra a mão na hora de estimar uma task?

> Task: Criar campo autocomplete que deve sugerir "tags" para o usuário
> Estimado em: 3SP

Assim que comecei a trabalhar task ouvi o Faustão falando no meu ouvido: "E-r-r-r-r-r-oooouu".

Existem inúmeras otimizações que podem ser feitas em um campo desses que fiquei até desnorteado.

Gostaria de compartilhar um pouco do que acabei produzindo nessa task. Estou longe de dizer que esta é a melhor forma possível de fazer um campo como esse, mas acredito que é um bom ponta-pé inicial.

Para facilitar a conversa e deixar esse post mais interessante, ao invés de buscar tags, vamos buscar personagens da Marvel.

Se quiser dar uma olhada no resultado final, acessa aí:

  - Exemplo Funcional: https://marvel.apps.jony.dev/
  - Código-fonte: https://github.com/jonyhayama/find-marvel-characters

## O que eu fiz no Postgres

Se você ainda não conheçe, a Marvel fornece uma API para podermos coletar dados: https://developer.marvel.com/

Como meu objetivo aqui é falar sobre o Postgres, fiz alguns requests para a API, salvei os dados que me interessam em um CSV e criei um seed para importar esses dados no banco.

> Se quiser, pode baixar o repositório do projeto e rodar `rails db:seed`, o CSV está commitado 😜.

Vamos considerar, portanto, que temos uma tabela chamada `characters` e que precisamos fazer uma busca na coluna `name` para devolver o resultado dessa pesquisa para o usuário.

Minha primeira ideia foi bem simples:

```ruby
Character.where('name ILIKE ?', "%#{params[:query]}%").pluck(:name)
# SELECT "characters"."name" FROM "characters" WHERE (name ILIKE '%Iron Man%');
# Execution Time: 2.995 ms
```

> **⚠️ OBS:** 
> 1 - Vou sempre deixar query gerada pelo ActiveRecord comentada em baixo da instrução para facilitar o papo 😜
> 2 - Considere que estamos em um Controller e que `params[:query] = 'Iron Man'`

Logo de cara temos uma otimização a fazer: Em linhas gerais, o Postgres é mais performático fazendo comparações com `LIKE` do que `ILIKE`.

Nós conseguimos exatamente o mesmo resultado, porém com um tempo de execução menor, se escrevermos:

```ruby
Character.where('lower(name) LIKE ?', "%#{params[:query].downcase}%").pluck(:name)
# SELECT "characters"."name" FROM "characters" WHERE (lower(name) LIKE '%iron man%');
# Execution Time: 1.012 ms
```

A próxima otimização seria criar um índice para a coluna `name` que está sendo alvo da busca.

Eu já estava pronto para criar uma migration com `add_index :characters, :name` quando descobri que é possível criar [índices baseados em expressões](https://www.postgresql.org/docs/current/indexes-expressional.html).

Algo simples como:

```SQL
CREATE INDEX index_characters_lower_name ON characters (lower(name));
```

O que, naturalmente, traria ainda mais performance já que estamos fazendo a busca baseado exatamente nessa expressão.

Foi então que, antes mesmo de ter tempo de implementar esse índice, o StackOverflow me indagou...

> **StackOverflow:** Já ouviu falar da palavra de [pg_trgm](https://www.postgresql.org/docs/current/pgtrgm.html)?
> **Eu:** Caramba, como será que se fala "trgm"? 
> **Documentação do Postgres:** it's "Trigram"
> **Google Translate:** "Trigram" = "Trigrama" em terras tupiniquins.
> **Eu:** Mas o que diabos é um trigrama?
> **Google:** vocábulo de três letras.
> **Eu:** ![que joia](https://media.tenor.com/QcrcBpXx3qYAAAAC/boy-kid.gif)

Lendo a documentação com um pouco mais de carinho e, sem entrar em muitos detalhes (até porque eu acho que nem conseguiria), entendi que:

> Usando Trigramas, o Postgres consegue gerar um valor numérico para dizer qual é a similaridade entre dois termos, sendo 0 completamente diferente e 1 exatamente igual.

Isso quer dizer que, com uma simples ordenação, eu poderia retornar sugestões de personagens com mais assertividade:

```sql
SELECT
  name,
  similarity(name, 'iron man') AS score
FROM characters
WHERE lower(name) LIKE '%iron man%' 
ORDER BY score DESC;
```

Corri, abri o `rails dbconsole`, colei a query e...

```
ERROR:  function similarity(character varying, unknown) does not exist
LINE 3:   similarity(name, 'iron man') AS score
          ^
HINT:  No function matches the given name and argument types. You might need to add explicit type casts.
```

De volta à documentação, descobri que é necessário habilitar a extesão primeiro 🙈... então criei uma migration rapidinho:

```ruby
class EnablePgTrgmExtension < ActiveRecord::Migration[6.0]
  def change
    enable_extension 'pg_trgm'
  end
end
# CREATE EXTENSION pg_trgm;
```

Agora, com a extensão ativada, query executada, temos o seguinte resultado:

|                      name                       |   score    |
|-------------------------------------------------|------------|
| Iron Man                                        |          1 |
| Iron Man (Ultimate)                             |        0.5 |
| Iron Man (Marvel Heroes)                        | 0.42857143 |
| Iron Man/Tony Stark (MAA)                       |  0.4090909 |
| Iron Man (Marvel War of Heroes)                 | 0.32142857 |
| Iron Man (Iron Man 3 - The Official Game)       | 0.31034482 |
| Iron Man (LEGO Marvel Super Heroes)             |    0.28125 |
| M.O.D.O.K. (Iron Man 3 - The Official Game)     | 0.25714287 |
| War Machine (Iron Man 3 - The Official Game)    | 0.23076923 |
| Crimson Dynamo (Iron Man 3 - The Official Game) | 0.20930232 |

Repare que temos uma ordenação bem diferente em relação à nossa primeira query:

|                      name                       |
|-------------------------------------------------|
| Crimson Dynamo (Iron Man 3 - The Official Game) |
| Iron Man                                        |
| Iron Man (Iron Man 3 - The Official Game)       |
| Iron Man (LEGO Marvel Super Heroes)             |
| Iron Man (Marvel Heroes)                        |
| Iron Man (Marvel War of Heroes)                 |
| Iron Man (Ultimate)                             |
| Iron Man/Tony Stark (MAA)                       |
| M.O.D.O.K. (Iron Man 3 - The Official Game)     |
| War Machine (Iron Man 3 - The Official Game)    |

Você deve estar se perguntando por que comecei a falar da ordenação antes de criar o índice né?

Simples: podemos criar um índice para o trigrama 😜

Então, finalmente, bora para a migration:

```ruby
class AddCharacterNameLowerTagIndex < ActiveRecord::Migration[6.0]
  def up
    execute "CREATE INDEX index_characters_lower_name ON characters USING gin (lower(name) gin_trgm_ops);"
  end

  def down
    execute "DROP INDEX index_characters_lower_name;"
  end
end
```

## O que eu fiz no Rails

Agora que temos uma query bacana, é só uma questão de reescrevê-la utilizando o ActiveRecord, certo?

```ruby
query = params[:query].downcase
Character.select("name, similarity(name, '#{query}') AS score").where('lower(name) LIKE ?', "%#{query}%").order('score DESC').load.pluck(:name)
```

🤔 Ficou meio grande né?

Sem contar que, naturalmente, a gente não deve simplesmente concatenar um parâmetro enviado pelo usuário na nossa query...

Infelizmente o `select()` não tem a mesma sanitização que o `where()` que te permite fazer parameter binding.

Para driblar esse problema, podemos usar um método chamado `sanitize_sql_array()` para nos auxiliar:

```ruby
query = params[:query].downcase
Character.select(ActiveRecord::Base::sanitize_sql_array(["name, similarity(name, ?) AS score", query])).where('lower(name) LIKE ?', "%#{query}%").order('score DESC').load.pluck(:name)
```

![parece que piorou](https://media.tenor.com/jZ3P8DPb_YEAAAAd/tava-b%C3%A3o.gif)

🤔 Como estamos acessando `ActiveRecord::Base`, há grandes chances de isso ficar melhor no Model...vamos transformar isso num scopo?

```ruby
# app/models/character.rb
class Character < ApplicationRecord
  scope :order_by_name_similarity, ->(name) {
    select('*')
      .select(sanitize_sql_array(["similarity(name, ?) AS score", name]))
      .order("score DESC")
  }
end
```

Repare que optei por deixar o padrão `select(*)` ao invés de `select(:name)`, dessa forma temos o objeto completo e podemos utilizar outros dados no retorno (se necessário).

Agora a consulta em si fica bem mais simples:

```ruby
query = params[:query].downcase
Character.where('lower(name) LIKE ?', "%#{query}%").order_by_name_similarity(query)
# SELECT *, similarity(name, 'iron man') AS score FROM "characters" WHERE (lower(name) LIKE '%iron man%') ORDER BY score DESC
```

Acho interessante também adicionar um `limit(10)` para não ter uma lista muito grande de opções aparecendo para o usuário.

No final do dia, o método no Controller ficou assim:

```ruby
def index
  query = params[:query].downcase
  @characters = Character.where('lower(name) LIKE ?', "%#{query}%").order_by_name_similarity(query).limit(10)
  # SELECT *, similarity(name, 'iron man') AS score FROM "characters" WHERE (lower(name) LIKE '%iron man%') ORDER BY score DESC LIMIT 10

  expires_in 10.minutes
end
```

Repare que também adicionei um `expires_in 10.minutes` para o navegador pode cachear essa resposta.

É claro que cada caso é um caso e - talvez - 10 minutos não seja a melhor estratégia, mas meu ponto é: é importante ter alguma estratégia de cache.

---

Não sei você, mas pra mim tudo isso passou longe dos 3SPs estimados inicialmente. Especialmente se considerarmos que ainda falta fazer o front.

Para falar a verdade mesmo, eu dei uma ajustada na história... quando disse que "antes mesmo de criar o índice"... convenhamos... você sabe que eu fiz, desfiz e fiz de novo né? 😜

Só organizei a história de uma forma mais cronológica, mas rolou muita pesquisa, teste e re-teste até chegar nessa nessa solução.

E olha que só falamos de Back...vou deixar o Front para um próximo post.

**PS:** Não encontrei um lugar no meio do texto, mas queria deixar uma queryzinha de presente que talvez lhe seja útil:

```sql
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'characters';
```

Ess query lista os índices de uma determinada tabela.

**EM TEMPO:**

Quando comecei a trabalhar no front, comecei a tentar faze a busca por outras personagens.

Foi nessa que eu descobri que o nome oficial é "Spider-man". Não é "Spider man", nem "spiderman"...

Para a busca por "Spider man", podemos fazer um mini-hack rapidinho:

```ruby
query = params[:query].downcase.gsub(/ /, '%')
```

Já para "spiderman" acho que a coisa complica mais e - talvez - `pg_trgm` não seja o ideal...

É nessa hora que ferramentas como Elastic Search, Algolia e afins podem ser aliadas...mas tudo tem seu custo (seja financeiro ou de implementação) 😜


---

Tem ideias, críticas ou sugestões? comenta aí :D