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 +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE.txt +21 -0
- data/README.md +219 -0
- data/README.pt-BR.md +219 -0
- data/lib/huginn/configuration.rb +32 -0
- data/lib/huginn/datatable/allowed_paths.rb +129 -0
- data/lib/huginn/datatable/association_path.rb +170 -0
- data/lib/huginn/datatable/datatable.rb +308 -0
- data/lib/huginn/datatable/filter_normalizer.rb +53 -0
- data/lib/huginn/datatable/paginator.rb +91 -0
- data/lib/huginn/datatable/validator.rb +134 -0
- data/lib/huginn/datatable.rb +13 -0
- data/lib/huginn/railtie.rb +12 -0
- data/lib/huginn/searchable/fuzzy.rb +88 -0
- data/lib/huginn/searchable/query.rb +129 -0
- data/lib/huginn/searchable/searchable.rb +55 -0
- data/lib/huginn/searchable.rb +10 -0
- data/lib/huginn/version.rb +5 -0
- data/lib/huginn.rb +42 -0
- data/logo.png +0 -0
- metadata +182 -0
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
|