huginn_datatable 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +5 -5
- data/README.pt-BR.md +5 -5
- data/lib/huginn/datatable/paginator.rb +4 -8
- data/lib/huginn/searchable/query.rb +60 -22
- data/lib/huginn/searchable/searchable.rb +2 -2
- data/lib/huginn/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '05719914312baa1225e26059bd5154312ee5df8bc369df6f5f9676d423ed9d0e'
|
|
4
|
+
data.tar.gz: b876f81b7add7781a4bd6b7424e3674f251c09ecfe38e0a70e8f891bb2286c76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bed2c0bc55d70a6ceaa57a6a84fb264360df75cbab4f42a2a82031073fdd0f15778b1681afcdc35aba31a478e4e8c18a6acfc960b725a1018306211845e72c72
|
|
7
|
+
data.tar.gz: 8f58d9e3fec2d2a38350dc010f9b68d6ab60449e27338291dfae8ae7bcaba2b96cbb64f945067487c1ed97889c6642bbb451dfde21644b0170e708ae109aa30a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
### Unreleased
|
|
4
4
|
|
|
5
|
+
#### 0.2.1
|
|
6
|
+
|
|
7
|
+
- Association-scoped `search` now matches through **primary-key semi-join subqueries** (`pk IN (SELECT DISTINCT pk FROM <base> JOIN <chain> WHERE <fuzzy>)`) instead of `left_joins` — one subquery per distinct association chain, combined with OR. The base relation never joins, so the count is a plain `COUNT(*)`.
|
|
8
|
+
|
|
5
9
|
#### 0.2.0
|
|
6
10
|
|
|
7
11
|
- **Breaking:** `:pg_trgm` search now uses the PostgreSQL `%` operator (`UNACCENT(col) % UNACCENT('term')`), driven by the **indexable** operator instead of a bare `similarity()` comparison. A `gin_trgm_ops` GIN index on `UNACCENT(col)` is used when present (BitmapOr over the same index with the ILIKE branch); without it, results stay correct via sequential scan.
|
data/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Huginn is a lightweight query layer for Rails that turns a raw datatable request
|
|
|
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
|
-
- **Lean counts** — `COUNT(
|
|
21
|
+
- **Lean counts** — the base relation never joins (`COUNT(*)` over a stripped relation); association matches run as pk subqueries instead.
|
|
22
22
|
- **SQL injection safe ordering/filtering** — every column reference is resolved through Arel reflection, never string-interpolated.
|
|
23
23
|
- **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.
|
|
24
24
|
- **Rails conventions** — works with `ActionController::Parameters`, Railtie auto-includes both concerns (toggleable), zero boilerplate.
|
|
@@ -129,7 +129,7 @@ Any `column` **or** `association.column` reference is validated and mapped to it
|
|
|
129
129
|
Plano.datatable({ orders: [{ "operadora.pessoa.nome" => "asc" }] }, allowed_paths: [{ operadora: :pessoa }])
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
> Association filters/range and
|
|
132
|
+
> Association filters/range, ordering and search use reflection-resolved subqueries (see the "allowlist of associations" section below). The base relation stays singular and join-free, so the count is a plain `COUNT(*)`.
|
|
133
133
|
|
|
134
134
|
## Association allowlist (`allowed_paths`)
|
|
135
135
|
|
|
@@ -185,7 +185,7 @@ class Person < ApplicationRecord
|
|
|
185
185
|
searchable_columns :name, company: [:name, :cnpj]
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
-
Person.search("globex") # matches company.name via a
|
|
188
|
+
Person.search("globex") # matches company.name via a pk subquery
|
|
189
189
|
Person.search("kayky", distinct: false) # disable the implicit DISTINCT
|
|
190
190
|
```
|
|
191
191
|
|
|
@@ -235,7 +235,7 @@ Notes:
|
|
|
235
235
|
|
|
236
236
|
```
|
|
237
237
|
phase 1 build the relation subqueries (pk IN … / ORDER BY (SELECT …)) + search + filters + order (no data in memory)
|
|
238
|
-
phase 2 count SELECT COUNT(
|
|
238
|
+
phase 2 count SELECT COUNT(*) ... (base relation is join-free)
|
|
239
239
|
paginate offset / limit
|
|
240
240
|
preload SELECT ... WHERE id IN (subset) (2nd lightweight query)
|
|
241
241
|
```
|
|
@@ -257,7 +257,7 @@ lib/huginn/datatable/filter_normalizer.rb functional param normalization
|
|
|
257
257
|
lib/huginn/datatable/paginator.rb lean count, pagination, isolated preload
|
|
258
258
|
lib/huginn/searchable.rb Huginn::Searchable (aggregator)
|
|
259
259
|
lib/huginn/searchable/searchable.rb the search Concern + DSL
|
|
260
|
-
lib/huginn/searchable/query.rb tolerant search builder (
|
|
260
|
+
lib/huginn/searchable/query.rb tolerant search builder (subqueries + OR)
|
|
261
261
|
lib/huginn/searchable/fuzzy.rb pg_trgm / unaccent / simple predicates
|
|
262
262
|
lib/generators/... `huginn:trigram_indexes` (GIN index migrations)
|
|
263
263
|
```
|
data/README.pt-BR.md
CHANGED
|
@@ -18,7 +18,7 @@ O Huginn é uma camada de consulta leve para Rails que transforma uma requisiç
|
|
|
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
|
-
- **Counts enxutos** — `COUNT(
|
|
21
|
+
- **Counts enxutos** — a relation base nunca faz join (`COUNT(*)` sobre uma relation restrita); as buscas de associação acontecem via subqueries no pk.
|
|
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
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.
|
|
24
24
|
- **Convenções do Rails** — funciona com `ActionController::Parameters`, Railtie inclui ambos os concerns automaticamente (desligável), zero boilerplate.
|
|
@@ -129,7 +129,7 @@ Qualquer referência `column` **ou** `associacao.column` é validada e mapeada p
|
|
|
129
129
|
Plano.datatable({ orders: [{ "operadora.pessoa.nome" => "asc" }] }, allowed_paths: [{ operadora: :pessoa }])
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
> Filtros/ranges e
|
|
132
|
+
> Filtros/ranges, order e busca de associação usam subqueries resolvidas por reflexão (veja a seção "Allowlist de associações" abaixo). A relation base permanece única e sem joins, então o count é um `COUNT(*)` simples.
|
|
133
133
|
|
|
134
134
|
## Allowlist de associações (`allowed_paths`)
|
|
135
135
|
|
|
@@ -185,7 +185,7 @@ class Person < ApplicationRecord
|
|
|
185
185
|
searchable_columns :name, company: [:name, :cnpj]
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
-
Person.search("globex") # encontra company.name via
|
|
188
|
+
Person.search("globex") # encontra company.name via subquery no pk
|
|
189
189
|
Person.search("kayky", distinct: false) # desativa o DISTINCT implícito
|
|
190
190
|
```
|
|
191
191
|
|
|
@@ -235,7 +235,7 @@ Observações:
|
|
|
235
235
|
|
|
236
236
|
```
|
|
237
237
|
fase 1 construir a relation subqueries (pk IN … / ORDER BY (SELECT …)) + search + filters + order (sem dados em memória)
|
|
238
|
-
fase 2 count SELECT COUNT(
|
|
238
|
+
fase 2 count SELECT COUNT(*) ... (relation base sem joins)
|
|
239
239
|
paginate offset / limit
|
|
240
240
|
preload SELECT ... WHERE id IN (subset) (2ª query leve)
|
|
241
241
|
```
|
|
@@ -257,7 +257,7 @@ lib/huginn/datatable/filter_normalizer.rb normalização funcional de params
|
|
|
257
257
|
lib/huginn/datatable/paginator.rb count enxuto, paginação, preload isolado
|
|
258
258
|
lib/huginn/searchable.rb Huginn::Searchable (agregador)
|
|
259
259
|
lib/huginn/searchable/searchable.rb o Concern do search + DSL
|
|
260
|
-
lib/huginn/searchable/query.rb construtor de busca tolerante (
|
|
260
|
+
lib/huginn/searchable/query.rb construtor de busca tolerante (subqueries + OR)
|
|
261
261
|
lib/huginn/searchable/fuzzy.rb predicados pg_trgm / unaccent / simple
|
|
262
262
|
lib/generators/... gerador `huginn:trigram_indexes` (migrations de índices GIN)
|
|
263
263
|
```
|
|
@@ -10,10 +10,9 @@ module Huginn
|
|
|
10
10
|
# 2. count lean, paginate with Pagy, then preload associations only
|
|
11
11
|
# on the small paginated subset.
|
|
12
12
|
#
|
|
13
|
-
# The
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# massive COUNT(DISTINCT ...) over the joins.
|
|
13
|
+
# The base relation never carries joins (association filters, ranges,
|
|
14
|
+
# orders and the search are all resolved through primary-key subqueries),
|
|
15
|
+
# so the count stays a plain COUNT(*) over the stripped relation.
|
|
17
16
|
class Paginator
|
|
18
17
|
attr_reader :relation, :params, :includes
|
|
19
18
|
|
|
@@ -77,10 +76,7 @@ module Huginn
|
|
|
77
76
|
end
|
|
78
77
|
|
|
79
78
|
def total_count
|
|
80
|
-
|
|
81
|
-
return stripped.count unless stripped.left_outer_joins_values.any? || stripped.joins_values.any?
|
|
82
|
-
|
|
83
|
-
stripped.select(stripped.arel_table[stripped.primary_key]).distinct.count
|
|
79
|
+
relation.except(:select, :includes, :order, :offset, :limit).count
|
|
84
80
|
end
|
|
85
81
|
|
|
86
82
|
def config
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../datatable/association_path"
|
|
4
|
+
|
|
3
5
|
module Huginn
|
|
4
6
|
module Searchable
|
|
7
|
+
AssociationPath = Datatable::AssociationPath unless const_defined?(:AssociationPath)
|
|
5
8
|
# Builds a tolerant full-text search relation over a model's searchable
|
|
6
|
-
# columns
|
|
9
|
+
# columns.
|
|
10
|
+
#
|
|
11
|
+
# Columns are split in two groups following the datatable pattern:
|
|
7
12
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
13
|
+
# * the model's own columns -> direct fuzzy predicates on the base WHERE
|
|
14
|
+
# * association columns -> semi-join subqueries on the primary key
|
|
15
|
+
# pk IN (SELECT DISTINCT pk FROM base
|
|
16
|
+
# JOIN <chain> ... WHERE <fuzzy>)
|
|
17
|
+
#
|
|
18
|
+
# The base relation never carries joins, so every match group stays one
|
|
19
|
+
# query and the count remains a plain COUNT(*). Multiple association
|
|
20
|
+
# chains produce one subquery each, combined with OR.
|
|
11
21
|
class Query
|
|
12
22
|
def self.call(model, value, options = {})
|
|
13
23
|
new(model, value, options).call
|
|
@@ -25,45 +35,65 @@ module Huginn
|
|
|
25
35
|
return apply_distinct(relation) if @value.strip.empty?
|
|
26
36
|
|
|
27
37
|
resolution = resolve_columns
|
|
28
|
-
|
|
38
|
+
predicates = fuzzy_predicates(resolution.fetch(:own))
|
|
39
|
+
resolution.fetch(:associations).each do |chain, attrs|
|
|
40
|
+
subquery = association_subquery(chain, attrs)
|
|
41
|
+
predicates << arel_table[primary_key].in(subquery.arel) if subquery
|
|
42
|
+
end
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
relation = relation.left_joins(*joins) if joins.any?
|
|
44
|
+
return apply_distinct(relation) if predicates.empty?
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
apply_distinct(relation.where(predicate))
|
|
46
|
+
apply_distinct(relation.where(predicates.reduce(&:or)))
|
|
35
47
|
end
|
|
36
48
|
|
|
37
49
|
private
|
|
38
50
|
|
|
51
|
+
def fuzzy_predicates(attrs)
|
|
52
|
+
attrs.map { |attr| fuzzy_predicate(attr) }
|
|
53
|
+
end
|
|
54
|
+
|
|
39
55
|
def resolve_columns
|
|
40
56
|
columns = @model.searchable_columns_config_resolved
|
|
41
|
-
|
|
42
|
-
|
|
57
|
+
own = []
|
|
58
|
+
associations = Hash.new { |hash, key| hash[key] = [] }
|
|
43
59
|
|
|
44
60
|
Array(columns[:columns]).map(&:to_s).reject(&:blank?).each do |field|
|
|
45
61
|
if field.include?(".")
|
|
46
|
-
|
|
47
|
-
push_association(
|
|
62
|
+
chain, column = field.split(".", 2)
|
|
63
|
+
push_association(associations, chain, column)
|
|
48
64
|
else
|
|
49
|
-
|
|
65
|
+
own << @model.arel_table[field] if @model.columns_hash.key?(field)
|
|
50
66
|
end
|
|
51
67
|
end
|
|
52
68
|
|
|
53
69
|
columns[:associations].each do |name, cols|
|
|
54
|
-
Array(cols).each { |
|
|
70
|
+
Array(cols).each { |column| push_association(associations, name.to_s, column.to_s) }
|
|
55
71
|
end
|
|
56
72
|
|
|
57
|
-
{
|
|
73
|
+
{ own: own, associations: associations }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def push_association(groups, chain, column)
|
|
77
|
+
path = AssociationPath.call(@model, "#{chain}.#{column}")
|
|
78
|
+
return unless path.valid?
|
|
79
|
+
return unless path.target_klass.columns_hash.key?(path.column)
|
|
80
|
+
|
|
81
|
+
groups[path.association_names] << path.target_table[path.column]
|
|
58
82
|
end
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
# Semi-join subquery over the base table: DISTINCT pk while joining the
|
|
85
|
+
# association chain of the group and applying the fuzzy predicates on the
|
|
86
|
+
# target columns. Mirrors datatable's association_ids_subquery.
|
|
87
|
+
def association_subquery(chain, attrs)
|
|
88
|
+
return nil if chain.empty?
|
|
63
89
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
spec = AssociationPath.join_spec_for(chain.map(&:to_sym))
|
|
91
|
+
base = @model.all.except(:select, :order, :offset, :limit)
|
|
92
|
+
.select(arel_table[primary_key])
|
|
93
|
+
.distinct
|
|
94
|
+
|
|
95
|
+
predicate = fuzzy_predicates(attrs).reduce(&:or)
|
|
96
|
+
base.joins(spec).where(predicate)
|
|
67
97
|
end
|
|
68
98
|
|
|
69
99
|
def fuzzy_predicate(attr)
|
|
@@ -121,6 +151,14 @@ module Huginn
|
|
|
121
151
|
@model.connection.adapter_name.downcase.include?("postgres")
|
|
122
152
|
end
|
|
123
153
|
|
|
154
|
+
def arel_table
|
|
155
|
+
@model.arel_table
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def primary_key
|
|
159
|
+
@model.primary_key
|
|
160
|
+
end
|
|
161
|
+
|
|
124
162
|
def apply_distinct(relation)
|
|
125
163
|
@distinct ? relation.distinct : relation
|
|
126
164
|
end
|
|
@@ -17,8 +17,8 @@ module Huginn
|
|
|
17
17
|
#
|
|
18
18
|
# When nothing is declared, every :string/:text column of the model is
|
|
19
19
|
# searched automatically. Columns may be association-scoped ("person.name")
|
|
20
|
-
# or declared via keyword arguments; scoped columns are
|
|
21
|
-
#
|
|
20
|
+
# or declared via keyword arguments; scoped columns are resolved through
|
|
21
|
+
# semi-join subqueries on the primary key (no joins on the base relation).
|
|
22
22
|
def searchable_columns(*columns, **associations)
|
|
23
23
|
self.searchable_columns_config = {
|
|
24
24
|
columns: columns.flatten.compact.map(&:to_s),
|
data/lib/huginn/version.rb
CHANGED