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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7014d31503c76a5637c2bd15b82b8cf2be1c173ef11575a9325ec8457d7c65e
4
- data.tar.gz: 175542cecf2d1378d0fa571ef538c0f2cccf24d6acab94bb3e0b398e97926261
3
+ metadata.gz: '05719914312baa1225e26059bd5154312ee5df8bc369df6f5f9676d423ed9d0e'
4
+ data.tar.gz: b876f81b7add7781a4bd6b7424e3674f251c09ecfe38e0a70e8f891bb2286c76
5
5
  SHA512:
6
- metadata.gz: 951dcb3aeb617bc98b23fc8e883e0fa06d572ff312e0888171b17a26b70454eddbf84972bc6dfd91589a3788ac188266d1282d9122f53522923094ebdec0017c
7
- data.tar.gz: 21ed148247e3e9d3a9cff2682d4d19c15c4bdf7806fd0222c4018f829a98fde7f52720e8219e2caaaa929d8c778f5ccc6b7933de9b2245bc6b597c89f27a7745
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(DISTINCT pk)` through a stripped relation; no JOIN materialization.
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 ordering use reflection-resolved subqueries (see the "allowlist of associations" section below). The main relation stays singular and the count is `COUNT(DISTINCT pk)`.
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 left_join
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(DISTINCT "<pk column>") ... (subquery, pk-indexed)
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 (joins + OR)
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(DISTINCT pk)` através de uma relation restrita, sem materialização de JOINs.
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 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)`.
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 left_join
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(DISTINCT "<pk>") ... (subquery, indexada por pk)
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 (joins + OR)
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 count runs against a stripped relation (no select/includes/order/
14
- # offset/limit) selecting only the primary key with DISTINCT, so that
15
- # PostgreSQL answers it through the PK index as a subquery instead of a
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
- stripped = relation.except(:select, :includes, :order, :offset, :limit)
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, optionally crossing associations through left_joins.
9
+ # columns.
10
+ #
11
+ # Columns are split in two groups following the datatable pattern:
7
12
  #
8
- # All matched columns are fused into a single Arel OR predicate, so the
9
- # whole search stays one query regardless of how many columns (or
10
- # associations) are involved.
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
- return apply_distinct(relation) if resolution.fetch(:attrs).empty?
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
- joins = resolution.fetch(:joins)
31
- relation = relation.left_joins(*joins) if joins.any?
44
+ return apply_distinct(relation) if predicates.empty?
32
45
 
33
- predicate = resolution.fetch(:attrs).map { |attr| fuzzy_predicate(attr) }.reduce(&:or)
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
- joins = []
42
- attrs = []
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
- name, col = field.split(".", 2)
47
- push_association(joins, attrs, name, col)
62
+ chain, column = field.split(".", 2)
63
+ push_association(associations, chain, column)
48
64
  else
49
- attrs << @model.arel_table[field] if @model.columns_hash.key?(field)
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 { |col| push_association(joins, attrs, name.to_s, col.to_s) }
70
+ Array(cols).each { |column| push_association(associations, name.to_s, column.to_s) }
55
71
  end
56
72
 
57
- { joins: joins.uniq, attrs: attrs }
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
- def push_association(joins, attrs, name, col)
61
- association = @model.reflect_on_association(name.to_sym)
62
- return unless association
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
- table = association.klass.arel_table
65
- attrs << table[col] if association.klass.columns_hash.key?(col)
66
- joins << name.to_sym unless joins.include?(name.to_sym)
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 reached through a
21
- # left_join.
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),
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Huginn
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kayky Marcelo