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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Huginn
|
|
4
|
+
module Datatable
|
|
5
|
+
# Resolves a multi-segment association chain ("company.people.name") into
|
|
6
|
+
# the concrete steps (tables, klasses and Arel link conditions) needed to
|
|
7
|
+
# build:
|
|
8
|
+
#
|
|
9
|
+
# * a subquery for filtering/ranges
|
|
10
|
+
# WHERE pk IN (SELECT DISTINCT pk FROM <chain> join ... WHERE ...)
|
|
11
|
+
#
|
|
12
|
+
# * a correlated scalar subquery for ordering
|
|
13
|
+
# ORDER BY (SELECT col FROM <chain> join ... WHERE <correlation> ...)
|
|
14
|
+
#
|
|
15
|
+
# The link conditions are direction aware: a `belongs_to` puts the FK on
|
|
16
|
+
# the owner table while `has_many`/`has_one` put it on the child table.
|
|
17
|
+
# Association scopes are folded in as Arel predicates (never parsed SQL).
|
|
18
|
+
class AssociationPath
|
|
19
|
+
Step = Struct.new(:reflection, :klass, :table, :link_to_previous, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
attr_reader :segments, :column
|
|
22
|
+
|
|
23
|
+
def self.call(model, field)
|
|
24
|
+
new(model, field)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(model, field)
|
|
28
|
+
@model = model
|
|
29
|
+
@segments = field.to_s.split(".")
|
|
30
|
+
@column = segments.last
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The association steps of the chain ("company", "people").
|
|
34
|
+
def association_names
|
|
35
|
+
resolved_steps.map { |step| step.reflection.name.to_s }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def target_klass
|
|
39
|
+
resolved_steps.last.klass
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def target_table
|
|
43
|
+
resolved_steps.last.table
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def valid?
|
|
47
|
+
resolved_steps.present?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The walkable Arel filter/order chain, or nil when invalid.
|
|
51
|
+
def resolved_steps
|
|
52
|
+
@steps ||= build_steps
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Correlated scalar subquery: ORDER BY (SELECT <col> FROM ... WHERE
|
|
56
|
+
# <correlation> [AND scope...] ORDER BY <col> ASC LIMIT 1).
|
|
57
|
+
# Deterministic: the smallest matching value per parent row.
|
|
58
|
+
def correlated_order_expression
|
|
59
|
+
steps = resolved_steps
|
|
60
|
+
return nil unless steps
|
|
61
|
+
|
|
62
|
+
manager = Arel::SelectManager.new
|
|
63
|
+
manager.from(steps.first.table)
|
|
64
|
+
steps.drop(1).each do |step|
|
|
65
|
+
manager.join(step.table).on(step.link_to_previous)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The first step correlates the subquery to the outer (base) table.
|
|
69
|
+
manager.where(steps.first.link_to_previous)
|
|
70
|
+
scope_predicates.each { |predicate| manager.where(predicate) }
|
|
71
|
+
manager.project(target_table[column])
|
|
72
|
+
manager.order(target_table[column].asc)
|
|
73
|
+
manager.take(1)
|
|
74
|
+
|
|
75
|
+
Arel.sql("(#{manager.to_sql})")
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Reverse engineers a join spec usable by `ActiveRecord::Relation#joins`
|
|
81
|
+
# for filter subqueries: `.joins(:people: { company: ... })`.
|
|
82
|
+
def self.join_spec_for(names)
|
|
83
|
+
names.reverse.reduce(nil) do |acc, name|
|
|
84
|
+
acc.nil? ? name.to_sym : { name.to_sym => acc }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
attr_reader :model
|
|
91
|
+
|
|
92
|
+
def build_steps
|
|
93
|
+
chain = @segments[0..-2]
|
|
94
|
+
return nil if chain.empty?
|
|
95
|
+
|
|
96
|
+
klass = model
|
|
97
|
+
previous_table = model.arel_table
|
|
98
|
+
walked = []
|
|
99
|
+
|
|
100
|
+
chain.each do |name|
|
|
101
|
+
reflection = klass.reflect_on_association(name.to_sym) || table_reflection(klass, name)
|
|
102
|
+
return nil unless reflection
|
|
103
|
+
|
|
104
|
+
decomposed_reflections(reflection).each do |ref|
|
|
105
|
+
table = ref.klass.arel_table
|
|
106
|
+
link = join_link(ref, previous_table)
|
|
107
|
+
return nil unless link
|
|
108
|
+
|
|
109
|
+
walked << Step.new(reflection: ref, klass: ref.klass, table: table, link_to_previous: link)
|
|
110
|
+
previous_table = table
|
|
111
|
+
klass = ref.klass
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
walked
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Flattens a through reflection into through + source reflections so the
|
|
119
|
+
# chain walk stays uniform.
|
|
120
|
+
def decomposed_reflections(reflection)
|
|
121
|
+
if reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
|
122
|
+
[reflection.through_reflection, reflection.source_reflection].compact
|
|
123
|
+
else
|
|
124
|
+
[reflection]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Allows a scoped column to be addressed by its table name instead of
|
|
129
|
+
# the association name ("companies.name" vs "company.name"), matching
|
|
130
|
+
# the base model's first reflection that resolves to that table.
|
|
131
|
+
def table_reflection(klass, name)
|
|
132
|
+
target = begin
|
|
133
|
+
name.singularize.camelize.constantize
|
|
134
|
+
rescue NameError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
return nil unless target && target < ActiveRecord::Base
|
|
138
|
+
|
|
139
|
+
klass.reflect_on_all_associations.find { |ref| ref.klass == target }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Direction aware join condition:
|
|
143
|
+
# belongs_to -> target_table[target_pk] = owner_table[fk]
|
|
144
|
+
# has_one/many -> child_table[fk] = parent_table[parent_pk]
|
|
145
|
+
def join_link(reflection, previous_table)
|
|
146
|
+
current = reflection.klass.arel_table
|
|
147
|
+
|
|
148
|
+
if reflection.belongs_to?
|
|
149
|
+
current[reflection.join_primary_key].eq(previous_table[reflection.foreign_key])
|
|
150
|
+
else
|
|
151
|
+
current[reflection.foreign_key].eq(previous_table[reflection.active_record_primary_key])
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Association scope (e.g. `has_many :people, -> { where(active: true) }`)
|
|
156
|
+
# folded into the subquery through its Arel constraints instead of a
|
|
157
|
+
# parsed WHERE string.
|
|
158
|
+
def scope_predicates
|
|
159
|
+
resolved_steps.flat_map do |step|
|
|
160
|
+
next [] unless step.reflection.scope.is_a?(Proc)
|
|
161
|
+
|
|
162
|
+
relation = step.klass.instance_exec(&step.reflection.scope)
|
|
163
|
+
relation.instance_of?(ActiveRecord::Relation) ? relation.arel.constraints : []
|
|
164
|
+
rescue StandardError
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Huginn
|
|
4
|
+
module Datatable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class_attribute :huginn_attribute_mapping, default: nil
|
|
9
|
+
class_attribute :huginn_strict_mapping, default: false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
# Executes a datatable request in two phases:
|
|
14
|
+
#
|
|
15
|
+
# phase 1 filtering & ordering built on join-subqueries and scalar
|
|
16
|
+
# correlated subqueries (never joins/loads data on the
|
|
17
|
+
# base relation)
|
|
18
|
+
# phase 2 lean count, pagination and preload of the small subset
|
|
19
|
+
#
|
|
20
|
+
# Association scoped fields are resolved as subqueries against the
|
|
21
|
+
# primary key, so the base relation stays flat and the count is
|
|
22
|
+
# lightweight. `allowed_paths` is a strict allowlist of the association
|
|
23
|
+
# chains that may be filtered/ordered/ranged — anything else is
|
|
24
|
+
# silently ignored.
|
|
25
|
+
#
|
|
26
|
+
# @param params [ActionController::Parameters, Hash] keys: :page,
|
|
27
|
+
# :per_page, :search, :filters, :range_data and :orders
|
|
28
|
+
# @option allowed_paths [Array<Symbol, String, Hash>] strict allowlist of
|
|
29
|
+
# association chains for filtering/ordering/ranges (deny-all by default)
|
|
30
|
+
# @option includes [Array<Symbol, Hash>] associations preloaded only on
|
|
31
|
+
# the final paginated subset (keeps queries lightweight)
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash] { total_count: Integer, data: ActiveRecord::Relation }
|
|
34
|
+
def datatable(params, allowed_paths: [], includes: [])
|
|
35
|
+
relation = datatable_relation(params, allowed_paths: allowed_paths)
|
|
36
|
+
Paginator.call(relation, params, includes: includes)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Builds the filtered/ordered relation. Kept public so callers can
|
|
40
|
+
# compose it with their own relation (e.g. tenant scoping) before
|
|
41
|
+
# pagination.
|
|
42
|
+
def datatable_relation(params, allowed_paths: [])
|
|
43
|
+
allowlist = effective_allowed_paths(allowed_paths)
|
|
44
|
+
relation = all
|
|
45
|
+
|
|
46
|
+
relation = apply_datatable_search(relation, params[:search])
|
|
47
|
+
relation = apply_datatable_filters(relation, params, allowlist)
|
|
48
|
+
relation = apply_datatable_order(relation, params[:orders], allowlist)
|
|
49
|
+
|
|
50
|
+
relation
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Declares the public API names that map to real columns (or
|
|
54
|
+
# association-scoped columns), hiding the database schema.
|
|
55
|
+
#
|
|
56
|
+
# huginn_attributes name: "users.name",
|
|
57
|
+
# company_name: "companies.name"
|
|
58
|
+
#
|
|
59
|
+
# When `strict:` is true (default), fields not present in the mapping
|
|
60
|
+
# are rejected — API callers can only filter/order by the aliases.
|
|
61
|
+
# Without a mapping the model falls back to plain/reflected columns.
|
|
62
|
+
# Accepts a positional Hash or keyword arguments.
|
|
63
|
+
def huginn_attributes(mapping = nil, strict: true, **aliases)
|
|
64
|
+
merged = (mapping || {}).merge(aliases)
|
|
65
|
+
self.huginn_attribute_mapping = merged.map { |key, value| [key.to_s, value.to_s] }.to_h
|
|
66
|
+
self.huginn_strict_mapping = strict
|
|
67
|
+
huginn_attribute_mapping
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def apply_datatable_search(relation, value)
|
|
73
|
+
return relation if value.blank? || !respond_to?(:search)
|
|
74
|
+
|
|
75
|
+
relation.merge(search(value, distinct: false))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def apply_datatable_filters(relation, params, allowed)
|
|
79
|
+
filters = FilterNormalizer.call(params[:filters])
|
|
80
|
+
filters.each do |field, values|
|
|
81
|
+
relation = apply_datatable_filter(relation, field, values, allowed)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
each_range_data(params[:range_data]) do |field, value|
|
|
85
|
+
relation = apply_datatable_range(relation, field, value, allowed)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
relation
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def apply_datatable_filter(relation, field, values, allowed)
|
|
92
|
+
validator = Validator.call(self, field)
|
|
93
|
+
return relation unless authorized_validator?(validator, allowed)
|
|
94
|
+
|
|
95
|
+
if validator.plain?
|
|
96
|
+
apply_plain_filter(relation, validator, Array(values))
|
|
97
|
+
else
|
|
98
|
+
apply_scoped_filter(relation, validator, Array(values))
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def apply_plain_filter(relation, validator, values)
|
|
103
|
+
attr = validator.arel_attribute
|
|
104
|
+
return relation if attr.nil?
|
|
105
|
+
|
|
106
|
+
if values.include?("null")
|
|
107
|
+
apply_nullable_predicate(relation, attr, values)
|
|
108
|
+
elsif values.size == 1
|
|
109
|
+
relation.where(attr.eq(values.first))
|
|
110
|
+
else
|
|
111
|
+
relation.where(attr.in(values))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Scoped filters run as: WHERE <base PK> IN (
|
|
116
|
+
# SELECT DISTINCT <base PK> FROM <chain> JOIN ... WHERE <target> IN ...)
|
|
117
|
+
def apply_scoped_filter(relation, validator, values)
|
|
118
|
+
subquery = association_ids_subquery(relation, validator)
|
|
119
|
+
return relation unless subquery
|
|
120
|
+
|
|
121
|
+
attr = validator.arel_attribute
|
|
122
|
+
target = if values.include?("null")
|
|
123
|
+
apply_nullable_predicate(subquery, attr, values)
|
|
124
|
+
elsif values.size == 1
|
|
125
|
+
subquery.where(attr.eq(values.first))
|
|
126
|
+
else
|
|
127
|
+
subquery.where(attr.in(values))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
relation.where(primary_key => target)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A subquery over the same base relation (keeps tenant scoping) that
|
|
134
|
+
# selects DISTINCT pk while joining the association chain of the field.
|
|
135
|
+
def association_ids_subquery(relation, validator)
|
|
136
|
+
chain = validator.authorization_path
|
|
137
|
+
return nil if chain.empty?
|
|
138
|
+
|
|
139
|
+
spec = AssociationPath.join_spec_for(chain.map(&:to_sym))
|
|
140
|
+
base = relation.except(:select, :order, :offset, :limit)
|
|
141
|
+
.select(arel_table[primary_key])
|
|
142
|
+
.distinct
|
|
143
|
+
base.joins(spec)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def apply_datatable_range(relation, field, value, allowed)
|
|
147
|
+
values = Array(value)
|
|
148
|
+
return relation if field.blank? || values.size < 2
|
|
149
|
+
|
|
150
|
+
validator = Validator.call(self, field)
|
|
151
|
+
return relation unless authorized_validator?(validator, allowed) && validator.valid?
|
|
152
|
+
|
|
153
|
+
from, to = values[0], values[1]
|
|
154
|
+
return relation if from.blank? && to.blank?
|
|
155
|
+
|
|
156
|
+
if validator.plain?
|
|
157
|
+
attr = validator.arel_attribute
|
|
158
|
+
apply_range_predicate(relation, attr, from, to, validator.column_type)
|
|
159
|
+
else
|
|
160
|
+
apply_scoped_range(relation, validator, from, to)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def apply_scoped_range(relation, validator, from, to)
|
|
165
|
+
subquery = association_ids_subquery(relation, validator)
|
|
166
|
+
return relation unless subquery
|
|
167
|
+
|
|
168
|
+
attr = validator.arel_attribute
|
|
169
|
+
target = apply_range_predicate(subquery, attr, from, to, validator.column_type)
|
|
170
|
+
relation.where(primary_key => target)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def apply_range_predicate(relation, attr, from, to, type)
|
|
174
|
+
if numeric_column?(type)
|
|
175
|
+
from = to_numeric(from)
|
|
176
|
+
to = to_numeric(to)
|
|
177
|
+
return relation if from.nil? && to.nil?
|
|
178
|
+
|
|
179
|
+
predicate = range_predicate(attr, from, to)
|
|
180
|
+
relation.where(predicate)
|
|
181
|
+
else
|
|
182
|
+
from = parse_datatable_date(from)
|
|
183
|
+
to = parse_datatable_date(to)
|
|
184
|
+
return relation if from.blank? && to.blank?
|
|
185
|
+
|
|
186
|
+
relation.where(range_predicate(attr, from, to))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def range_predicate(attr, from, to)
|
|
191
|
+
if from && to
|
|
192
|
+
attr.between(from..to)
|
|
193
|
+
elsif from
|
|
194
|
+
attr.gteq(from)
|
|
195
|
+
else
|
|
196
|
+
attr.lteq(to)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def numeric_column?(type)
|
|
201
|
+
%i[integer decimal float].include?(type)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def to_numeric(value)
|
|
205
|
+
value.to_f if value.present?
|
|
206
|
+
rescue ArgumentError, TypeError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def apply_nullable_predicate(relation, attr, values)
|
|
211
|
+
non_null = values.reject { |value| value == "null" }
|
|
212
|
+
predicate = if non_null.empty?
|
|
213
|
+
attr.eq(nil)
|
|
214
|
+
elsif non_null.size == 1
|
|
215
|
+
attr.eq(non_null.first).or(attr.eq(nil))
|
|
216
|
+
else
|
|
217
|
+
attr.in(non_null).or(attr.eq(nil))
|
|
218
|
+
end
|
|
219
|
+
relation.where(predicate)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def apply_datatable_order(relation, orders, allowed)
|
|
223
|
+
return relation if orders.blank?
|
|
224
|
+
|
|
225
|
+
Array(orders).compact.each do |item|
|
|
226
|
+
next unless item.respond_to?(:each_pair)
|
|
227
|
+
|
|
228
|
+
item.each_pair do |field, direction|
|
|
229
|
+
validator = Validator.call(self, field)
|
|
230
|
+
next unless authorized_validator?(validator, allowed)
|
|
231
|
+
|
|
232
|
+
expression = if validator.plain?
|
|
233
|
+
validator.arel_attribute
|
|
234
|
+
else
|
|
235
|
+
validator.correlated_order_expression
|
|
236
|
+
end
|
|
237
|
+
next if expression.nil?
|
|
238
|
+
|
|
239
|
+
ordering = normalized_direction(direction) == "desc" ? expression.desc : expression.asc
|
|
240
|
+
relation = relation.order(ordering)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
relation
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def normalized_direction(direction)
|
|
248
|
+
%w[asc desc].include?(direction.to_s.downcase) ? direction.to_s.downcase : "asc"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def authorized_validator?(validator, allowed)
|
|
252
|
+
return false unless validator.valid?
|
|
253
|
+
return true if validator.plain?
|
|
254
|
+
|
|
255
|
+
chain = validator.authorization_path
|
|
256
|
+
!chain.empty? && allowed.include?(chain)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The allowlist, merged with the association chains reached by the
|
|
260
|
+
# model's declared `huginn_attributes`, so declared aliases keep working
|
|
261
|
+
# under strict authorization.
|
|
262
|
+
def effective_allowed_paths(spec)
|
|
263
|
+
allowed = AllowedPaths.call(self, spec)
|
|
264
|
+
each_huginn_alias_path do |path|
|
|
265
|
+
allowed.include_path(path)
|
|
266
|
+
end
|
|
267
|
+
allowed
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def each_huginn_alias_path
|
|
271
|
+
return unless respond_to?(:huginn_attribute_mapping) && huginn_attribute_mapping.present?
|
|
272
|
+
|
|
273
|
+
huginn_attribute_mapping.each_value do |target|
|
|
274
|
+
segments = target.to_s.split(".")
|
|
275
|
+
yield segments[0..-2] if segments.size >= 2
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def each_range_data(range_data)
|
|
280
|
+
return if range_data.blank?
|
|
281
|
+
|
|
282
|
+
case range_data
|
|
283
|
+
when ActionController::Parameters, Hash
|
|
284
|
+
range_data.each_pair { |field, value| yield(field, value) }
|
|
285
|
+
when Array
|
|
286
|
+
range_data.each do |item|
|
|
287
|
+
case item
|
|
288
|
+
when ActionController::Parameters, Hash
|
|
289
|
+
item.each_pair { |field, value| yield(field, value) }
|
|
290
|
+
when Array
|
|
291
|
+
field, value = item
|
|
292
|
+
yield(field, value) if field
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def parse_datatable_date(value)
|
|
299
|
+
return nil if value.blank?
|
|
300
|
+
return value.to_date if value.respond_to?(:to_date)
|
|
301
|
+
|
|
302
|
+
Date.parse(value.to_s)
|
|
303
|
+
rescue ArgumentError, TypeError
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Huginn
|
|
4
|
+
module Datatable
|
|
5
|
+
# Normalizes the heterogeneous filter payloads sent by frontends
|
|
6
|
+
# (ActionController::Parameters, Hash, Array of hashes, [key, value]
|
|
7
|
+
# pairs, ...) into a single Hash of { column => Array(values) }.
|
|
8
|
+
#
|
|
9
|
+
# Values are accumulated in arrays so that repeated keys produce an
|
|
10
|
+
# IN condition when applied as a datatable filter. Invalid payload
|
|
11
|
+
# shapes are simply ignored, and keys are normalized to strings.
|
|
12
|
+
class FilterNormalizer
|
|
13
|
+
def self.call(raw)
|
|
14
|
+
new(raw).call
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(raw)
|
|
18
|
+
@raw = raw
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
each_pair(@raw).each_with_object({}) do |(key, value), acc|
|
|
23
|
+
(acc[key.to_s] ||= []) << value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def each_pair(raw, &block)
|
|
30
|
+
return enum_for(:each_pair, raw) unless block
|
|
31
|
+
|
|
32
|
+
case raw
|
|
33
|
+
when ActionController::Parameters
|
|
34
|
+
raw.to_unsafe_h.each { |key, value| block.call(key, value) }
|
|
35
|
+
when Hash
|
|
36
|
+
raw.each { |key, value| block.call(key, value) }
|
|
37
|
+
when Array
|
|
38
|
+
if pair?(raw)
|
|
39
|
+
block.call(raw.first, raw.last)
|
|
40
|
+
else
|
|
41
|
+
raw.each { |item| each_pair(item) { |key, value| block.call(key, value) } }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A two-element array whose first element is a key (String/Symbol)
|
|
47
|
+
# is treated as a single [key, value] pair rather than a list.
|
|
48
|
+
def pair?(array)
|
|
49
|
+
array.size == 2 && (array.first.is_a?(String) || array.first.is_a?(Symbol))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pagy"
|
|
4
|
+
|
|
5
|
+
module Huginn
|
|
6
|
+
module Datatable
|
|
7
|
+
# Splits datatable execution into two phases:
|
|
8
|
+
#
|
|
9
|
+
# 1. build the filtered/ordered relation
|
|
10
|
+
# 2. count lean, paginate with Pagy, then preload associations only
|
|
11
|
+
# on the small paginated subset.
|
|
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.
|
|
17
|
+
class Paginator
|
|
18
|
+
attr_reader :relation, :params, :includes
|
|
19
|
+
|
|
20
|
+
def self.call(relation, params, includes: [])
|
|
21
|
+
new(relation, params, includes: includes).call
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(relation, params, includes: [])
|
|
25
|
+
@relation = relation
|
|
26
|
+
@params = params
|
|
27
|
+
@includes = Array(includes).compact
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
pagy = build_pagy
|
|
32
|
+
data = relation.offset(pagy.offset).limit(limit_for(pagy))
|
|
33
|
+
data = data.preload(*includes) if includes.any?
|
|
34
|
+
|
|
35
|
+
{ total_count: pagy.count, data: data }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_pagy
|
|
41
|
+
page = params.fetch(:page, 1).to_i
|
|
42
|
+
items = params.fetch(:per_page, nil).presence || config.pagy_items
|
|
43
|
+
items = items.to_i
|
|
44
|
+
items = config.pagy_max_items if items > config.pagy_max_items
|
|
45
|
+
items = 1 if items < 1
|
|
46
|
+
page = 1 if page < 1
|
|
47
|
+
|
|
48
|
+
if offset_api?
|
|
49
|
+
::Pagy::Offset.new(count: total_count, page: page, limit: items)
|
|
50
|
+
elsif items_keyword?
|
|
51
|
+
::Pagy.new(count: total_count, page: page, items: items)
|
|
52
|
+
else
|
|
53
|
+
::Pagy.new(count: total_count, page: page, limit: items)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Pagy >= 43 exposes the dedicated `Pagy::Offset` class that speaks
|
|
58
|
+
# `limit:`. Releases 6..9 carry the legacy `Pagy.new(count:page:items:)`
|
|
59
|
+
# interface instead.
|
|
60
|
+
def offset_api?
|
|
61
|
+
defined?(::Pagy::Offset)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Pagy 6..8 accept the page size through the `items:` constructor key and
|
|
65
|
+
# expose it through `#items`. Pagy 9 restructured the constructors, but
|
|
66
|
+
# the early 9.x releases keep `Pagy.new` keyed on `limit:`. Probe the
|
|
67
|
+
# live constructor instead of guessing by version number.
|
|
68
|
+
def items_keyword?
|
|
69
|
+
sample = ::Pagy.new(count: 100, page: 1, items: 7)
|
|
70
|
+
sample.respond_to?(:items) && sample.items == 7
|
|
71
|
+
rescue StandardError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def limit_for(pagy)
|
|
76
|
+
pagy.respond_to?(:limit) ? pagy.limit : pagy.items
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
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
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def config
|
|
87
|
+
Huginn.configuration
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|