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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e68c742da97818c79b41681099972a3e4c3cc4613b8500831226abe09b2a87ed
4
- data.tar.gz: 855168e5b6bbc9df1bceb381484bf20f550fcf57c0d0a35d6aba15c50b20a775
3
+ metadata.gz: 82a13098551b25e1b656beb853926f7dcbc964c420a64dde0e22182f735a02bb
4
+ data.tar.gz: abdcf5e8f489ad2b6bff0870bde97d443247f6d31b0efc93274a47da81837916
5
5
  SHA512:
6
- metadata.gz: f345b93074fec47d95589bdc06078b5c2585a79e0fd2a7c02c519d3ca35baa286a885f94278a64048004891a139f4c97a293848cf094523cbcff85ccc2276ce5
7
- data.tar.gz: d2bfa72d92cc096dba420c20e2a9bc0b26f466e0b981b5d9e653d6f5f2dc33c105c5fd12a1c3e3269691ecafd0704efabaec80943f166b610798066420c3fd81
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 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.
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
- - **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.
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 DISTINCT pk)
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 *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.
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 a acentos/typos** — similaridade `pg_trgm` (operador `%`) OU `unaccent+ILIKE`, com cadeia de fallback configurável; usa índice GIN `gin_trgm_ops` quando existente.
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 DISTINCT pk)
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/... gerador `huginn:trigram_indexes` (migrations de índices GIN)
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
- self.huginn_attribute_mapping = merged.map { |key, value| [key.to_s, value.to_s] }.to_h
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 values.include?("null")
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 produce an
10
- # IN condition when applied as a datatable filter. Invalid payload
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
- (acc[key.to_s] ||= []) << value
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
- acc << name if %i[string text].include?(column.type)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Huginn
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.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.6
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: []