Huginn
Datatables ActiveRecord performantes e busca tolerante a erros.
Huginn é o corvo de Odin que representa o pensamento e a memória — o companheiro de Muninn.
🇺🇸 English · 🇧🇷 Português
O Huginn é uma camada de consulta leve para Rails que transforma uma requisição de datatable bruta em um count enxuto, um subconjunto paginado e um único preload — em vez de um JOIN enorme materializado em memória. Também traz um construtor de busca fuzzy para PostgreSQL (similaridade pg_trgm via operador % com unaccent e fallback ILIKE) tolerante a erros de digitação e acentuação — e que pode usar um índice GIN quando presente.
Destaques
- Execução em duas fases — filtros/orders/ranges de associação se tornam subqueries resolvidas por reflexão, e o
preloadé feito somente no subconjunto paginado. - Counts enxutos — a relation base nunca faz join (
COUNT(*)sobre uma relation restrita); as buscas de associação acontecem via subqueries no pk. - Ordenação/filtro seguros contra SQL injection — toda referência de coluna é resolvida via reflexão do Arel, nunca interpolada como string.
- Busca tolerante a acentos/typos — similaridade
pg_trgm(operador%) OUunaccent+ILIKE, com cadeia de fallback configurável; usa índice GINgin_trgm_opsquando existente. - Convenções do Rails — funciona com
ActionController::Parameters, Railtie inclui ambos os concerns automaticamente (desligável), zero boilerplate.
Desenvolvimento
O Gemfile raiz mantém apenas as ferramentas (rspec, appraisal, pry) — cada série suportada do Rails vive num Appraisal próprio. Para rodar a suíte:
bundle install
bundle exec appraisal install # gera os gemfiles/*.gemfile + resolve
bundle exec appraisal rspec # roda a matrix completa (Rails 7.1/7.2/8.0)
bundle exec appraisal rails-8.0 rspec # ou uma série isolada
bundle exec rake matrix # atalho para a matrix completa
Um bundle exec rspec isolado precisa de ambiente ativo: export BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile.
Versões suportadas
| Componente | Escopo |
|---|---|
| Ruby | >= 3.0 (sem teto — Rails 8 + Ruby 4 suportados) |
| Rails | >= 7.1, < 9 |
| Pagy | >= 6 (dependência runtime, instalada automaticamente) |
| PostgreSQL | busca pg_trgm / unaccent / ILIKE; degrada sem eles na ausência |
A suíte é verificada contra Rails 7.1, 7.2 e 8.0 em várias Rubies suportadas via Appraisal. Rode a matrix completa localmente:
bundle exec appraisal install
bundle exec appraisal rspec
Os gemfiles/*.gemfile são gerados pelo Appraisal (commitados); os arquivos .lock deles não são — cada célula da CI resolve pelo seu par Ruby/Rails.
Instalação
gem "huginn"
Configuração
# config/initializers/huginn.rb
Huginn.configure do |config|
# :pg_trgm (recomendado) — similaridade trigram (%) OU unaccent+ILIKE
# :full_text — full text search do PostgreSQL sobre lexemas
# (to_tsvector @@ plainto_tsquery)
# :unaccent — somente unaccent + ILIKE
# :simple — LIKE simples
# Para combinar estratégias com OR, passe um Array:
# config.search_strategy = [:pg_trgm, :full_text]
config.search_strategy = :pg_trgm
# Função unaccent usada em torno de termos/colunas. O default é o UNACCENT()
# built-in do PG. Aponte para o wrapper IMMUTABLE criado por
# `rails g huginn:trigram_indexes` ("public.f_unaccent") para que índices
# GIN trigram possam ser usados de fato.
config.unaccent_function = "unaccent"
# Full text search (estratégia :full_text). O dicionário e o wrapper
# tsvector IMMUTABLE (`rails g huginn:fts_indexes`) fixado nesse dicionário;
# só têm efeito quando o wrapper existe — caso contrário a busca degrada
# para o fallback unaccent.
# config.fts_dictionary = "portuguese"
# config.fts_function = "public.f_tsvector"
config.pagy_items = 10 # tamanho de página padrão
config.pagy_max_items = 500 # teto máximo de per_page
end
Limiar: sob
:pg_trgm, o corte de similaridade é o GUC do PostgreSQLpg_trgm.similarity_threshold(default0.3), não uma configuração da gem. Ajuste comSET pg_trgm.similarity_threshold = 0.4/SELECT set_limit(0.4)no banco.
Tradeoffs de estratégia:
:pg_trgmtolera typos e substrings, mas não tem noção de morfologia.:full_textcaptura variações morfológicas (ex. "correndo"/"correr" → mesmo lexema), mas é cego a typos, e brilha em colunas de texto mais longo — para busca por nome/palavra-chave ele em grande parte se sobrepõe ao trigram. Combinar os dois ([:pg_trgm, :full_text]) une os dois conjuntos de resultado e exige os dois conjuntos de índices (rails g huginn:trigram_indexes+rails g huginn:fts_indexes).
Railtie (include automático)
Por padrão, o Railtie inclui Huginn::Datatable e Huginn::Searchable em toda ActiveRecord::Base. Você não precisa de include, a menos que opte seletivamente:
Huginn.configure { |c| c.auto_include_datatable = false; c.auto_include_searchable = false }
Uso — datatable
class Plano < ApplicationRecord
# Datatable + Searchable são incluídos automaticamente via Railtie
end
result = Plano.datatable(
params,
allowed_paths: [:grupo, { operadora: [:pessoa] }], # associações que filtros/orders podem usar
includes: [{ operadora: { pessoa: [:endereco, :contatos] } }] # preload somente na página
)
result[:total_count] # Integer (count enxuto COUNT DISTINCT pk)
result[:data] # ActiveRecord::Relation (paginada + preloaded)
Parâmetros suportados:
| Parâmetro | Comportamento |
|---|---|
page, per_page |
Paginação (limitada a pagy_max_items) |
search |
Delega para Huginn::Searchable.search |
filters |
Hash / Array de hashes / pares -> condições exatas ou IN ("col" => "null" → IS NULL) |
range_data |
{ "created_at" => ["2024-01-01", "2024-12-31"] } — ranges de datas ou numéricos |
orders |
[{ "pessoa.nome" => "desc" }] — colunas simples ou de associação |
Ordenação/filtro com associação
Qualquer referência column ou associacao.column é validada e mapeada para a tabela refletida real:
Plano.datatable({ orders: [{ "operadora.pessoa.nome" => "asc" }] }, allowed_paths: [{ operadora: :pessoa }])
Filtros/ranges, order e busca de associação usam subqueries resolvidas por reflexão (veja a seção "Allowlist de associações" abaixo). A relation base permanece única e sem joins, então o count é um
COUNT(*)simples.
Allowlist de associações (allowed_paths)
Para proteger o schema e manter a consulta enxuta, o datatable não materializa left_joins para filtrar/ordenar por associações. Em vez disso:
- Filtros/ranges de colunas de associação viram subqueries
pk IN (SELECT DISTINCT pk …)— a relation principal nunca é multiplicada; - Ordenação por coluna de associação usa uma subquery escalar correlacionada (
ORDER BY (SELECT … ORDER BY col ASC LIMIT 1)), determinística mesmo parahas_many(menor valor); - Apenas as associações autorizadas podem ser referenciadas. Passe
allowed_paths:com as associações que o chamador pode usar na query:
result = Plano.datatable(
params,
allowed_paths: [:grupo, { operadora: :pessoa }], # associações que filtros/orders podem usar
includes: [{ operadora: { pessoa: [:endereco, :contatos] } }] # preload somente da página
)
- Deny-all por padrão: sem
allowed_paths:, nenhuma associação é autorizada para filtro/ordem — apenas colunas da própria tabela. allowed_paths:aceita os mesmos formatos do Rails (:symbol,"string", Hash aninhado, Array misto). Nomes de tabela ("companies") são reconhecidos como a associação correspondente (:company).includes:continua independente doallowed_paths:: ele só controla o preload dos dados na página paginada.
Aliases de campos & proteção do schema
APIs públicas não deveriam expor o schema do banco. Declare um mapeamento de nomes públicos para colunas/tabelas reais com huginn_attributes:
class User < ApplicationRecord
# Nome de API pública -> coluna/tabela real (nome da associação ou nome da tabela)
huginn_attributes(
name: "users.name",
email: "users.email",
created_at: "users.created_at",
company_name: "companies.name" # coluna de associação, resolvida via subquery
)
end
- Os chamadores filtram/ordenam/rangeiam apenas pelos aliases:
{ filters: { company_name: "Acme Corp" } },{ orders: [{ company_name: "asc" }] }. - Estrito por padrão: campos fora do mapeamento são silenciosamente rejeitados (nunca chegam ao SQL e nunca são respondidos). O schema permanece oculto para consumidores da API.
- Aliases de associação (
companies.name) resolvem pela associação somente se ela estiver autorizada emallowed_paths:(a mesma allowlist se aplica aos aliases). - Sem
huginn_attributes, o modelo cai de volta para colunas simples/refletidas (name,company.name). - Sem
allowed_paths:, aliases de associação são negados; apenas colunas simples podem ser usadas. huginn_attributes({ ... }, strict: false)mantém a tradução de aliases, mas também aceita colunas cruas.
Uso — search
# Padrão: busca em toda coluna :string / :text do modelo.
Person.search("kayky") # tolerante a typos e acentos, sem distinção de caixa
# Sobrescreva quais colunas pesquisar (inclusive através de associações):
class Person < ApplicationRecord
searchable_columns :name, company: [:name, :cnpj]
end
Person.search("globex") # encontra company.name via subquery no pk
Person.search("kayky", distinct: false) # desativa o DISTINCT implícito
Huginn::Datatable reutiliza Huginn::Searchable.search automaticamente quando o modelo responde a search.
Índices trigram (performance)
Sob :pg_trgm, cada coluna pesquisável gera um predicado indexável:
(UNACCENT(col) % UNACCENT('termo')) OR (UNACCENT(col) ILIKE UNACCENT('%termo%'))
Ambos os ramos são suportados por um índice GIN trigram, então o planner pode executar um BitmapOr sobre ele. Como o unaccent() built-in do PG é STABLE (não IMMUTABLE) desde o PostgreSQL 13+, ele não pode ser usado diretamente numa expressão de índice — você precisa de um wrapper IMMUTABLE + índice de expressão:
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
RETURNS text AS $$
SELECT public.unaccent('public.unaccent', $1);
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
CREATE INDEX index_people_name_trgm ON people USING gin (public.f_unaccent(name) gin_trgm_ops);
E aponte a gem para o wrapper:
Huginn.configure { |c| c.unaccent_function = "public.f_unaccent" }
Ou gere a migration para todos os modelos Searchable (wrapper + índices inclusos):
rails g huginn:trigram_indexes # todos os modelos Searchable
rails g huginn:trigram_indexes Person Product # modelos específicos
O índice é opcional — sem ele a busca ainda retorna resultados corretos (via sequential scan), e os mesmos índices também aceleram as estratégias :unaccent (ILIKE) e :simple (LIKE).
Observações:
- Requer as extensões
pg_trgmeunaccent. - Só ajuda em termos de busca de 3 caracteres ou mais — trigramas precisam disso para casar. Termos menores sempre fazem scan.
- Para conferir o uso, rode
Person.search("termo").explaincom o wrapper configurado e um índicegin_trgm_opspresente.
Eficiência da query
fase 1 construir a relation subqueries (pk IN … / ORDER BY (SELECT …)) + search + filters + order (sem dados em memória)
fase 2 count SELECT COUNT(*) ... (relation base sem joins)
paginate offset / limit
preload SELECT ... WHERE id IN (subset) (2ª query leve)
Para um datatable de Plano com includes: profundos, isso são exatamente 2 queries extras na página pequena em vez de um JOIN enorme.
Arquitetura
lib/huginn.rb entry, Huginn.configure, Huginn.instrument
lib/huginn/configuration.rb search_strategy, unaccent_function, pagy_*
lib/huginn/railtie.rb auto-inclui os concerns no ActiveRecord
lib/huginn/datatable.rb Huginn::Datatable (agregador)
lib/huginn/datatable/datatable.rb o Concern do datatable
lib/huginn/datatable/validator.rb validação de coluna/associação + resolução Arel
lib/huginn/datatable/association_path.rb resolução de cadeias de associação + subqueries
lib/huginn/datatable/allowed_paths.rb expansão/autorização da allowlist `allowed_paths:`
lib/huginn/datatable/filter_normalizer.rb normalização funcional de params
lib/huginn/datatable/paginator.rb count enxuto, paginação, preload isolado
lib/huginn/searchable.rb Huginn::Searchable (agregador)
lib/huginn/searchable/searchable.rb o Concern do search + DSL
lib/huginn/searchable/query.rb construtor de busca tolerante (subqueries + OR)
lib/huginn/searchable/fuzzy.rb predicados pg_trgm / unaccent / simple
lib/generators/... gerador `huginn:trigram_indexes` (migrations de índices GIN)
Instrumentação
Huginn.instrument envolve eventos de ActiveSupport::Notifications no namespace huginn (ex.: datatable.call.huginn). Assine com ActiveSupport::Notifications.subscribe(/\.huginn/).
Licença
MIT