huginn_datatable 0.3.0 → 0.4.0
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 +11 -0
- data/README.md +83 -8
- data/README.pt-BR.md +83 -8
- data/lib/huginn/datatable/datatable.rb +69 -2
- data/lib/huginn/datatable/filter_normalizer.rb +6 -3
- data/lib/huginn/datatable/validator.rb +40 -0
- data/lib/huginn/searchable/searchable.rb +2 -1
- data/lib/huginn/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82a13098551b25e1b656beb853926f7dcbc964c420a64dde0e22182f735a02bb
|
|
4
|
+
data.tar.gz: abdcf5e8f489ad2b6bff0870bde97d443247f6d31b0efc93274a47da81837916
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a03a5dc06c8f00d4a66a9bd9af8d06ddc2a9a910dbbb5c8a392985f4b0a21a89439d40829d20559232e1a04d2978444fdcf1c39ac86ce20c176af3fbb98a1de
|
|
7
|
+
data.tar.gz: b229398a323d43253aba96f667ca27dc5d76eacdf3bec68d84cb51c482e8838914de5289fdc9093a293016743f5e14c69acdc5b60705ba9e57306d639b86ca74
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
### Unreleased
|
|
4
4
|
|
|
5
|
+
#### 0.4.0
|
|
6
|
+
|
|
7
|
+
- **PostgreSQL array columns in `filters`** (`uuid[]`, `text[]`, `string[]`...), for own columns, `huginn_attributes` aliases and association-scoped columns. The payload is unchanged:
|
|
8
|
+
- one value → the row contains it (`col @> ARRAY[v]`);
|
|
9
|
+
- several values → **any** of them (`col && ARRAY[...]`, the same semantics as `IN`), or **all** of them (`col @> ARRAY[...]`) when the field is declared with `array: :all`;
|
|
10
|
+
- `"null"` → empty (or NULL) array, combinable with values.
|
|
11
|
+
- New `huginn_array_match :field, :all` and `huginn_attributes alias: { column: "table.col", array: :all }` to choose the match mode (default `:any`).
|
|
12
|
+
- Array filter values are cast by the element type (invalid values — e.g. a malformed UUID — are dropped, never reaching SQL) and the literal is built from quoted values only.
|
|
13
|
+
- `filters` accept a Hash whose value is an Array (`filters[col][]=a&filters[col][]=b` → `{ "col" => [a, b] }`) as several values (IN / array match), the same as repeating the key. Before, the Array was taken as one value and never matched.
|
|
14
|
+
- **Fix:** default searchable columns no longer include array columns (`string[]`/`text[]`), which broke `search` with `unaccent(character varying[]) does not exist`.
|
|
15
|
+
|
|
5
16
|
#### 0.3.0
|
|
6
17
|
|
|
7
18
|
- **New `:full_text` search strategy** — PostgreSQL full text search over lexemes (`public.f_tsvector(col) @@ plainto_tsquery('portuguese'::regconfig, 'term')`), so morphological variants match (e.g. "compras" finds "compra"). Blind to typos, unlike `:pg_trgm`. Uses a GIN tsvector index when the `fts_function` wrapper (IMMUTABLE, dictionary pinned) exists; degrades gracefully to unaccent+ILIKE otherwise.
|
data/README.md
CHANGED
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
🇺🇸 English · <a href="./README.pt-BR.md">🇧🇷 Português</a>
|
|
14
14
|
</p>
|
|
15
15
|
|
|
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
|
|
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 tolerant-search builder (`pg_trgm` similarity via the `%` operator with `unaccent` and `ILIKE` fallback, plus a `:full_text` lexeme strategy) that is tolerant to typos and accents — and that can lean on GIN indexes when present.
|
|
17
17
|
|
|
18
18
|
## Highlights
|
|
19
19
|
|
|
20
20
|
- **Two-phase execution** — association filters/orders/range become reflection-secured subqueries, then a lean count and `preload` **only on the paginated subset**.
|
|
21
21
|
- **Lean counts** — the base relation never joins (`COUNT(*)` over a stripped relation); association matches run as pk subqueries instead.
|
|
22
22
|
- **SQL injection safe ordering/filtering** — every column reference is resolved through Arel reflection, never string-interpolated.
|
|
23
|
-
- **
|
|
23
|
+
- **Tolerant search** — `pg_trgm` similarity (`%` operator) OR `unaccent+ILIKE` (typo/acent tolerant) and `:full_text` over lexemes (morphology-aware); strategies are `search_strategy`-selectable or combined with OR, backed by GIN indexes when present.
|
|
24
24
|
- **Rails conventions** — works with `ActionController::Parameters`, Railtie auto-includes both concerns (toggleable), zero boilerplate.
|
|
25
25
|
|
|
26
26
|
## Development
|
|
@@ -58,7 +58,7 @@ The `gemfiles/*.gemfile` are generated by Appraisal (committed); their `.lock` f
|
|
|
58
58
|
## Installation
|
|
59
59
|
|
|
60
60
|
```ruby
|
|
61
|
-
gem "huginn"
|
|
61
|
+
gem "huginn_datatable", require: "huginn"
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
## Configuration
|
|
@@ -120,7 +120,7 @@ result = Plano.datatable(
|
|
|
120
120
|
includes: [{ operadora: { pessoa: [:endereco, :contatos] } }] # preloaded on the page only
|
|
121
121
|
)
|
|
122
122
|
|
|
123
|
-
result[:total_count] # Integer (lean COUNT
|
|
123
|
+
result[:total_count] # Integer (lean COUNT(*))
|
|
124
124
|
result[:data] # ActiveRecord::Relation (paged + preloaded)
|
|
125
125
|
```
|
|
126
126
|
|
|
@@ -130,10 +130,32 @@ Supported params:
|
|
|
130
130
|
|---|---|
|
|
131
131
|
| `page`, `per_page` | Pagination (clamped to `pagy_max_items`) |
|
|
132
132
|
| `search` | Delegates to `Huginn::Searchable.search` |
|
|
133
|
-
| `filters` | Hash / Array of hashes / pairs -> exact or `IN` conditions (`"col" => "null"` → `IS NULL`) |
|
|
133
|
+
| `filters` | Hash (`{ "col" => v }` or `{ "col" => [a, b] }`) / Array of hashes / pairs -> exact or `IN` conditions (`"col" => "null"` → `IS NULL`) |
|
|
134
134
|
| `range_data` | `{ "created_at" => ["2024-01-01", "2024-12-31"] }` — date or numeric ranges |
|
|
135
135
|
| `orders` | `[{ "pessoa.nome" => "desc" }]` — plain or association-scoped columns |
|
|
136
136
|
|
|
137
|
+
### Array columns (PostgreSQL)
|
|
138
|
+
|
|
139
|
+
`filters` also work on array columns (`uuid[]`, `text[]`, `string[]`...) — own, aliased or association-scoped — with the same payload:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
Person.datatable({ filters: { tag_ids: id } }) # contains id (tag_ids @> ARRAY[id])
|
|
143
|
+
Person.datatable({ filters: [{ tag_ids: a }, { tag_ids: b }] }) # contains a OR b (tag_ids && ARRAY[a, b])
|
|
144
|
+
Person.datatable({ filters: { tag_ids: "null" } }) # empty array
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Several values match **any** of them by default (like `IN`). To require **all** of them:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class Person < ApplicationRecord
|
|
151
|
+
huginn_array_match :tag_ids, :all
|
|
152
|
+
# or, with an alias:
|
|
153
|
+
huginn_attributes tags: { column: "people.tag_ids", array: :all }
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Values are cast by the element type (an invalid UUID is dropped, never sent to the database) and the array literal is built from quoted values only. Array columns are left out of the default `search` columns.
|
|
158
|
+
|
|
137
159
|
### Scoped ordering / filtering
|
|
138
160
|
|
|
139
161
|
Any `column` **or** `association.column` reference is validated and mapped to its real reflected table:
|
|
@@ -202,6 +224,14 @@ Person.search("globex") # matches company.name via a
|
|
|
202
224
|
Person.search("kayky", distinct: false) # disable the implicit DISTINCT
|
|
203
225
|
```
|
|
204
226
|
|
|
227
|
+
`searchable_columns columns: []` is honored: an empty array means "search only the declared associations" — it no longer falls back to the model's own `:string`/`:text` columns:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
class Person < ApplicationRecord
|
|
231
|
+
searchable_columns columns: [], company: [:name, :cnpj] # only company.name/cnpj are searched
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
205
235
|
`Huginn::Datatable` reuses `Huginn::Searchable.search` automatically when the model responds to `search`.
|
|
206
236
|
|
|
207
237
|
## Trigram indexes (performance)
|
|
@@ -244,6 +274,51 @@ Notes:
|
|
|
244
274
|
- Only helpful for search terms of **3 or more characters** — trigrams need that many to match. Shorter terms always scan.
|
|
245
275
|
- To see it working, run a `Person.search("term").explain` with the wrapper configured and a `gin_trgm_ops` index present.
|
|
246
276
|
|
|
277
|
+
## Full-text indexes (performance)
|
|
278
|
+
|
|
279
|
+
Under `:full_text`, each searchable column produces a lexeme predicate backed by a GIN tsvector index:
|
|
280
|
+
|
|
281
|
+
```sql
|
|
282
|
+
public.f_tsvector(col) @@ plainto_tsquery('portuguese'::regconfig, 'term')
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Because `to_tsvector()` is `STABLE` (not `IMMUTABLE`), it cannot be indexed directly — you need an `IMMUTABLE` wrapper that pins the dictionary plus an expression index on it:
|
|
286
|
+
|
|
287
|
+
```sql
|
|
288
|
+
CREATE OR REPLACE FUNCTION public.f_tsvector(text)
|
|
289
|
+
RETURNS tsvector AS $$
|
|
290
|
+
SELECT to_tsvector('portuguese'::regconfig, $1);
|
|
291
|
+
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
|
|
292
|
+
|
|
293
|
+
CREATE INDEX index_people_name_fts ON people USING gin (public.f_tsvector(name));
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Then point the gem at the wrapper and dictionary:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
Huginn.configure do |c|
|
|
300
|
+
c.search_strategy = :full_text
|
|
301
|
+
c.fts_dictionary = "portuguese"
|
|
302
|
+
c.fts_function = "public.f_tsvector"
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Or scaffold the migration for every Searchable model (wrapper + GIN indexes included), picking a dictionary via `--dictionary`:
|
|
307
|
+
|
|
308
|
+
```sh
|
|
309
|
+
rails g huginn:fts_indexes # all Searchable models
|
|
310
|
+
rails g huginn:fts_indexes Person Product # specific models
|
|
311
|
+
rails g huginn:fts_indexes --dictionary english # pin a different dictionary
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The index is **optional** — without the wrapper/index the search degrades gracefully to the unaccent+ILIKE fallback and stays correct (via sequential scan).
|
|
315
|
+
|
|
316
|
+
Notes:
|
|
317
|
+
|
|
318
|
+
- Requires the full text search support bundled with PostgreSQL (no extra extension).
|
|
319
|
+
- `:full_text` has no typo tolerance — combine it with `:pg_trgm` (`search_strategy: [:pg_trgm, :full_text]`) and generate **both** index sets (`huginn:trigram_indexes` + `huginn:fts_indexes`).
|
|
320
|
+
- To see it working, run a `Person.search("term").explain` with the wrapper configured and a GIN tsvector index present.
|
|
321
|
+
|
|
247
322
|
## Query efficiency
|
|
248
323
|
|
|
249
324
|
```
|
|
@@ -259,7 +334,7 @@ For a `Plano` datatable with deep `includes:`, this is exactly **2 extra queries
|
|
|
259
334
|
|
|
260
335
|
```
|
|
261
336
|
lib/huginn.rb entry, Huginn.configure, Huginn.instrument
|
|
262
|
-
lib/huginn/configuration.rb search_strategy, unaccent_function, pagy_*
|
|
337
|
+
lib/huginn/configuration.rb search_strategy, unaccent_function, fts_*, pagy_*
|
|
263
338
|
lib/huginn/railtie.rb auto-includes concerns into ActiveRecord
|
|
264
339
|
lib/huginn/datatable.rb Huginn::Datatable (aggregator)
|
|
265
340
|
lib/huginn/datatable/datatable.rb the datatable Concern
|
|
@@ -271,8 +346,8 @@ lib/huginn/datatable/paginator.rb lean count, pagination, isolated preload
|
|
|
271
346
|
lib/huginn/searchable.rb Huginn::Searchable (aggregator)
|
|
272
347
|
lib/huginn/searchable/searchable.rb the search Concern + DSL
|
|
273
348
|
lib/huginn/searchable/query.rb tolerant search builder (subqueries + OR)
|
|
274
|
-
lib/huginn/searchable/fuzzy.rb pg_trgm / unaccent / simple predicates
|
|
275
|
-
lib/generators/... `huginn:trigram_indexes` (GIN index migrations)
|
|
349
|
+
lib/huginn/searchable/fuzzy.rb pg_trgm / full_text / unaccent / simple predicates
|
|
350
|
+
lib/generators/... `huginn:trigram_indexes` + `huginn:fts_indexes` (GIN index migrations)
|
|
276
351
|
```
|
|
277
352
|
|
|
278
353
|
## Instrumentation
|
data/README.pt-BR.md
CHANGED
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
<a href="./README.md">🇺🇸 English</a> · 🇧🇷 Português
|
|
14
14
|
</p>
|
|
15
15
|
|
|
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
|
|
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 tolerante a erros para PostgreSQL (similaridade `pg_trgm` via operador `%` com `unaccent` e fallback `ILIKE`, além da estratégia `:full_text` sobre lexemas) que tolera erros de digitação e acentuação — e que pode usar índices GIN quando presentes.
|
|
17
17
|
|
|
18
18
|
## Destaques
|
|
19
19
|
|
|
20
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**.
|
|
21
21
|
- **Counts enxutos** — a relation base nunca faz join (`COUNT(*)` sobre uma relation restrita); as buscas de associação acontecem via subqueries no pk.
|
|
22
22
|
- **Ordenação/filtro seguros contra SQL injection** — toda referência de coluna é resolvida via reflexão do Arel, nunca interpolada como string.
|
|
23
|
-
- **Busca tolerante
|
|
23
|
+
- **Busca tolerante** — similaridade `pg_trgm` (operador `%`) OU `unaccent+ILIKE` (tolerante a typos/acentos) e `:full_text` sobre lexemas (ciente de morfologia); as estratégias são selecionáveis via `search_strategy` ou combinadas com OR, respaldadas por índices GIN quando presentes.
|
|
24
24
|
- **Convenções do Rails** — funciona com `ActionController::Parameters`, Railtie inclui ambos os concerns automaticamente (desligável), zero boilerplate.
|
|
25
25
|
|
|
26
26
|
## Desenvolvimento
|
|
@@ -58,7 +58,7 @@ Os `gemfiles/*.gemfile` são gerados pelo Appraisal (commitados); os arquivos `.
|
|
|
58
58
|
## Instalação
|
|
59
59
|
|
|
60
60
|
```ruby
|
|
61
|
-
gem "huginn"
|
|
61
|
+
gem "huginn_datatable", require: "huginn"
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
## Configuração
|
|
@@ -120,7 +120,7 @@ result = Plano.datatable(
|
|
|
120
120
|
includes: [{ operadora: { pessoa: [:endereco, :contatos] } }] # preload somente na página
|
|
121
121
|
)
|
|
122
122
|
|
|
123
|
-
result[:total_count] # Integer (count enxuto COUNT
|
|
123
|
+
result[:total_count] # Integer (count enxuto COUNT(*))
|
|
124
124
|
result[:data] # ActiveRecord::Relation (paginada + preloaded)
|
|
125
125
|
```
|
|
126
126
|
|
|
@@ -130,10 +130,32 @@ Parâmetros suportados:
|
|
|
130
130
|
|---|---|
|
|
131
131
|
| `page`, `per_page` | Paginação (limitada a `pagy_max_items`) |
|
|
132
132
|
| `search` | Delega para `Huginn::Searchable.search` |
|
|
133
|
-
| `filters` | Hash / Array de hashes / pares -> condições exatas ou `IN` (`"col" => "null"` → `IS NULL`) |
|
|
133
|
+
| `filters` | Hash (`{ "col" => v }` ou `{ "col" => [a, b] }`) / Array de hashes / pares -> condições exatas ou `IN` (`"col" => "null"` → `IS NULL`) |
|
|
134
134
|
| `range_data` | `{ "created_at" => ["2024-01-01", "2024-12-31"] }` — ranges de datas ou numéricos |
|
|
135
135
|
| `orders` | `[{ "pessoa.nome" => "desc" }]` — colunas simples ou de associação |
|
|
136
136
|
|
|
137
|
+
### Colunas array (PostgreSQL)
|
|
138
|
+
|
|
139
|
+
Os `filters` também funcionam em colunas array (`uuid[]`, `text[]`, `string[]`...) — próprias, com alias ou de associação — com o mesmo payload:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
Person.datatable({ filters: { tag_ids: id } }) # contém id (tag_ids @> ARRAY[id])
|
|
143
|
+
Person.datatable({ filters: [{ tag_ids: a }, { tag_ids: b }] }) # contém a OU b (tag_ids && ARRAY[a, b])
|
|
144
|
+
Person.datatable({ filters: { tag_ids: "null" } }) # array vazio
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Vários valores casam com **qualquer** um por padrão (como o `IN`). Para exigir **todos**:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class Person < ApplicationRecord
|
|
151
|
+
huginn_array_match :tag_ids, :all
|
|
152
|
+
# ou, com alias:
|
|
153
|
+
huginn_attributes tags: { column: "people.tag_ids", array: :all }
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Os valores são convertidos pelo tipo do elemento (um UUID inválido é descartado, nunca vai ao banco) e o literal do array é montado só com valores escapados. Colunas array ficam fora das colunas padrão do `search`.
|
|
158
|
+
|
|
137
159
|
### Ordenação/filtro com associação
|
|
138
160
|
|
|
139
161
|
Qualquer referência `column` **ou** `associacao.column` é validada e mapeada para a tabela refletida real:
|
|
@@ -202,6 +224,14 @@ Person.search("globex") # encontra company.name via s
|
|
|
202
224
|
Person.search("kayky", distinct: false) # desativa o DISTINCT implícito
|
|
203
225
|
```
|
|
204
226
|
|
|
227
|
+
`searchable_columns columns: []` é respeitado: um array vazio significa "pesquisar somente as associações declaradas" — não cai mais para as colunas `:string`/`:text` do próprio modelo:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
class Person < ApplicationRecord
|
|
231
|
+
searchable_columns columns: [], company: [:name, :cnpj] # só company.name/cnpj são pesquisados
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
205
235
|
`Huginn::Datatable` reutiliza `Huginn::Searchable.search` automaticamente quando o modelo responde a `search`.
|
|
206
236
|
|
|
207
237
|
## Índices trigram (performance)
|
|
@@ -244,6 +274,51 @@ Observações:
|
|
|
244
274
|
- Só ajuda em termos de busca de **3 caracteres ou mais** — trigramas precisam disso para casar. Termos menores sempre fazem scan.
|
|
245
275
|
- Para conferir o uso, rode `Person.search("termo").explain` com o wrapper configurado e um índice `gin_trgm_ops` presente.
|
|
246
276
|
|
|
277
|
+
## Índices full-text (performance)
|
|
278
|
+
|
|
279
|
+
Sob `:full_text`, cada coluna pesquisável gera um predicado por lexema respaldado por um índice GIN tsvector:
|
|
280
|
+
|
|
281
|
+
```sql
|
|
282
|
+
public.f_tsvector(col) @@ plainto_tsquery('portuguese'::regconfig, 'termo')
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Como o `to_tsvector()` é `STABLE` (não `IMMUTABLE`), ele não pode ser indexado diretamente — você precisa de um wrapper `IMMUTABLE` que fixe o dicionário além do índice de expressão:
|
|
286
|
+
|
|
287
|
+
```sql
|
|
288
|
+
CREATE OR REPLACE FUNCTION public.f_tsvector(text)
|
|
289
|
+
RETURNS tsvector AS $$
|
|
290
|
+
SELECT to_tsvector('portuguese'::regconfig, $1);
|
|
291
|
+
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
|
|
292
|
+
|
|
293
|
+
CREATE INDEX index_people_name_fts ON people USING gin (public.f_tsvector(name));
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
E aponte a gem para o wrapper e o dicionário:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
Huginn.configure do |c|
|
|
300
|
+
c.search_strategy = :full_text
|
|
301
|
+
c.fts_dictionary = "portuguese"
|
|
302
|
+
c.fts_function = "public.f_tsvector"
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Ou gere a migration para todos os modelos Searchable (wrapper + índices GIN inclusos), escolhendo um dicionário com `--dictionary`:
|
|
307
|
+
|
|
308
|
+
```sh
|
|
309
|
+
rails g huginn:fts_indexes # todos os modelos Searchable
|
|
310
|
+
rails g huginn:fts_indexes Person Product # modelos específicos
|
|
311
|
+
rails g huginn:fts_indexes --dictionary english # fixar outro dicionário
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
O índice é **opcional** — sem o wrapper/índice a busca degrada graciosamente para o fallback unaccent+ILIKE e permanece correta (via sequential scan).
|
|
315
|
+
|
|
316
|
+
Observações:
|
|
317
|
+
|
|
318
|
+
- Requer o suporte a full text search embutido no PostgreSQL (sem extensão extra).
|
|
319
|
+
- `:full_text` não tem tolerância a typos — combine com `:pg_trgm` (`search_strategy: [:pg_trgm, :full_text]`) e gere **os dois** conjuntos de índices (`huginn:trigram_indexes` + `huginn:fts_indexes`).
|
|
320
|
+
- Para conferir o uso, rode `Person.search("termo").explain` com o wrapper configurado e um índice GIN tsvector presente.
|
|
321
|
+
|
|
247
322
|
## Eficiência da query
|
|
248
323
|
|
|
249
324
|
```
|
|
@@ -259,7 +334,7 @@ Para um datatable de `Plano` com `includes:` profundos, isso são exatamente **2
|
|
|
259
334
|
|
|
260
335
|
```
|
|
261
336
|
lib/huginn.rb entry, Huginn.configure, Huginn.instrument
|
|
262
|
-
lib/huginn/configuration.rb search_strategy, unaccent_function, pagy_*
|
|
337
|
+
lib/huginn/configuration.rb search_strategy, unaccent_function, fts_*, pagy_*
|
|
263
338
|
lib/huginn/railtie.rb auto-inclui os concerns no ActiveRecord
|
|
264
339
|
lib/huginn/datatable.rb Huginn::Datatable (agregador)
|
|
265
340
|
lib/huginn/datatable/datatable.rb o Concern do datatable
|
|
@@ -271,8 +346,8 @@ lib/huginn/datatable/paginator.rb count enxuto, paginação, preload isolado
|
|
|
271
346
|
lib/huginn/searchable.rb Huginn::Searchable (agregador)
|
|
272
347
|
lib/huginn/searchable/searchable.rb o Concern do search + DSL
|
|
273
348
|
lib/huginn/searchable/query.rb construtor de busca tolerante (subqueries + OR)
|
|
274
|
-
lib/huginn/searchable/fuzzy.rb predicados pg_trgm / unaccent / simple
|
|
275
|
-
lib/generators/...
|
|
349
|
+
lib/huginn/searchable/fuzzy.rb predicados pg_trgm / full_text / unaccent / simple
|
|
350
|
+
lib/generators/... geradores `huginn:trigram_indexes` + `huginn:fts_indexes` (migrations de índices GIN)
|
|
276
351
|
```
|
|
277
352
|
|
|
278
353
|
## Instrumentação
|
|
@@ -7,6 +7,7 @@ module Huginn
|
|
|
7
7
|
included do
|
|
8
8
|
class_attribute :huginn_attribute_mapping, default: nil
|
|
9
9
|
class_attribute :huginn_strict_mapping, default: false
|
|
10
|
+
class_attribute :huginn_array_modes, default: {}
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
class_methods do
|
|
@@ -60,15 +61,49 @@ module Huginn
|
|
|
60
61
|
# are rejected — API callers can only filter/order by the aliases.
|
|
61
62
|
# Without a mapping the model falls back to plain/reflected columns.
|
|
62
63
|
# Accepts a positional Hash or keyword arguments.
|
|
64
|
+
#
|
|
65
|
+
# An alias over a PostgreSQL array column may set how several filter
|
|
66
|
+
# values match (see `huginn_array_match`):
|
|
67
|
+
#
|
|
68
|
+
# huginn_attributes tags: { column: "people.tags", array: :all }
|
|
63
69
|
def huginn_attributes(mapping = nil, strict: true, **aliases)
|
|
64
70
|
merged = (mapping || {}).merge(aliases)
|
|
65
|
-
|
|
71
|
+
modes = {}
|
|
72
|
+
self.huginn_attribute_mapping = merged.to_h do |key, value|
|
|
73
|
+
if value.is_a?(Hash)
|
|
74
|
+
options = value.transform_keys(&:to_sym)
|
|
75
|
+
modes[key.to_s] = normalized_array_mode(options[:array]) if options.key?(:array)
|
|
76
|
+
[key.to_s, options.fetch(:column).to_s]
|
|
77
|
+
else
|
|
78
|
+
[key.to_s, value.to_s]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
self.huginn_array_modes = huginn_array_modes.merge(modes)
|
|
66
82
|
self.huginn_strict_mapping = strict
|
|
67
83
|
huginn_attribute_mapping
|
|
68
84
|
end
|
|
69
85
|
|
|
86
|
+
# How several values filter a PostgreSQL array column:
|
|
87
|
+
#
|
|
88
|
+
# :any (default) row contains at least one of the values (`&&`),
|
|
89
|
+
# the same semantics as `IN` on a plain column
|
|
90
|
+
# :all row contains every value (`@>`)
|
|
91
|
+
#
|
|
92
|
+
# huginn_array_match :tag_ids, :all
|
|
93
|
+
def huginn_array_match(*fields, mode)
|
|
94
|
+
mode = normalized_array_mode(mode)
|
|
95
|
+
self.huginn_array_modes = huginn_array_modes.merge(fields.flatten.to_h { |field| [field.to_s, mode] })
|
|
96
|
+
end
|
|
97
|
+
|
|
70
98
|
private
|
|
71
99
|
|
|
100
|
+
def normalized_array_mode(mode)
|
|
101
|
+
mode = mode.to_s.to_sym
|
|
102
|
+
raise ArgumentError, "array mode must be :any or :all, got #{mode.inspect}" unless %i[any all].include?(mode)
|
|
103
|
+
|
|
104
|
+
mode
|
|
105
|
+
end
|
|
106
|
+
|
|
72
107
|
def apply_datatable_search(relation, value)
|
|
73
108
|
return relation if value.blank? || !respond_to?(:search)
|
|
74
109
|
|
|
@@ -102,6 +137,7 @@ module Huginn
|
|
|
102
137
|
def apply_plain_filter(relation, validator, values)
|
|
103
138
|
attr = validator.arel_attribute
|
|
104
139
|
return relation if attr.nil?
|
|
140
|
+
return relation.where(array_predicate(attr, validator, values)) if validator.array?
|
|
105
141
|
|
|
106
142
|
if values.include?("null")
|
|
107
143
|
apply_nullable_predicate(relation, attr, values)
|
|
@@ -119,7 +155,9 @@ module Huginn
|
|
|
119
155
|
return relation unless subquery
|
|
120
156
|
|
|
121
157
|
attr = validator.arel_attribute
|
|
122
|
-
target = if
|
|
158
|
+
target = if validator.array?
|
|
159
|
+
subquery.where(array_predicate(attr, validator, values))
|
|
160
|
+
elsif values.include?("null")
|
|
123
161
|
apply_nullable_predicate(subquery, attr, values)
|
|
124
162
|
elsif values.size == 1
|
|
125
163
|
subquery.where(attr.eq(values.first))
|
|
@@ -207,6 +245,35 @@ module Huginn
|
|
|
207
245
|
nil
|
|
208
246
|
end
|
|
209
247
|
|
|
248
|
+
# PostgreSQL array columns: one value means "contains it" (`@>`); several
|
|
249
|
+
# values follow the field's array mode — :any (`&&`, like IN) or :all
|
|
250
|
+
# (`@>`). "null" matches an empty (or NULL) array. Values are cast by the
|
|
251
|
+
# element type, so invalid ones are dropped before reaching SQL, and the
|
|
252
|
+
# literal is built from quoted values only.
|
|
253
|
+
def array_predicate(attr, validator, values)
|
|
254
|
+
wants_empty = values.include?("null")
|
|
255
|
+
cast = values.reject { |value| value == "null" }.filter_map { |value| validator.cast_array_element(value) }.uniq
|
|
256
|
+
predicates = []
|
|
257
|
+
predicates << empty_array_predicate(attr) if wants_empty
|
|
258
|
+
unless cast.empty?
|
|
259
|
+
operator = cast.size == 1 || validator.array_mode == :all ? "@>" : "&&"
|
|
260
|
+
predicates << Arel::Nodes::InfixOperation.new(operator, attr, array_literal(cast, validator.array_element_sql_type))
|
|
261
|
+
end
|
|
262
|
+
return Arel::Nodes::SqlLiteral.new("1=0") if predicates.empty?
|
|
263
|
+
|
|
264
|
+
predicates.reduce { |left, right| Arel::Nodes::Grouping.new(left.or(right)) }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def array_literal(values, sql_type)
|
|
268
|
+
quoted = values.map { |value| connection.quote(value) }.join(", ")
|
|
269
|
+
Arel::Nodes::SqlLiteral.new("ARRAY[#{quoted}]::#{sql_type}[]")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def empty_array_predicate(attr)
|
|
273
|
+
cardinality = Arel::Nodes::NamedFunction.new("cardinality", [attr])
|
|
274
|
+
Arel::Nodes::NamedFunction.new("COALESCE", [cardinality, Arel::Nodes.build_quoted(0)]).eq(0)
|
|
275
|
+
end
|
|
276
|
+
|
|
210
277
|
def apply_nullable_predicate(relation, attr, values)
|
|
211
278
|
non_null = values.reject { |value| value == "null" }
|
|
212
279
|
predicate = if non_null.empty?
|
|
@@ -6,8 +6,9 @@ module Huginn
|
|
|
6
6
|
# (ActionController::Parameters, Hash, Array of hashes, [key, value]
|
|
7
7
|
# pairs, ...) into a single Hash of { column => Array(values) }.
|
|
8
8
|
#
|
|
9
|
-
# Values are accumulated in arrays so that repeated keys
|
|
10
|
-
# IN condition when applied as a
|
|
9
|
+
# Values are accumulated in arrays so that repeated keys — or a key with
|
|
10
|
+
# an Array of values — produce an IN condition when applied as a
|
|
11
|
+
# datatable filter. Invalid payload
|
|
11
12
|
# shapes are simply ignored, and keys are normalized to strings.
|
|
12
13
|
class FilterNormalizer
|
|
13
14
|
def self.call(raw)
|
|
@@ -20,7 +21,9 @@ module Huginn
|
|
|
20
21
|
|
|
21
22
|
def call
|
|
22
23
|
each_pair(@raw).each_with_object({}) do |(key, value), acc|
|
|
23
|
-
|
|
24
|
+
# { "col" => [a, b] } (e.g. filters[col][]=a&filters[col][]=b) is the
|
|
25
|
+
# same as repeating the key: several values, IN / array match.
|
|
26
|
+
(acc[key.to_s] ||= []).concat(value.is_a?(Array) ? value : [value])
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
@@ -24,6 +24,7 @@ module Huginn
|
|
|
24
24
|
|
|
25
25
|
def initialize(model, field)
|
|
26
26
|
@model = model
|
|
27
|
+
@public_name = field.to_s
|
|
27
28
|
@strict_denied = false
|
|
28
29
|
@field = resolve_alias(field)
|
|
29
30
|
@field = @field.split(".").drop(1).join(".") if own_table_prefix?(@field)
|
|
@@ -74,6 +75,35 @@ module Huginn
|
|
|
74
75
|
klass.columns_hash[column].type
|
|
75
76
|
end
|
|
76
77
|
|
|
78
|
+
# True for PostgreSQL array columns (e.g. `uuid[]`, `text[]`).
|
|
79
|
+
def array?
|
|
80
|
+
definition = column_definition
|
|
81
|
+
!!(definition.respond_to?(:array?) ? definition.array? : definition&.try(:array))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# SQL type of the array elements ("uuid", "character varying"...),
|
|
85
|
+
# used to cast the filter literal.
|
|
86
|
+
def array_element_sql_type
|
|
87
|
+
column_definition.sql_type.delete_suffix("[]")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Casts a filter value with the array element type; nil when invalid.
|
|
91
|
+
def cast_array_element(value)
|
|
92
|
+
type = target_klass.type_for_attribute(column)
|
|
93
|
+
element = type.respond_to?(:subtype) ? type.subtype : type
|
|
94
|
+
element.serialize(element.cast(value))
|
|
95
|
+
rescue ArgumentError, TypeError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# :any or :all, declared with huginn_attributes(array:) or
|
|
100
|
+
# huginn_array_match (by public alias or by column name).
|
|
101
|
+
def array_mode
|
|
102
|
+
modes = target_klass.respond_to?(:huginn_array_modes) ? target_klass.huginn_array_modes : {}
|
|
103
|
+
own = model.respond_to?(:huginn_array_modes) ? model.huginn_array_modes : {}
|
|
104
|
+
own[@public_name] || own[@field] || modes[column] || :any
|
|
105
|
+
end
|
|
106
|
+
|
|
77
107
|
# The AssociationPath for scoped fields (builds filter/order
|
|
78
108
|
# subqueries), or nil for plain columns.
|
|
79
109
|
def association_path
|
|
@@ -111,6 +141,16 @@ module Huginn
|
|
|
111
141
|
segments.size >= 2 && segments.first == model.table_name
|
|
112
142
|
end
|
|
113
143
|
|
|
144
|
+
def target_klass
|
|
145
|
+
plain? ? model : association_path.target_klass
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def column_definition
|
|
149
|
+
return nil unless valid?
|
|
150
|
+
|
|
151
|
+
target_klass.columns_hash[column]
|
|
152
|
+
end
|
|
153
|
+
|
|
114
154
|
def strict_denied?
|
|
115
155
|
@strict_denied
|
|
116
156
|
end
|
|
@@ -51,7 +51,8 @@ module Huginn
|
|
|
51
51
|
|
|
52
52
|
def default_searchable_columns
|
|
53
53
|
columns_hash.each_with_object([]) do |(name, column), acc|
|
|
54
|
-
|
|
54
|
+
# Array columns (string[]/text[]) are not searchable text: unaccent/ILIKE need a scalar.
|
|
55
|
+
acc << name if %i[string text].include?(column.type) && !column.try(:array)
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
58
|
end
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kayky Marcelo
|
|
@@ -181,7 +181,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
181
181
|
- !ruby/object:Gem::Version
|
|
182
182
|
version: '0'
|
|
183
183
|
requirements: []
|
|
184
|
-
rubygems_version: 4.0.
|
|
184
|
+
rubygems_version: 4.0.16
|
|
185
185
|
specification_version: 4
|
|
186
186
|
summary: Performant and elegant ActiveRecord datatables/data-grids with tolerant search
|
|
187
187
|
test_files: []
|