huginn_datatable 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 05de880382d0477bad2668f9f64ae7cc9d3bbbc78f71c6e359dfba0ccf9cc43b
4
+ data.tar.gz: 7d8053d071e2bc52b09463af4a8b6ed7c5b024afd5f9fa5b3f6e39f30cbb5f04
5
+ SHA512:
6
+ metadata.gz: 20676dc384afff5cb64a1b6289f13207fdc1cde8bc549659ce876408eb435efb9668d2aff5064968bf7cc3d21e71c8e801603ba8737d5c0cc034c9f368788b9e
7
+ data.tar.gz: 36b11ac27abd80641faa22db4e44feb865881d27f5dd3d8c2b6cb9df1b8dcef10a24b22271021c1e12603481da18a7e17eef4201983586c0c2d2851cf9a86bd6
data/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ ## Changelog
2
+
3
+ ### Unreleased
4
+
5
+ - Runtime dependency on Pagy declared in the gemspec (`pagy >= 6`) — consumers install it automatically (datatable pagination is not test-only anymore).
6
+ - Supported Rails floor bumped to **7.1** (`>= 7.1, < 9`); Ruby `>= 3.0` without an upper bound (Rails 8 + Ruby 4 supported).
7
+ - 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).
8
+ - `allowed_paths:` allowlist: associações que filtros/orders/range podem usar.
9
+ - **Deny-all por padrão** — sem `allowed_paths:`, apenas colunas da própria tabela.
10
+ - Accepts Rails shapes (Symbol/String/Hash/Array); table names map to the reflection.
11
+ - Ordenação por associação via subquery escalar correlacionada determinística (`ORDER BY (SELECT … LIMIT 1)`); filtros/range via `pk IN (SELECT DISTINCT pk …)`.
12
+ - `allowed_paths` also gates `huginn_attributes` association aliases.
13
+ - `huginn_attributes` mapping: public API aliases for datatable fields, hiding the database schema.
14
+ - Strict by default — fields outside the mapping are silently rejected.
15
+ - Alias targets accept association names (`company.name`) or table names (`companies.name`).
16
+ - `huginn_attributes({ ... }, strict: false)` for a translation-only mapping; plain/reflected columns remain the fallback when no mapping is set.
17
+
18
+ ### 0.1.0 — 2026-08-05
19
+
20
+ - Initial release of the `Huginn` gem.
21
+ - `Huginn::Datatable` concern: two-phase (filter → count/paginate/preload) datatable execution.
22
+ - `left_joins` for filtering/ordering; values are never string-interpolated.
23
+ - Lean `COUNT(DISTINCT pk)` via subquery.
24
+ - `preload` applied *only* to the final paginated subset.
25
+ - DISTINCT applied only when a joined association multiplies rows.
26
+ - `Huginn::FilterNormalizer`: fluid, tolerant normalization of filter payloads (Parameters / Hash / Array / pairs).
27
+ - `Huginn::Datatable::Validator`: validation + Arel resolution of plain and association-scoped columns.
28
+ - `Huginn::Datatable::Paginator`: enxuto count and pagination (Pagy), `preload` isolation.
29
+ - `Huginn::Searchable`: tolerant full-text search over PostgreSQL.
30
+ - `:pg_trgm` (similarity OR unaccent+ILIKE), `:unaccent`, `:simple` strategies, runtime degrade when extensions missing.
31
+ - Default: all `:string`/`:text` columns; overridable via `searchable_columns`.
32
+ - Association-scoped columns reached through `left_joins`.
33
+ - Railtie auto-includes both concerns into `ActiveRecord::Base` (configurable), mirrors the `muninn` gem layout (version, configuration, Appraisals, CI matrix, `spec/dummy`).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kayky Marcelo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,219 @@
1
+ <h1 align="center">Huginn</h1>
2
+
3
+ <p align="center">
4
+ <em>Performant, elegant ActiveRecord datatables and tolerant search.</em><br>
5
+ Huginn is the raven of Odin that represents thought and remembrance — the mate of Muninn.
6
+ </p>
7
+
8
+ <p align="center">
9
+ 🇺🇸 English · <a href="./README.pt-BR.md">🇧🇷 Português</a>
10
+ </p>
11
+
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.
13
+
14
+ ## Highlights
15
+
16
+ - **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(DISTINCT pk)` through a stripped relation; no JOIN materialization.
18
+ - **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.
20
+ - **Rails conventions** — works with `ActionController::Parameters`, Railtie auto-includes both concerns (toggleable), zero boilerplate.
21
+
22
+ ## Development
23
+
24
+ The root `Gemfile` keeps only the tooling (rspec, appraisal, pry) — each supported Rails series lives in its own Appraisal. Use these to run the suite:
25
+
26
+ ```
27
+ bundle install
28
+ bundle exec appraisal install # generates gemfiles/*.gemfile + resolves
29
+ bundle exec appraisal rspec # runs the full matrix (Rails 7.1/7.2/8.0)
30
+ bundle exec appraisal rails-8.0 rspec # or a single series
31
+ bundle exec rake matrix # alias for the full matrix
32
+ ```
33
+
34
+ A bare `bundle exec rspec` needs an active environment: `export BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile`.
35
+
36
+ ## Supported versions
37
+
38
+ | Component | Range |
39
+ |---|---|
40
+ | Ruby | `>= 3.0` (no upper bound — Rails 8 + Ruby 4 supported) |
41
+ | Rails | `>= 7.1, < 9` |
42
+ | Pagy | `>= 6` (runtime dependency, installed automatically) |
43
+ | PostgreSQL | pg_trgm / unaccent / ILIKE search; degrades gracefully without them |
44
+
45
+ The suite is verified against **Rails 7.1, 7.2 and 8.0** across supported Rubies via [Appraisal](https://github.com/thoughtbot/appraisal). Run the full matrix locally:
46
+
47
+ ```
48
+ bundle exec appraisal install
49
+ bundle exec appraisal rspec
50
+ ```
51
+
52
+ The `gemfiles/*.gemfile` are generated by Appraisal (committed); their `.lock` files are not — each CI cell resolves for its own Ruby/Rails pair.
53
+
54
+ ## Installation
55
+
56
+ ```ruby
57
+ gem "huginn"
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ ```ruby
63
+ # config/initializers/huginn.rb
64
+ Huginn.configure do |config|
65
+ # :pg_trgm (recommended) — trigram similarity OR unaccent+ILIKE
66
+ # :unaccent — unaccent + ILIKE only
67
+ # :simple — plain LIKE
68
+ config.search_strategy = :pg_trgm
69
+
70
+ config.fuzzy_threshold = 0.3 # similarity() cutoff used by :pg_trgm
71
+ config.pagy_items = 10 # default page size
72
+ config.pagy_max_items = 500 # hard cap for per_page
73
+ end
74
+ ```
75
+
76
+ ## Railtie (automatic include)
77
+
78
+ 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:
79
+
80
+ ```ruby
81
+ Huginn.configure { |c| c.auto_include_datatable = false; c.auto_include_searchable = false }
82
+ ```
83
+
84
+ ## Usage — datatable
85
+
86
+ ```ruby
87
+ class Plano < ApplicationRecord
88
+ # Datatable + Searchable are auto-included via Railtie
89
+ end
90
+ ```
91
+
92
+ ```ruby
93
+ result = Plano.datatable(
94
+ params,
95
+ allowed_paths: [:grupo, { operadora: [:pessoa] }], # associations filters/orders may use
96
+ includes: [{ operadora: { pessoa: [:endereco, :contatos] } }] # preloaded on the page only
97
+ )
98
+
99
+ result[:total_count] # Integer (lean COUNT DISTINCT pk)
100
+ result[:data] # ActiveRecord::Relation (paged + preloaded)
101
+ ```
102
+
103
+ Supported params:
104
+
105
+ | Key | Behavior |
106
+ |---|---|
107
+ | `page`, `per_page` | Pagination (clamped to `pagy_max_items`) |
108
+ | `search` | Delegates to `Huginn::Searchable.search` |
109
+ | `filters` | Hash / Array of hashes / pairs -> exact or `IN` conditions (`"col" => "null"` → `IS NULL`) |
110
+ | `range_data` | `{ "created_at" => ["2024-01-01", "2024-12-31"] }` — date or numeric ranges |
111
+ | `orders` | `[{ "pessoa.nome" => "desc" }]` — plain or association-scoped columns |
112
+
113
+ ### Scoped ordering / filtering
114
+
115
+ Any `column` **or** `association.column` reference is validated and mapped to its real reflected table:
116
+
117
+ ```ruby
118
+ Plano.datatable({ orders: [{ "operadora.pessoa.nome" => "asc" }] }, allowed_paths: [{ operadora: :pessoa }])
119
+ ```
120
+
121
+ > Association filters/range and ordering use reflection-resolved subqueries (see the "allowlist of associations" section below). The main relation stays singular and the count is `COUNT(DISTINCT pk)`.
122
+
123
+ ## Association allowlist (`allowed_paths`)
124
+
125
+ To protect the schema and keep the query lean, the `datatable` does not materialize `left_joins` to filter/order by associations. Instead:
126
+
127
+ - **Filters/ranges** over association columns become `pk IN (SELECT DISTINCT pk …)` subqueries — the main relation is never multiplied;
128
+ - **Ordering** by an association column uses a *correlated scalar subquery* (`ORDER BY (SELECT … ORDER BY col ASC LIMIT 1)`), which is deterministic even for `has_many` (smallest value);
129
+ - **Only authorized associations** may be referenced. Pass `allowed_paths:` with the associations the caller may use in the query:
130
+
131
+ ```ruby
132
+ result = Plano.datatable(
133
+ params,
134
+ allowed_paths: [:grupo, { operadora: :pessoa }], # associations filters/orders may use
135
+ includes: [{ operadora: { pessoa: [:endereco, :contatos] } }] # preload only the page
136
+ )
137
+ ```
138
+
139
+ - **Deny-all by default**: without `allowed_paths:`, no association is authorized for filtering/ordering — only columns of the table itself.
140
+ - `allowed_paths:` accepts the same shapes Rails knows (`:symbol`, `"string"`, nested Hash, mixed Array). Table names (`"companies"`) are recognized as the matching association (`:company`).
141
+ - `includes:` stays independent of `allowed_paths:` — it only controls the **preload** of the paginated page.
142
+
143
+ ## Field aliases & schema protection
144
+
145
+ Public APIs should not expose the database schema. Declare a mapping of **public names** to real columns/tables with `huginn_attributes`:
146
+
147
+ ```ruby
148
+ class User < ApplicationRecord
149
+ # Public API name -> real column/table (association name or table name)
150
+ huginn_attributes(
151
+ name: "users.name",
152
+ email: "users.email",
153
+ created_at: "users.created_at",
154
+ company_name: "companies.name" # association column, resolved via subquery
155
+ )
156
+ end
157
+ ```
158
+
159
+ - Callers then filter/order/range only by the aliases: `{ filters: { company_name: "Acme Corp" } }`, `{ orders: [{ company_name: "asc" }] }`.
160
+ - **Strict by default**: fields outside the mapping are silently rejected (they never reach SQL and are never answered). The schema stays hidden from API consumers.
161
+ - Scoped aliases (`companies.name`) resolve through the association **only if it is authorized in `allowed_paths:`** (the same allowlist applies to aliases).
162
+ - Without `huginn_attributes`, the model **falls back** to plain/reflected columns (`name`, `company.name`).
163
+ - Without `allowed_paths:`, association-scoped aliases are **denied**; only plain columns are usable.
164
+ - `huginn_attributes({ ... }, strict: false)` keeps alias translation but also accepts raw columns.
165
+
166
+ ## Usage — search
167
+
168
+ ```ruby
169
+ # Default: searches every :string / :text column of the model.
170
+ Person.search("kayky") # typo/accent tolerant, case-insensitive
171
+
172
+ # Override which columns (including through associations) are searched:
173
+ class Person < ApplicationRecord
174
+ searchable_columns :name, company: [:name, :cnpj]
175
+ end
176
+
177
+ Person.search("globex") # matches company.name via a left_join
178
+ Person.search("kayky", distinct: false) # disable the implicit DISTINCT
179
+ ```
180
+
181
+ `Huginn::Datatable` reuses `Huginn::Searchable.search` automatically when the model responds to `search`.
182
+
183
+ ## Query efficiency
184
+
185
+ ```
186
+ phase 1 build the relation subqueries (pk IN … / ORDER BY (SELECT …)) + search + filters + order (no data in memory)
187
+ phase 2 count SELECT COUNT(DISTINCT "<pk column>") ... (subquery, pk-indexed)
188
+ paginate offset / limit
189
+ preload SELECT ... WHERE id IN (subset) (2nd lightweight query)
190
+ ```
191
+
192
+ For a `Plano` datatable with deep `includes:`, this is exactly **2 extra queries** on the small page instead of one enormous JOIN.
193
+
194
+ ## Architecture
195
+
196
+ ```
197
+ lib/huginn.rb entry, Huginn.configure, Huginn.instrument
198
+ lib/huginn/configuration.rb search_strategy, fuzzy_threshold, pagy_*
199
+ lib/huginn/railtie.rb auto-includes concerns into ActiveRecord
200
+ lib/huginn/datatable.rb Huginn::Datatable (aggregator)
201
+ lib/huginn/datatable/datatable.rb the datatable Concern
202
+ lib/huginn/datatable/validator.rb column/association validation + Arel resolution
203
+ lib/huginn/datatable/association_path.rb resolution of association chains + correlated subqueries
204
+ lib/huginn/datatable/allowed_paths.rb `allowed_paths:` allowlist expansion/authorization
205
+ lib/huginn/datatable/filter_normalizer.rb functional param normalization
206
+ lib/huginn/datatable/paginator.rb lean count, pagination, isolated preload
207
+ lib/huginn/searchable.rb Huginn::Searchable (aggregator)
208
+ lib/huginn/searchable/searchable.rb the search Concern + DSL
209
+ lib/huginn/searchable/query.rb tolerant search builder (joins + OR)
210
+ lib/huginn/searchable/fuzzy.rb pg_trgm / unaccent / simple predicates
211
+ ```
212
+
213
+ ## Instrumentation
214
+
215
+ `Huginn.instrument` wraps `ActiveSupport::Notifications` events under the `huginn` namespace (e.g. `datatable.call.huginn`). Subscribe with `ActiveSupport::Notifications.subscribe(/\.huginn/)`.
216
+
217
+ ## License
218
+
219
+ MIT
data/README.pt-BR.md ADDED
@@ -0,0 +1,219 @@
1
+ <h1 align="center">Huginn</h1>
2
+
3
+ <p align="center">
4
+ <em>Datatables ActiveRecord performantes e busca tolerante a erros.</em><br>
5
+ Huginn é o corvo de Odin que representa o pensamento e a memória — o companheiro de Muninn.
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="./README.md">🇺🇸 English</a> · 🇧🇷 Português
10
+ </p>
11
+
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.
13
+
14
+ ## Destaques
15
+
16
+ - **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(DISTINCT pk)` através de uma relation restrita, sem materialização de JOINs.
18
+ - **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.
20
+ - **Convenções do Rails** — funciona com `ActionController::Parameters`, Railtie inclui ambos os concerns automaticamente (desligável), zero boilerplate.
21
+
22
+ ## Desenvolvimento
23
+
24
+ O `Gemfile` raiz mantém apenas as ferramentas (rspec, appraisal, pry) — cada série suportada do Rails vive num Appraisal próprio. Para rodar a suíte:
25
+
26
+ ```
27
+ bundle install
28
+ bundle exec appraisal install # gera os gemfiles/*.gemfile + resolve
29
+ bundle exec appraisal rspec # roda a matrix completa (Rails 7.1/7.2/8.0)
30
+ bundle exec appraisal rails-8.0 rspec # ou uma série isolada
31
+ bundle exec rake matrix # atalho para a matrix completa
32
+ ```
33
+
34
+ Um `bundle exec rspec` isolado precisa de ambiente ativo: `export BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile`.
35
+
36
+ ## Versões suportadas
37
+
38
+ | Componente | Escopo |
39
+ |---|---|
40
+ | Ruby | `>= 3.0` (sem teto — Rails 8 + Ruby 4 suportados) |
41
+ | Rails | `>= 7.1, < 9` |
42
+ | Pagy | `>= 6` (dependência runtime, instalada automaticamente) |
43
+ | PostgreSQL | busca pg_trgm / unaccent / ILIKE; degrada sem eles na ausência |
44
+
45
+ A suíte é verificada contra **Rails 7.1, 7.2 e 8.0** em várias Rubies suportadas via [Appraisal](https://github.com/thoughtbot/appraisal). Rode a matrix completa localmente:
46
+
47
+ ```
48
+ bundle exec appraisal install
49
+ bundle exec appraisal rspec
50
+ ```
51
+
52
+ Os `gemfiles/*.gemfile` são gerados pelo Appraisal (commitados); os arquivos `.lock` deles **não** são — cada célula da CI resolve pelo seu par Ruby/Rails.
53
+
54
+ ## Instalação
55
+
56
+ ```ruby
57
+ gem "huginn"
58
+ ```
59
+
60
+ ## Configuração
61
+
62
+ ```ruby
63
+ # config/initializers/huginn.rb
64
+ Huginn.configure do |config|
65
+ # :pg_trgm (recomendado) — similaridade trigram OU unaccent+ILIKE
66
+ # :unaccent — somente unaccent + ILIKE
67
+ # :simple — LIKE simples
68
+ config.search_strategy = :pg_trgm
69
+
70
+ config.fuzzy_threshold = 0.3 # cutoff de similarity() usado por :pg_trgm
71
+ config.pagy_items = 10 # tamanho de página padrão
72
+ config.pagy_max_items = 500 # teto máximo de per_page
73
+ end
74
+ ```
75
+
76
+ ## Railtie (include automático)
77
+
78
+ 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:
79
+
80
+ ```ruby
81
+ Huginn.configure { |c| c.auto_include_datatable = false; c.auto_include_searchable = false }
82
+ ```
83
+
84
+ ## Uso — datatable
85
+
86
+ ```ruby
87
+ class Plano < ApplicationRecord
88
+ # Datatable + Searchable são incluídos automaticamente via Railtie
89
+ end
90
+ ```
91
+
92
+ ```ruby
93
+ result = Plano.datatable(
94
+ params,
95
+ allowed_paths: [:grupo, { operadora: [:pessoa] }], # associações que filtros/orders podem usar
96
+ includes: [{ operadora: { pessoa: [:endereco, :contatos] } }] # preload somente na página
97
+ )
98
+
99
+ result[:total_count] # Integer (count enxuto COUNT DISTINCT pk)
100
+ result[:data] # ActiveRecord::Relation (paginada + preloaded)
101
+ ```
102
+
103
+ Parâmetros suportados:
104
+
105
+ | Parâmetro | Comportamento |
106
+ |---|---|
107
+ | `page`, `per_page` | Paginação (limitada a `pagy_max_items`) |
108
+ | `search` | Delega para `Huginn::Searchable.search` |
109
+ | `filters` | Hash / Array de hashes / pares -> condições exatas ou `IN` (`"col" => "null"` → `IS NULL`) |
110
+ | `range_data` | `{ "created_at" => ["2024-01-01", "2024-12-31"] }` — ranges de datas ou numéricos |
111
+ | `orders` | `[{ "pessoa.nome" => "desc" }]` — colunas simples ou de associação |
112
+
113
+ ### Ordenação/filtro com associação
114
+
115
+ Qualquer referência `column` **ou** `associacao.column` é validada e mapeada para a tabela refletida real:
116
+
117
+ ```ruby
118
+ Plano.datatable({ orders: [{ "operadora.pessoa.nome" => "asc" }] }, allowed_paths: [{ operadora: :pessoa }])
119
+ ```
120
+
121
+ > Filtros/ranges e order de associação usam subqueries resolvidas por reflexão (veja a seção "Allowlist de associações" abaixo). A relation principal permanece única e o count é `COUNT(DISTINCT pk)`.
122
+
123
+ ## Allowlist de associações (`allowed_paths`)
124
+
125
+ Para proteger o schema e manter a consulta enxuta, o `datatable` não materializa `left_joins` para filtrar/ordenar por associações. Em vez disso:
126
+
127
+ - **Filtros/ranges** de colunas de associação viram subqueries `pk IN (SELECT DISTINCT pk …)` — a relation principal nunca é multiplicada;
128
+ - **Ordenação** por coluna de associação usa uma *subquery escalar correlacionada* (`ORDER BY (SELECT … ORDER BY col ASC LIMIT 1)`), determinística mesmo para `has_many` (menor valor);
129
+ - **Apenas as associações autorizadas** podem ser referenciadas. Passe `allowed_paths:` com as associações que o chamador pode usar na query:
130
+
131
+ ```ruby
132
+ result = Plano.datatable(
133
+ params,
134
+ allowed_paths: [:grupo, { operadora: :pessoa }], # associações que filtros/orders podem usar
135
+ includes: [{ operadora: { pessoa: [:endereco, :contatos] } }] # preload somente da página
136
+ )
137
+ ```
138
+
139
+ - **Deny-all por padrão**: sem `allowed_paths:`, nenhuma associação é autorizada para filtro/ordem — apenas colunas da própria tabela.
140
+ - `allowed_paths:` aceita os mesmos formatos do Rails (`:symbol`, `"string"`, Hash aninhado, Array misto). Nomes de tabela (`"companies"`) são reconhecidos como a associação correspondente (`:company`).
141
+ - `includes:` continua independente do `allowed_paths:`: ele só controla o **preload** dos dados na página paginada.
142
+
143
+ ## Aliases de campos & proteção do schema
144
+
145
+ APIs públicas não deveriam expor o schema do banco. Declare um mapeamento de **nomes públicos** para colunas/tabelas reais com `huginn_attributes`:
146
+
147
+ ```ruby
148
+ class User < ApplicationRecord
149
+ # Nome de API pública -> coluna/tabela real (nome da associação ou nome da tabela)
150
+ huginn_attributes(
151
+ name: "users.name",
152
+ email: "users.email",
153
+ created_at: "users.created_at",
154
+ company_name: "companies.name" # coluna de associação, resolvida via subquery
155
+ )
156
+ end
157
+ ```
158
+
159
+ - Os chamadores filtram/ordenam/rangeiam apenas pelos aliases: `{ filters: { company_name: "Acme Corp" } }`, `{ orders: [{ company_name: "asc" }] }`.
160
+ - **Estrito por padrão**: campos fora do mapeamento são silenciosamente rejeitados (nunca chegam ao SQL e nunca são respondidos). O schema permanece oculto para consumidores da API.
161
+ - Aliases de associação (`companies.name`) resolvem pela associação **somente se ela estiver autorizada em `allowed_paths:`** (a mesma allowlist se aplica aos aliases).
162
+ - Sem `huginn_attributes`, o modelo **cai de volta** para colunas simples/refletidas (`name`, `company.name`).
163
+ - Sem `allowed_paths:`, aliases de associação são **negados**; apenas colunas simples podem ser usadas.
164
+ - `huginn_attributes({ ... }, strict: false)` mantém a tradução de aliases, mas também aceita colunas cruas.
165
+
166
+ ## Uso — search
167
+
168
+ ```ruby
169
+ # Padrão: busca em toda coluna :string / :text do modelo.
170
+ Person.search("kayky") # tolerante a typos e acentos, sem distinção de caixa
171
+
172
+ # Sobrescreva quais colunas pesquisar (inclusive através de associações):
173
+ class Person < ApplicationRecord
174
+ searchable_columns :name, company: [:name, :cnpj]
175
+ end
176
+
177
+ Person.search("globex") # encontra company.name via left_join
178
+ Person.search("kayky", distinct: false) # desativa o DISTINCT implícito
179
+ ```
180
+
181
+ `Huginn::Datatable` reutiliza `Huginn::Searchable.search` automaticamente quando o modelo responde a `search`.
182
+
183
+ ## Eficiência da query
184
+
185
+ ```
186
+ 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(DISTINCT "<pk>") ... (subquery, indexada por pk)
188
+ paginate offset / limit
189
+ preload SELECT ... WHERE id IN (subset) (2ª query leve)
190
+ ```
191
+
192
+ Para um datatable de `Plano` com `includes:` profundos, isso são exatamente **2 queries extras** na página pequena em vez de um JOIN enorme.
193
+
194
+ ## Arquitetura
195
+
196
+ ```
197
+ lib/huginn.rb entry, Huginn.configure, Huginn.instrument
198
+ lib/huginn/configuration.rb search_strategy, fuzzy_threshold, pagy_*
199
+ lib/huginn/railtie.rb auto-inclui os concerns no ActiveRecord
200
+ lib/huginn/datatable.rb Huginn::Datatable (agregador)
201
+ lib/huginn/datatable/datatable.rb o Concern do datatable
202
+ lib/huginn/datatable/validator.rb validação de coluna/associação + resolução Arel
203
+ lib/huginn/datatable/association_path.rb resolução de cadeias de associação + subqueries
204
+ lib/huginn/datatable/allowed_paths.rb expansão/autorização da allowlist `allowed_paths:`
205
+ lib/huginn/datatable/filter_normalizer.rb normalização funcional de params
206
+ lib/huginn/datatable/paginator.rb count enxuto, paginação, preload isolado
207
+ lib/huginn/searchable.rb Huginn::Searchable (agregador)
208
+ lib/huginn/searchable/searchable.rb o Concern do search + DSL
209
+ lib/huginn/searchable/query.rb construtor de busca tolerante (joins + OR)
210
+ lib/huginn/searchable/fuzzy.rb predicados pg_trgm / unaccent / simple
211
+ ```
212
+
213
+ ## Instrumentação
214
+
215
+ `Huginn.instrument` envolve eventos de `ActiveSupport::Notifications` no namespace `huginn` (ex.: `datatable.call.huginn`). Assine com `ActiveSupport::Notifications.subscribe(/\.huginn/)`.
216
+
217
+ ## Licença
218
+
219
+ MIT
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ class Configuration
5
+ attr_accessor :fuzzy_threshold, :pagy_items, :pagy_max_items
6
+ attr_writer :search_strategy, :auto_include_datatable, :auto_include_searchable
7
+
8
+ def initialize
9
+ @fuzzy_threshold = 0.3
10
+ @pagy_items = 10
11
+ @pagy_max_items = 500
12
+ @search_strategy = :pg_trgm
13
+ @auto_include_datatable = true
14
+ @auto_include_searchable = true
15
+ end
16
+
17
+ # :pg_trgm -> similarity(trgm) OR unaccent+ILIKE (recommended)
18
+ # :unaccent -> unaccent + ILIKE only
19
+ # :simple -> plain LIKE
20
+ def search_strategy
21
+ @search_strategy.to_sym
22
+ end
23
+
24
+ def auto_include_datatable?
25
+ @auto_include_datatable
26
+ end
27
+
28
+ def auto_include_searchable?
29
+ @auto_include_searchable
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ module Datatable
5
+ # Expands the `allowed_paths:` allowlist into its canonical association
6
+ # chains and answers whether a given chain is authorized.
7
+ #
8
+ # Accepts the same shapes Rails knows: a Symbol, a String, a Hash of
9
+ # nested associations, or an Array mixing those — e.g.
10
+ #
11
+ # allowed_paths: [:company, { company: :people }, "products"]
12
+ #
13
+ # Every path is canonicalized through the reflection so that table names
14
+ # and public aliases map to the association name the querier actually
15
+ # walks.
16
+ class AllowedPaths
17
+ def self.call(model, spec)
18
+ new(model, spec)
19
+ end
20
+
21
+ def initialize(model, spec)
22
+ @model = model
23
+ @allowed = expand(spec)
24
+ end
25
+
26
+ def allow(reference_or_spec)
27
+ self.class.call(@model, reference_or_spec)
28
+ end
29
+
30
+ # Authorizes an association chain produced by a huginn_attributes alias
31
+ # (canonicalized), even when the raw allowlist does not mention it.
32
+ def include_path(path)
33
+ @allowed << canonicalize_path(path)
34
+ end
35
+
36
+ # Canonical association-name path for a Validator/association Path.
37
+ def include?(path)
38
+ return false if path.blank?
39
+
40
+ canonical = canonicalize_path(path)
41
+ @allowed.include?(canonical)
42
+ end
43
+
44
+ def any?
45
+ @allowed.any?
46
+ end
47
+
48
+ def to_a
49
+ @allowed.dup
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :model
55
+
56
+ def expand(spec)
57
+ paths = case spec
58
+ when nil then []
59
+ when Hash then expand_hash(spec)
60
+ else Array(spec).flat_map { |node| expand_hash(node) }
61
+ end
62
+ paths.map { |path| canonicalize_path(path) }.uniq
63
+ end
64
+
65
+ def expand_hash(node, prefix = [])
66
+ case node
67
+ when Symbol, String
68
+ chain = [*prefix, node.to_s]
69
+ [*top_level_paths(chain), chain]
70
+ when Array
71
+ node.flat_map { |child| expand_hash(child, prefix) }
72
+ when Hash
73
+ node.flat_map do |assoc, child|
74
+ current = [*prefix, assoc.to_s]
75
+ [current] + expand_hash(child, current)
76
+ end
77
+ else
78
+ []
79
+ end
80
+ end
81
+
82
+ # A bare association in the allowlist also authorizes the "table_name
83
+ # of that association" reference used by scoped columns, e.g. allowing
84
+ # `:company` also allows `companies.name` columns.
85
+ def top_level_paths(chain)
86
+ first = chain.first
87
+ assoc = model.reflect_on_association(first.to_sym)
88
+ return [] unless assoc && chain.size == 1
89
+
90
+ [[assoc.klass.table_name]]
91
+ end
92
+
93
+ # Normalizes a path of names (association/table/alias) into the
94
+ # canonical list of reflection names the datatable can walk.
95
+ def canonicalize_path(names)
96
+ klass = model
97
+ paths = []
98
+
99
+ names.each_with_index do |segment, index|
100
+ reflection = klass.reflect_on_association(segment.to_sym)
101
+ reflection ||= index.zero? ? table_reflection(klass, segment) : nil
102
+
103
+ if reflection
104
+ paths << reflection.name.to_s
105
+ klass = reflection.klass
106
+ elsif index.zero? && (table_klass = klass_for_table(segment))
107
+ paths << table_klass.table_name
108
+ klass = table_klass
109
+ else
110
+ paths << segment.to_s
111
+ end
112
+ end
113
+
114
+ paths
115
+ end
116
+
117
+ def table_reflection(klass, name)
118
+ klass.reflect_on_all_associations.find { |ref| ref.klass == klass_for_table(name) }
119
+ end
120
+
121
+ def klass_for_table(name)
122
+ table = name.singularize.camelize.constantize
123
+ table < ActiveRecord::Base ? table : nil
124
+ rescue NameError
125
+ nil
126
+ end
127
+ end
128
+ end
129
+ end