huginn_datatable 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +62 -10
- data/README.pt-BR.md +62 -10
- data/lib/generators/huginn/trigram_indexes/templates/add_huginn_trigram_indexes.rb.tt +26 -0
- data/lib/generators/huginn/trigram_indexes/trigram_indexes_generator.rb +90 -0
- data/lib/huginn/configuration.rb +10 -3
- data/lib/huginn/datatable/paginator.rb +4 -8
- data/lib/huginn/searchable/fuzzy.rb +18 -11
- data/lib/huginn/searchable/query.rb +61 -23
- data/lib/huginn/searchable/searchable.rb +2 -2
- data/lib/huginn/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '05719914312baa1225e26059bd5154312ee5df8bc369df6f5f9676d423ed9d0e'
|
|
4
|
+
data.tar.gz: b876f81b7add7781a4bd6b7424e3674f251c09ecfe38e0a70e8f891bb2286c76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bed2c0bc55d70a6ceaa57a6a84fb264360df75cbab4f42a2a82031073fdd0f15778b1681afcdc35aba31a478e4e8c18a6acfc960b725a1018306211845e72c72
|
|
7
|
+
data.tar.gz: 8f58d9e3fec2d2a38350dc010f9b68d6ab60449e27338291dfae8ae7bcaba2b96cbb64f945067487c1ed97889c6642bbb451dfde21644b0170e708ae109aa30a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
### Unreleased
|
|
4
4
|
|
|
5
|
+
#### 0.2.1
|
|
6
|
+
|
|
7
|
+
- Association-scoped `search` now matches through **primary-key semi-join subqueries** (`pk IN (SELECT DISTINCT pk FROM <base> JOIN <chain> WHERE <fuzzy>)`) instead of `left_joins` — one subquery per distinct association chain, combined with OR. The base relation never joins, so the count is a plain `COUNT(*)`.
|
|
8
|
+
|
|
9
|
+
#### 0.2.0
|
|
10
|
+
|
|
11
|
+
- **Breaking:** `:pg_trgm` search now uses the PostgreSQL `%` operator (`UNACCENT(col) % UNACCENT('term')`), driven by the **indexable** operator instead of a bare `similarity()` comparison. A `gin_trgm_ops` GIN index on `UNACCENT(col)` is used when present (BitmapOr over the same index with the ILIKE branch); without it, results stay correct via sequential scan.
|
|
12
|
+
- **Breaking:** removed the `fuzzy_threshold` configuration. The similarity cutoff is now the PostgreSQL GUC `pg_trgm.similarity_threshold` (default `0.3`, same as the old gem default) — tune with `SELECT set_limit(...)` / `SET pg_trgm.similarity_threshold`.
|
|
13
|
+
- New `unaccent_function` configuration (default `"unaccent"`): the unaccent function used in search SQL. Point it at an `IMMUTABLE` wrapper to make GIN trigram indexes usable (the pg builtin `unaccent()` is `STABLE` on PG 13+ and cannot be indexed directly).
|
|
14
|
+
- New generator `rails g huginn:trigram_indexes [Model...]` scaffolds a migration that creates an `IMMUTABLE public.f_unaccent` wrapper and `CREATE INDEX ... USING gin (public.f_unaccent(col) gin_trgm_ops)` for Searchable models (all `:string`/`:text` columns, including association-scoped ones).
|
|
5
15
|
- Runtime dependency on Pagy declared in the gemspec (`pagy >= 6`) — consumers install it automatically (datatable pagination is not test-only anymore).
|
|
6
16
|
- Supported Rails floor bumped to **7.1** (`>= 7.1, < 9`); Ruby `>= 3.0` without an upper bound (Rails 8 + Ruby 4 supported).
|
|
7
17
|
- Appraisals now cover **Rails 7.1 / 7.2 / 8.0** (was 6.1/7.0/7.1) with generated `gemfiles/*.gemfile`; CI matrix runs every Rails over supported Rubies (3.0 up to 4.0, including Rails 8 on Ruby 4.0).
|
data/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./logo.png" width="140" alt="Huginn logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
<h1 align="center">Huginn</h1>
|
|
2
6
|
|
|
3
7
|
<p align="center">
|
|
@@ -9,14 +13,14 @@
|
|
|
9
13
|
🇺🇸 English · <a href="./README.pt-BR.md">🇧🇷 Português</a>
|
|
10
14
|
</p>
|
|
11
15
|
|
|
12
|
-
Huginn is a lightweight query layer for Rails that turns a raw datatable request into a **lean count, a paginated subset and one preload** — instead of a massive JOIN materialized in memory. It also ships a PostgreSQL fuzzy-search builder (`pg_trgm` similarity with `unaccent` and `ILIKE` fallback) that is tolerant to typos and accents.
|
|
16
|
+
Huginn is a lightweight query layer for Rails that turns a raw datatable request into a **lean count, a paginated subset and one preload** — instead of a massive JOIN materialized in memory. It also ships a PostgreSQL fuzzy-search builder (`pg_trgm` similarity via the `%` operator with `unaccent` and `ILIKE` fallback) that is tolerant to typos and accents — and that can lean on a GIN index when present.
|
|
13
17
|
|
|
14
18
|
## Highlights
|
|
15
19
|
|
|
16
20
|
- **Two-phase execution** — association filters/orders/range become reflection-secured subqueries, then a lean count and `preload` **only on the paginated subset**.
|
|
17
|
-
- **Lean counts** — `COUNT(
|
|
21
|
+
- **Lean counts** — the base relation never joins (`COUNT(*)` over a stripped relation); association matches run as pk subqueries instead.
|
|
18
22
|
- **SQL injection safe ordering/filtering** — every column reference is resolved through Arel reflection, never string-interpolated.
|
|
19
|
-
- **Accent/typo tolerant search** — `pg_trgm` similarity OR `unaccent+ILIKE`, with a configurable fallback chain.
|
|
23
|
+
- **Accent/typo tolerant search** — `pg_trgm` similarity (`%` operator) OR `unaccent+ILIKE`, with a configurable fallback chain; uses a `gin_trgm_ops` GIN index when one exists.
|
|
20
24
|
- **Rails conventions** — works with `ActionController::Parameters`, Railtie auto-includes both concerns (toggleable), zero boilerplate.
|
|
21
25
|
|
|
22
26
|
## Development
|
|
@@ -62,17 +66,24 @@ gem "huginn"
|
|
|
62
66
|
```ruby
|
|
63
67
|
# config/initializers/huginn.rb
|
|
64
68
|
Huginn.configure do |config|
|
|
65
|
-
# :pg_trgm (recommended) — trigram similarity OR unaccent+ILIKE
|
|
69
|
+
# :pg_trgm (recommended) — trigram similarity (%) OR unaccent+ILIKE
|
|
66
70
|
# :unaccent — unaccent + ILIKE only
|
|
67
71
|
# :simple — plain LIKE
|
|
68
72
|
config.search_strategy = :pg_trgm
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
# Unaccent function used around search terms/columns. Defaults to the pg
|
|
75
|
+
# builtin UNACCENT(). Point it at the IMMUTABLE wrapper created by
|
|
76
|
+
# `rails g huginn:trigram_indexes` ("public.f_unaccent") so GIN trigram
|
|
77
|
+
# indexes can actually be used.
|
|
78
|
+
config.unaccent_function = "unaccent"
|
|
79
|
+
|
|
71
80
|
config.pagy_items = 10 # default page size
|
|
72
81
|
config.pagy_max_items = 500 # hard cap for per_page
|
|
73
82
|
end
|
|
74
83
|
```
|
|
75
84
|
|
|
85
|
+
> **Threshold:** under `:pg_trgm` the similarity cutoff is the PostgreSQL GUC `pg_trgm.similarity_threshold` (default `0.3`), **not** a gem-level setting. Tune it with `SET pg_trgm.similarity_threshold = 0.4` / `SELECT set_limit(0.4)` on the database.
|
|
86
|
+
|
|
76
87
|
## Railtie (automatic include)
|
|
77
88
|
|
|
78
89
|
By default the Railtie includes `Huginn::Datatable` and `Huginn::Searchable` into every `ActiveRecord::Base` model. You do **not** need `include` statements unless you opt in selectively:
|
|
@@ -118,7 +129,7 @@ Any `column` **or** `association.column` reference is validated and mapped to it
|
|
|
118
129
|
Plano.datatable({ orders: [{ "operadora.pessoa.nome" => "asc" }] }, allowed_paths: [{ operadora: :pessoa }])
|
|
119
130
|
```
|
|
120
131
|
|
|
121
|
-
> Association filters/range and
|
|
132
|
+
> Association filters/range, ordering and search use reflection-resolved subqueries (see the "allowlist of associations" section below). The base relation stays singular and join-free, so the count is a plain `COUNT(*)`.
|
|
122
133
|
|
|
123
134
|
## Association allowlist (`allowed_paths`)
|
|
124
135
|
|
|
@@ -174,17 +185,57 @@ class Person < ApplicationRecord
|
|
|
174
185
|
searchable_columns :name, company: [:name, :cnpj]
|
|
175
186
|
end
|
|
176
187
|
|
|
177
|
-
Person.search("globex") # matches company.name via a
|
|
188
|
+
Person.search("globex") # matches company.name via a pk subquery
|
|
178
189
|
Person.search("kayky", distinct: false) # disable the implicit DISTINCT
|
|
179
190
|
```
|
|
180
191
|
|
|
181
192
|
`Huginn::Datatable` reuses `Huginn::Searchable.search` automatically when the model responds to `search`.
|
|
182
193
|
|
|
194
|
+
## Trigram indexes (performance)
|
|
195
|
+
|
|
196
|
+
Under `:pg_trgm`, each searchable column produces an indexable predicate:
|
|
197
|
+
|
|
198
|
+
```sql
|
|
199
|
+
(UNACCENT(col) % UNACCENT('term')) OR (UNACCENT(col) ILIKE UNACCENT('%term%'))
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Both branches are supported by a **GIN trigram index**, so the planner can run a `BitmapOr` on it. Because the pg builtin `unaccent()` is `STABLE` (not `IMMUTABLE`) on PostgreSQL 13+, it cannot be used directly in an index expression — you need an `IMMUTABLE` wrapper plus an expression index on it:
|
|
203
|
+
|
|
204
|
+
```sql
|
|
205
|
+
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
|
|
206
|
+
RETURNS text AS $$
|
|
207
|
+
SELECT public.unaccent('public.unaccent', $1);
|
|
208
|
+
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
|
|
209
|
+
|
|
210
|
+
CREATE INDEX index_people_name_trgm ON people USING gin (public.f_unaccent(name) gin_trgm_ops);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Then point the gem at the wrapper:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
Huginn.configure { |c| c.unaccent_function = "public.f_unaccent" }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Or scaffold the migration for every Searchable model (wrapper + indexes included):
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
rails g huginn:trigram_indexes # all Searchable models
|
|
223
|
+
rails g huginn:trigram_indexes Person Product # specific models
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The index is **optional** — without it the search still returns correct results (via a sequential scan), and the same indexes also accelerate the `:unaccent` (ILIKE) and `:simple` (LIKE) strategies.
|
|
227
|
+
|
|
228
|
+
Notes:
|
|
229
|
+
|
|
230
|
+
- Requires the `pg_trgm` and `unaccent` extensions.
|
|
231
|
+
- Only helpful for search terms of **3 or more characters** — trigrams need that many to match. Shorter terms always scan.
|
|
232
|
+
- To see it working, run a `Person.search("term").explain` with the wrapper configured and a `gin_trgm_ops` index present.
|
|
233
|
+
|
|
183
234
|
## Query efficiency
|
|
184
235
|
|
|
185
236
|
```
|
|
186
237
|
phase 1 build the relation subqueries (pk IN … / ORDER BY (SELECT …)) + search + filters + order (no data in memory)
|
|
187
|
-
phase 2 count SELECT COUNT(
|
|
238
|
+
phase 2 count SELECT COUNT(*) ... (base relation is join-free)
|
|
188
239
|
paginate offset / limit
|
|
189
240
|
preload SELECT ... WHERE id IN (subset) (2nd lightweight query)
|
|
190
241
|
```
|
|
@@ -195,7 +246,7 @@ For a `Plano` datatable with deep `includes:`, this is exactly **2 extra queries
|
|
|
195
246
|
|
|
196
247
|
```
|
|
197
248
|
lib/huginn.rb entry, Huginn.configure, Huginn.instrument
|
|
198
|
-
lib/huginn/configuration.rb search_strategy,
|
|
249
|
+
lib/huginn/configuration.rb search_strategy, unaccent_function, pagy_*
|
|
199
250
|
lib/huginn/railtie.rb auto-includes concerns into ActiveRecord
|
|
200
251
|
lib/huginn/datatable.rb Huginn::Datatable (aggregator)
|
|
201
252
|
lib/huginn/datatable/datatable.rb the datatable Concern
|
|
@@ -206,8 +257,9 @@ lib/huginn/datatable/filter_normalizer.rb functional param normalization
|
|
|
206
257
|
lib/huginn/datatable/paginator.rb lean count, pagination, isolated preload
|
|
207
258
|
lib/huginn/searchable.rb Huginn::Searchable (aggregator)
|
|
208
259
|
lib/huginn/searchable/searchable.rb the search Concern + DSL
|
|
209
|
-
lib/huginn/searchable/query.rb tolerant search builder (
|
|
260
|
+
lib/huginn/searchable/query.rb tolerant search builder (subqueries + OR)
|
|
210
261
|
lib/huginn/searchable/fuzzy.rb pg_trgm / unaccent / simple predicates
|
|
262
|
+
lib/generators/... `huginn:trigram_indexes` (GIN index migrations)
|
|
211
263
|
```
|
|
212
264
|
|
|
213
265
|
## Instrumentation
|
data/README.pt-BR.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./logo.png" width="140" alt="Logo do Huginn">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
<h1 align="center">Huginn</h1>
|
|
2
6
|
|
|
3
7
|
<p align="center">
|
|
@@ -9,14 +13,14 @@
|
|
|
9
13
|
<a href="./README.md">🇺🇸 English</a> · 🇧🇷 Português
|
|
10
14
|
</p>
|
|
11
15
|
|
|
12
|
-
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` com `unaccent` e fallback `ILIKE`) tolerante a erros de digitação e acentuação.
|
|
16
|
+
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.
|
|
13
17
|
|
|
14
18
|
## Destaques
|
|
15
19
|
|
|
16
20
|
- **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**.
|
|
17
|
-
- **Counts enxutos** — `COUNT(
|
|
21
|
+
- **Counts enxutos** — a relation base nunca faz join (`COUNT(*)` sobre uma relation restrita); as buscas de associação acontecem via subqueries no pk.
|
|
18
22
|
- **Ordenação/filtro seguros contra SQL injection** — toda referência de coluna é resolvida via reflexão do Arel, nunca interpolada como string.
|
|
19
|
-
- **Busca tolerante a acentos/typos** — similaridade `pg_trgm` OU `unaccent+ILIKE`, com cadeia de fallback configurável.
|
|
23
|
+
- **Busca tolerante a acentos/typos** — similaridade `pg_trgm` (operador `%`) OU `unaccent+ILIKE`, com cadeia de fallback configurável; usa índice GIN `gin_trgm_ops` quando existente.
|
|
20
24
|
- **Convenções do Rails** — funciona com `ActionController::Parameters`, Railtie inclui ambos os concerns automaticamente (desligável), zero boilerplate.
|
|
21
25
|
|
|
22
26
|
## Desenvolvimento
|
|
@@ -62,17 +66,24 @@ gem "huginn"
|
|
|
62
66
|
```ruby
|
|
63
67
|
# config/initializers/huginn.rb
|
|
64
68
|
Huginn.configure do |config|
|
|
65
|
-
# :pg_trgm (recomendado) — similaridade trigram OU unaccent+ILIKE
|
|
69
|
+
# :pg_trgm (recomendado) — similaridade trigram (%) OU unaccent+ILIKE
|
|
66
70
|
# :unaccent — somente unaccent + ILIKE
|
|
67
71
|
# :simple — LIKE simples
|
|
68
72
|
config.search_strategy = :pg_trgm
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
# Função unaccent usada em torno de termos/colunas. O default é o UNACCENT()
|
|
75
|
+
# built-in do PG. Aponte para o wrapper IMMUTABLE criado por
|
|
76
|
+
# `rails g huginn:trigram_indexes` ("public.f_unaccent") para que índices
|
|
77
|
+
# GIN trigram possam ser usados de fato.
|
|
78
|
+
config.unaccent_function = "unaccent"
|
|
79
|
+
|
|
71
80
|
config.pagy_items = 10 # tamanho de página padrão
|
|
72
81
|
config.pagy_max_items = 500 # teto máximo de per_page
|
|
73
82
|
end
|
|
74
83
|
```
|
|
75
84
|
|
|
85
|
+
> **Limiar:** sob `:pg_trgm`, o corte de similaridade é o GUC do PostgreSQL `pg_trgm.similarity_threshold` (default `0.3`), **não** uma configuração da gem. Ajuste com `SET pg_trgm.similarity_threshold = 0.4` / `SELECT set_limit(0.4)` no banco.
|
|
86
|
+
|
|
76
87
|
## Railtie (include automático)
|
|
77
88
|
|
|
78
89
|
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:
|
|
@@ -118,7 +129,7 @@ Qualquer referência `column` **ou** `associacao.column` é validada e mapeada p
|
|
|
118
129
|
Plano.datatable({ orders: [{ "operadora.pessoa.nome" => "asc" }] }, allowed_paths: [{ operadora: :pessoa }])
|
|
119
130
|
```
|
|
120
131
|
|
|
121
|
-
> Filtros/ranges e
|
|
132
|
+
> 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.
|
|
122
133
|
|
|
123
134
|
## Allowlist de associações (`allowed_paths`)
|
|
124
135
|
|
|
@@ -174,17 +185,57 @@ class Person < ApplicationRecord
|
|
|
174
185
|
searchable_columns :name, company: [:name, :cnpj]
|
|
175
186
|
end
|
|
176
187
|
|
|
177
|
-
Person.search("globex") # encontra company.name via
|
|
188
|
+
Person.search("globex") # encontra company.name via subquery no pk
|
|
178
189
|
Person.search("kayky", distinct: false) # desativa o DISTINCT implícito
|
|
179
190
|
```
|
|
180
191
|
|
|
181
192
|
`Huginn::Datatable` reutiliza `Huginn::Searchable.search` automaticamente quando o modelo responde a `search`.
|
|
182
193
|
|
|
194
|
+
## Índices trigram (performance)
|
|
195
|
+
|
|
196
|
+
Sob `:pg_trgm`, cada coluna pesquisável gera um predicado indexável:
|
|
197
|
+
|
|
198
|
+
```sql
|
|
199
|
+
(UNACCENT(col) % UNACCENT('termo')) OR (UNACCENT(col) ILIKE UNACCENT('%termo%'))
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
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:
|
|
203
|
+
|
|
204
|
+
```sql
|
|
205
|
+
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
|
|
206
|
+
RETURNS text AS $$
|
|
207
|
+
SELECT public.unaccent('public.unaccent', $1);
|
|
208
|
+
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
|
|
209
|
+
|
|
210
|
+
CREATE INDEX index_people_name_trgm ON people USING gin (public.f_unaccent(name) gin_trgm_ops);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
E aponte a gem para o wrapper:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
Huginn.configure { |c| c.unaccent_function = "public.f_unaccent" }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Ou gere a migration para todos os modelos Searchable (wrapper + índices inclusos):
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
rails g huginn:trigram_indexes # todos os modelos Searchable
|
|
223
|
+
rails g huginn:trigram_indexes Person Product # modelos específicos
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
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).
|
|
227
|
+
|
|
228
|
+
Observações:
|
|
229
|
+
|
|
230
|
+
- Requer as extensões `pg_trgm` e `unaccent`.
|
|
231
|
+
- Só ajuda em termos de busca de **3 caracteres ou mais** — trigramas precisam disso para casar. Termos menores sempre fazem scan.
|
|
232
|
+
- Para conferir o uso, rode `Person.search("termo").explain` com o wrapper configurado e um índice `gin_trgm_ops` presente.
|
|
233
|
+
|
|
183
234
|
## Eficiência da query
|
|
184
235
|
|
|
185
236
|
```
|
|
186
237
|
fase 1 construir a relation subqueries (pk IN … / ORDER BY (SELECT …)) + search + filters + order (sem dados em memória)
|
|
187
|
-
fase 2 count SELECT COUNT(
|
|
238
|
+
fase 2 count SELECT COUNT(*) ... (relation base sem joins)
|
|
188
239
|
paginate offset / limit
|
|
189
240
|
preload SELECT ... WHERE id IN (subset) (2ª query leve)
|
|
190
241
|
```
|
|
@@ -195,7 +246,7 @@ Para um datatable de `Plano` com `includes:` profundos, isso são exatamente **2
|
|
|
195
246
|
|
|
196
247
|
```
|
|
197
248
|
lib/huginn.rb entry, Huginn.configure, Huginn.instrument
|
|
198
|
-
lib/huginn/configuration.rb search_strategy,
|
|
249
|
+
lib/huginn/configuration.rb search_strategy, unaccent_function, pagy_*
|
|
199
250
|
lib/huginn/railtie.rb auto-inclui os concerns no ActiveRecord
|
|
200
251
|
lib/huginn/datatable.rb Huginn::Datatable (agregador)
|
|
201
252
|
lib/huginn/datatable/datatable.rb o Concern do datatable
|
|
@@ -206,8 +257,9 @@ lib/huginn/datatable/filter_normalizer.rb normalização funcional de params
|
|
|
206
257
|
lib/huginn/datatable/paginator.rb count enxuto, paginação, preload isolado
|
|
207
258
|
lib/huginn/searchable.rb Huginn::Searchable (agregador)
|
|
208
259
|
lib/huginn/searchable/searchable.rb o Concern do search + DSL
|
|
209
|
-
lib/huginn/searchable/query.rb construtor de busca tolerante (
|
|
260
|
+
lib/huginn/searchable/query.rb construtor de busca tolerante (subqueries + OR)
|
|
210
261
|
lib/huginn/searchable/fuzzy.rb predicados pg_trgm / unaccent / simple
|
|
262
|
+
lib/generators/... gerador `huginn:trigram_indexes` (migrations de índices GIN)
|
|
211
263
|
```
|
|
212
264
|
|
|
213
265
|
## Instrumentação
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class AddHuginnTrigramIndexes < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def up
|
|
3
|
+
enable_extension "unaccent" unless connection.extension_enabled?("unaccent")
|
|
4
|
+
|
|
5
|
+
# The pg builtin unaccent() is STABLE (not IMMUTABLE) since PG 13+, so it
|
|
6
|
+
# cannot be used in an index expression. Wrap it in an IMMUTABLE function
|
|
7
|
+
# that always pins the default dictionary.
|
|
8
|
+
execute <<~SQL
|
|
9
|
+
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
|
|
10
|
+
RETURNS text AS $$
|
|
11
|
+
SELECT public.unaccent('public.unaccent', $1);
|
|
12
|
+
$$
|
|
13
|
+
LANGUAGE sql IMMUTABLE PARALLEL SAFE;
|
|
14
|
+
SQL
|
|
15
|
+
<% @entries.each do |entry| %>
|
|
16
|
+
add_index :<%= entry[:table] %>, "public.f_unaccent(<%= entry[:column] %>) gin_trgm_ops", using: :gin, name: "index_<%= entry[:table] %>_<%= entry[:column] %>_trgm"
|
|
17
|
+
<% end %>
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def down
|
|
21
|
+
<% @entries.each do |entry| %>
|
|
22
|
+
remove_index :<%= entry[:table] %>, name: "index_<%= entry[:table] %>_<%= entry[:column] %>_trgm"
|
|
23
|
+
<% end %>
|
|
24
|
+
execute "DROP FUNCTION IF EXISTS public.f_unaccent(text)"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
|
|
6
|
+
module Huginn
|
|
7
|
+
module Generators
|
|
8
|
+
# Scaffolds a migration that creates the PostgreSQL expression GIN indexes
|
|
9
|
+
# (gin_trgm_ops on an IMMUTABLE unaccent wrapper of the column) backing
|
|
10
|
+
# Huginn::Searchable's :pg_trgm strategy for every model that includes
|
|
11
|
+
# Huginn::Searchable.
|
|
12
|
+
#
|
|
13
|
+
# rails g huginn:trigram_indexes # all Searchable models
|
|
14
|
+
# rails g huginn:trigram_indexes Person Product # specific models
|
|
15
|
+
#
|
|
16
|
+
# The migration creates an IMMUTABLE public.f_unaccent(text) wrapper and
|
|
17
|
+
# the GIN indexes. To make the search actually use them, point the gem at
|
|
18
|
+
# the wrapper (the pg builtin unaccent() is STABLE on PG 13+ and cannot be
|
|
19
|
+
# indexed directly):
|
|
20
|
+
#
|
|
21
|
+
# Huginn.configure { |c| c.unaccent_function = "public.f_unaccent" }
|
|
22
|
+
#
|
|
23
|
+
# Indexes are only used when the :pg_trgm strategy is configured and the
|
|
24
|
+
# pg_trgm/unaccent extensions are installed.
|
|
25
|
+
class TrigramIndexesGenerator < Rails::Generators::Base
|
|
26
|
+
include Rails::Generators::Migration
|
|
27
|
+
|
|
28
|
+
source_root File.expand_path("templates", __dir__)
|
|
29
|
+
|
|
30
|
+
argument :model_names, type: :array, default: [], desc: "Models to index (default: all Searchable models)"
|
|
31
|
+
|
|
32
|
+
def self.next_migration_number(dirname)
|
|
33
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
34
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def copy_migration
|
|
38
|
+
@entries = resolve_entries
|
|
39
|
+
return if @entries.empty?
|
|
40
|
+
|
|
41
|
+
migration_template(
|
|
42
|
+
"add_huginn_trigram_indexes.rb.tt",
|
|
43
|
+
"db/migrate/add_huginn_trigram_indexes.rb"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def resolve_entries
|
|
50
|
+
models = if model_names.any?
|
|
51
|
+
model_names.map { |name| constantize(name) }
|
|
52
|
+
else
|
|
53
|
+
ActiveRecord::Base.descendants
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
models.filter_map do |model|
|
|
57
|
+
next unless model.respond_to?(:searchable_columns_config_resolved)
|
|
58
|
+
|
|
59
|
+
columns_for(model)
|
|
60
|
+
end.reject(&:empty?).flatten.uniq
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def columns_for(model)
|
|
64
|
+
scope = []
|
|
65
|
+
resolved = model.searchable_columns_config_resolved
|
|
66
|
+
|
|
67
|
+
Array(resolved[:columns]).map(&:to_s).reject(&:blank?).each do |field|
|
|
68
|
+
scope << { table: model.table_name, column: field } if model.columns_hash.key?(field)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
resolved[:associations].each do |assoc, cols|
|
|
72
|
+
reflection = model.reflect_on_association(assoc.to_sym)
|
|
73
|
+
next unless reflection
|
|
74
|
+
|
|
75
|
+
Array(cols).map(&:to_s).each do |col|
|
|
76
|
+
scope << { table: reflection.klass.table_name, column: col } if reflection.klass.columns_hash.key?(col)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
scope
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def constantize(klass_name)
|
|
84
|
+
klass_name.constantize
|
|
85
|
+
rescue NameError
|
|
86
|
+
raise Thor::Error, "Could not find model #{klass_name.inspect}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/huginn/configuration.rb
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Huginn
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :
|
|
5
|
+
attr_accessor :pagy_items, :pagy_max_items, :unaccent_function
|
|
6
6
|
attr_writer :search_strategy, :auto_include_datatable, :auto_include_searchable
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
|
-
@fuzzy_threshold = 0.3
|
|
10
9
|
@pagy_items = 10
|
|
11
10
|
@pagy_max_items = 500
|
|
11
|
+
# The unaccent function SQL emits around searchable columns/terms. Defaults
|
|
12
|
+
# to the pg builtin UNACCENT(). To let GIN trigram indexes be used, point
|
|
13
|
+
# this at an IMMUTABLE wrapper (see `rails g huginn:trigram_indexes`),
|
|
14
|
+
# e.g. "public.f_unaccent".
|
|
15
|
+
@unaccent_function = "unaccent"
|
|
12
16
|
@search_strategy = :pg_trgm
|
|
13
17
|
@auto_include_datatable = true
|
|
14
18
|
@auto_include_searchable = true
|
|
15
19
|
end
|
|
16
20
|
|
|
17
|
-
# :pg_trgm -> similarity(
|
|
21
|
+
# :pg_trgm -> trigram similarity (%) OR unaccent+ILIKE. The similarity
|
|
22
|
+
# cutoff is the PostgreSQL GUC pg_trgm.similarity_threshold
|
|
23
|
+
# (tune with SELECT set_limit(...)); a gin_trgm_ops GIN index
|
|
24
|
+
# on UNACCENT(col) is used when present.
|
|
18
25
|
# :unaccent -> unaccent + ILIKE only
|
|
19
26
|
# :simple -> plain LIKE
|
|
20
27
|
def search_strategy
|
|
@@ -10,10 +10,9 @@ module Huginn
|
|
|
10
10
|
# 2. count lean, paginate with Pagy, then preload associations only
|
|
11
11
|
# on the small paginated subset.
|
|
12
12
|
#
|
|
13
|
-
# The
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# massive COUNT(DISTINCT ...) over the joins.
|
|
13
|
+
# The base relation never carries joins (association filters, ranges,
|
|
14
|
+
# orders and the search are all resolved through primary-key subqueries),
|
|
15
|
+
# so the count stays a plain COUNT(*) over the stripped relation.
|
|
17
16
|
class Paginator
|
|
18
17
|
attr_reader :relation, :params, :includes
|
|
19
18
|
|
|
@@ -77,10 +76,7 @@ module Huginn
|
|
|
77
76
|
end
|
|
78
77
|
|
|
79
78
|
def total_count
|
|
80
|
-
|
|
81
|
-
return stripped.count unless stripped.left_outer_joins_values.any? || stripped.joins_values.any?
|
|
82
|
-
|
|
83
|
-
stripped.select(stripped.arel_table[stripped.primary_key]).distinct.count
|
|
79
|
+
relation.except(:select, :includes, :order, :offset, :limit).count
|
|
84
80
|
end
|
|
85
81
|
|
|
86
82
|
def config
|
|
@@ -6,16 +6,24 @@ module Huginn
|
|
|
6
6
|
#
|
|
7
7
|
# Strategy chain (driven by Huginn.configuration.search_strategy):
|
|
8
8
|
#
|
|
9
|
-
# :pg_trgm -> trigram similarity OR unaccent+ILIKE (best for typos)
|
|
9
|
+
# :pg_trgm -> trigram similarity (%) OR unaccent+ILIKE (best for typos).
|
|
10
|
+
# Both branches are supported by a gin_trgm_ops GIN index
|
|
11
|
+
# on UNACCENT(col); the index is used only when it exists.
|
|
10
12
|
# :unaccent -> unaccent + ILIKE (case/accents insensitive)
|
|
11
13
|
# :simple -> plain LIKE
|
|
12
14
|
module Fuzzy
|
|
13
|
-
# Gentle helpers to wrap a node in UNACCENT(...).
|
|
15
|
+
# Gentle helpers to wrap a node in UNACCENT(...). The function name comes
|
|
16
|
+
# from Huginn.configuration.unaccent_function so an IMMUTABLE wrapper can
|
|
17
|
+
# be swapped in to make GIN trigram indexes usable.
|
|
14
18
|
module Unaccentable
|
|
15
19
|
private
|
|
16
20
|
|
|
17
21
|
def unaccent(node)
|
|
18
|
-
Arel::Nodes::NamedFunction.new(
|
|
22
|
+
Arel::Nodes::NamedFunction.new(unaccent_function, [node])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def unaccent_function
|
|
26
|
+
Huginn.configuration.unaccent_function
|
|
19
27
|
end
|
|
20
28
|
end
|
|
21
29
|
|
|
@@ -62,23 +70,22 @@ module Huginn
|
|
|
62
70
|
class Trigram
|
|
63
71
|
include Unaccentable
|
|
64
72
|
|
|
65
|
-
def initialize(column, value
|
|
73
|
+
def initialize(column, value)
|
|
66
74
|
@column = column
|
|
67
75
|
@value = value
|
|
68
|
-
@threshold = threshold
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
def predicate
|
|
72
|
-
similarity = Arel::Nodes::
|
|
73
|
-
|
|
74
|
-
unaccent(Arel::Nodes.build_quoted(@value, @column))
|
|
75
|
-
])
|
|
76
|
-
threshold = Arel::Nodes.build_quoted(@threshold)
|
|
77
|
-
Arel::Nodes::InfixOperation.new(">", similarity, threshold).or(fallback)
|
|
79
|
+
similarity = Arel::Nodes::InfixOperation.new("%", unaccent(@column), unaccent(value))
|
|
80
|
+
similarity.or(fallback)
|
|
78
81
|
end
|
|
79
82
|
|
|
80
83
|
private
|
|
81
84
|
|
|
85
|
+
def value
|
|
86
|
+
Arel::Nodes.build_quoted(@value, @column)
|
|
87
|
+
end
|
|
88
|
+
|
|
82
89
|
def fallback
|
|
83
90
|
Unaccent.new(@column, @value).predicate
|
|
84
91
|
end
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../datatable/association_path"
|
|
4
|
+
|
|
3
5
|
module Huginn
|
|
4
6
|
module Searchable
|
|
7
|
+
AssociationPath = Datatable::AssociationPath unless const_defined?(:AssociationPath)
|
|
5
8
|
# Builds a tolerant full-text search relation over a model's searchable
|
|
6
|
-
# columns
|
|
9
|
+
# columns.
|
|
10
|
+
#
|
|
11
|
+
# Columns are split in two groups following the datatable pattern:
|
|
7
12
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
13
|
+
# * the model's own columns -> direct fuzzy predicates on the base WHERE
|
|
14
|
+
# * association columns -> semi-join subqueries on the primary key
|
|
15
|
+
# pk IN (SELECT DISTINCT pk FROM base
|
|
16
|
+
# JOIN <chain> ... WHERE <fuzzy>)
|
|
17
|
+
#
|
|
18
|
+
# The base relation never carries joins, so every match group stays one
|
|
19
|
+
# query and the count remains a plain COUNT(*). Multiple association
|
|
20
|
+
# chains produce one subquery each, combined with OR.
|
|
11
21
|
class Query
|
|
12
22
|
def self.call(model, value, options = {})
|
|
13
23
|
new(model, value, options).call
|
|
@@ -25,52 +35,72 @@ module Huginn
|
|
|
25
35
|
return apply_distinct(relation) if @value.strip.empty?
|
|
26
36
|
|
|
27
37
|
resolution = resolve_columns
|
|
28
|
-
|
|
38
|
+
predicates = fuzzy_predicates(resolution.fetch(:own))
|
|
39
|
+
resolution.fetch(:associations).each do |chain, attrs|
|
|
40
|
+
subquery = association_subquery(chain, attrs)
|
|
41
|
+
predicates << arel_table[primary_key].in(subquery.arel) if subquery
|
|
42
|
+
end
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
relation = relation.left_joins(*joins) if joins.any?
|
|
44
|
+
return apply_distinct(relation) if predicates.empty?
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
apply_distinct(relation.where(predicate))
|
|
46
|
+
apply_distinct(relation.where(predicates.reduce(&:or)))
|
|
35
47
|
end
|
|
36
48
|
|
|
37
49
|
private
|
|
38
50
|
|
|
51
|
+
def fuzzy_predicates(attrs)
|
|
52
|
+
attrs.map { |attr| fuzzy_predicate(attr) }
|
|
53
|
+
end
|
|
54
|
+
|
|
39
55
|
def resolve_columns
|
|
40
56
|
columns = @model.searchable_columns_config_resolved
|
|
41
|
-
|
|
42
|
-
|
|
57
|
+
own = []
|
|
58
|
+
associations = Hash.new { |hash, key| hash[key] = [] }
|
|
43
59
|
|
|
44
60
|
Array(columns[:columns]).map(&:to_s).reject(&:blank?).each do |field|
|
|
45
61
|
if field.include?(".")
|
|
46
|
-
|
|
47
|
-
push_association(
|
|
62
|
+
chain, column = field.split(".", 2)
|
|
63
|
+
push_association(associations, chain, column)
|
|
48
64
|
else
|
|
49
|
-
|
|
65
|
+
own << @model.arel_table[field] if @model.columns_hash.key?(field)
|
|
50
66
|
end
|
|
51
67
|
end
|
|
52
68
|
|
|
53
69
|
columns[:associations].each do |name, cols|
|
|
54
|
-
Array(cols).each { |
|
|
70
|
+
Array(cols).each { |column| push_association(associations, name.to_s, column.to_s) }
|
|
55
71
|
end
|
|
56
72
|
|
|
57
|
-
{
|
|
73
|
+
{ own: own, associations: associations }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def push_association(groups, chain, column)
|
|
77
|
+
path = AssociationPath.call(@model, "#{chain}.#{column}")
|
|
78
|
+
return unless path.valid?
|
|
79
|
+
return unless path.target_klass.columns_hash.key?(path.column)
|
|
80
|
+
|
|
81
|
+
groups[path.association_names] << path.target_table[path.column]
|
|
58
82
|
end
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
# Semi-join subquery over the base table: DISTINCT pk while joining the
|
|
85
|
+
# association chain of the group and applying the fuzzy predicates on the
|
|
86
|
+
# target columns. Mirrors datatable's association_ids_subquery.
|
|
87
|
+
def association_subquery(chain, attrs)
|
|
88
|
+
return nil if chain.empty?
|
|
63
89
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
spec = AssociationPath.join_spec_for(chain.map(&:to_sym))
|
|
91
|
+
base = @model.all.except(:select, :order, :offset, :limit)
|
|
92
|
+
.select(arel_table[primary_key])
|
|
93
|
+
.distinct
|
|
94
|
+
|
|
95
|
+
predicate = fuzzy_predicates(attrs).reduce(&:or)
|
|
96
|
+
base.joins(spec).where(predicate)
|
|
67
97
|
end
|
|
68
98
|
|
|
69
99
|
def fuzzy_predicate(attr)
|
|
70
100
|
case strategy
|
|
71
101
|
when :pg_trgm
|
|
72
102
|
if pg_trgm_available?
|
|
73
|
-
Fuzzy::Trigram.new(attr, @value
|
|
103
|
+
Fuzzy::Trigram.new(attr, @value).predicate
|
|
74
104
|
else
|
|
75
105
|
fallback_fuzzy(attr)
|
|
76
106
|
end
|
|
@@ -121,6 +151,14 @@ module Huginn
|
|
|
121
151
|
@model.connection.adapter_name.downcase.include?("postgres")
|
|
122
152
|
end
|
|
123
153
|
|
|
154
|
+
def arel_table
|
|
155
|
+
@model.arel_table
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def primary_key
|
|
159
|
+
@model.primary_key
|
|
160
|
+
end
|
|
161
|
+
|
|
124
162
|
def apply_distinct(relation)
|
|
125
163
|
@distinct ? relation.distinct : relation
|
|
126
164
|
end
|
|
@@ -17,8 +17,8 @@ module Huginn
|
|
|
17
17
|
#
|
|
18
18
|
# When nothing is declared, every :string/:text column of the model is
|
|
19
19
|
# searched automatically. Columns may be association-scoped ("person.name")
|
|
20
|
-
# or declared via keyword arguments; scoped columns are
|
|
21
|
-
#
|
|
20
|
+
# or declared via keyword arguments; scoped columns are resolved through
|
|
21
|
+
# semi-join subqueries on the primary key (no joins on the base relation).
|
|
22
22
|
def searchable_columns(*columns, **associations)
|
|
23
23
|
self.searchable_columns_config = {
|
|
24
24
|
columns: columns.flatten.compact.map(&:to_s),
|
data/lib/huginn/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: huginn_datatable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kayky Marcelo
|
|
@@ -139,6 +139,8 @@ files:
|
|
|
139
139
|
- LICENSE.txt
|
|
140
140
|
- README.md
|
|
141
141
|
- README.pt-BR.md
|
|
142
|
+
- lib/generators/huginn/trigram_indexes/templates/add_huginn_trigram_indexes.rb.tt
|
|
143
|
+
- lib/generators/huginn/trigram_indexes/trigram_indexes_generator.rb
|
|
142
144
|
- lib/huginn.rb
|
|
143
145
|
- lib/huginn/configuration.rb
|
|
144
146
|
- lib/huginn/datatable.rb
|