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.
@@ -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