forest_liana 9.22.1 → 9.22.3

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: be442492f5ecaafbff4d9d246c03634430ce0164159117f30a898cb730486090
4
- data.tar.gz: 331dad7bbb30db9f6542adebf235ce1b8bf6e6f8abc96ef986b8fbb2c677aacd
3
+ metadata.gz: 277b2499887f0ff82ea53ca55fc79843541cef4709f064c70f5efa55a4735d53
4
+ data.tar.gz: f0fbeb9771dae6c1db93581279e433815f47eca0e257543f24bf012057da51d3
5
5
  SHA512:
6
- metadata.gz: 8b01396fdeb47da3b36cba49666da16dd352ac8742a12cb83b84617b9c5e8529aaeaf500181c8b36cc4993aa090caaddf03e9c6b21ee3a17ddd47ab471b64e70
7
- data.tar.gz: 707b4b922403fdefa05723d0fbdfadedc73bf7c73b59c1b7a9df6e8a55de61b6a43c2056ff50192063a385fc3e7a1202ac0770114c242409bf0a4499c84a46b0
6
+ metadata.gz: 8e02b44b8348a306b0e7e60b44da087c0effb4ba538947e9165e6db2e156698b4a276a0288335ea8b7768454e0d4309ef4232e37c994abd2adffc04763f66f2b
7
+ data.tar.gz: 2be7bc1df7f51619407f1fe8f2b49a8e7b5629ada6151677e6f1f9a57a5548ffbeec5cfdb33f31cb8ca015a26937fb73e96f7decb9b63e959903e69497791f1b
@@ -222,7 +222,20 @@ module ForestLiana
222
222
  if reflection_primary_key != klass_primary_key
223
223
  data[attribute_name] = attr_data.merge({
224
224
  attr_or_block: proc {
225
- relation.klass.find_by(reflection_primary_key => object.send(relation.foreign_key))
225
+ # The find_by below reads once per row, undoing any preload, and
226
+ # resolves less than ActiveRecord does: it drops the association's
227
+ # own scope and matches a nil key against a target row whose key is
228
+ # NULL too. Both branches answer the same record only once it is
229
+ # aligned on the preloader, which is what scope_for does — and what
230
+ # raises on a relation declaring no scope at all, the common case.
231
+ association = object.association(attribute_name)
232
+ next association.target if association.loaded? && !association.stale_target?
233
+
234
+ key = object.send(relation.foreign_key)
235
+ next nil if key.nil?
236
+
237
+ scoped = relation.scope ? relation.scope_for(relation.klass.all, object) : relation.klass
238
+ scoped.find_by(reflection_primary_key => key)
226
239
  }
227
240
  })
228
241
  next
@@ -1,22 +1,22 @@
1
1
  module ForestLiana
2
2
  module AggregationHelper
3
- def resolve_field_path(field_param, default_field = 'id')
3
+ def resolve_field_path(field_param, default_field = 'id', resource = @resource)
4
4
  if field_param.blank?
5
- default_field ||= @resource.primary_key || 'id'
6
- return "#{@resource.table_name}.#{default_field}"
5
+ default_field ||= resource.primary_key || 'id'
6
+ return "#{resource.table_name}.#{default_field}"
7
7
  end
8
8
 
9
9
  if field_param.include?(':')
10
10
  association, field = field_param.split ':'
11
- associated_resource = @resource.reflect_on_association(association.to_sym)
11
+ associated_resource = resource.reflect_on_association(association.to_sym)
12
12
  "#{associated_resource.table_name}.#{field}"
13
13
  else
14
- "#{@resource.table_name}.#{field_param}"
14
+ "#{resource.table_name}.#{field_param}"
15
15
  end
16
16
  end
17
17
 
18
- def aggregation_sql(type, field)
19
- field_path = resolve_field_path(field)
18
+ def aggregation_sql(type, field, resource = @resource)
19
+ field_path = resolve_field_path(field, 'id', resource)
20
20
 
21
21
  case type
22
22
  when 'sum'
@@ -38,7 +38,9 @@ module ForestLiana
38
38
  polymorphic, preload_loads = analyze_associations(resource)
39
39
  result = records.eager_load(@includes.uniq - preload_loads - polymorphic - @optional_includes)
40
40
 
41
- result = result.preload(preload_loads) if Rails::VERSION::MAJOR >= 7 && force_preload
41
+ if Rails::VERSION::MAJOR >= 7 && force_preload
42
+ result = result.preload(selectable_preloads(result, preload_loads))
43
+ end
42
44
 
43
45
  result
44
46
  end
@@ -48,6 +50,7 @@ module ForestLiana
48
50
  # instance_eval, once per record — loaded here in one query for the whole page instead.
49
51
  def apply_smart_field_preloads(records)
50
52
  preloads = smart_field_preloads
53
+ preloads = preloads.slice(*selectable_preloads(records, preloads.keys))
51
54
 
52
55
  preloads.empty? ? records : records.preload(preloads)
53
56
  end
@@ -192,6 +195,153 @@ module ForestLiana
192
195
  end
193
196
  end
194
197
 
198
+ # To-one only: a filter puts a to-many in @includes too, and preloading that would read every
199
+ # child row of a page that never displays them.
200
+ PRELOADABLE_CROSS_DATABASE_MACROS = [:belongs_to, :has_one].freeze
201
+
202
+ def cross_database_associations(resource)
203
+ @includes.uniq.select do |name|
204
+ association = resource.reflect_on_association(name)
205
+ next false if association.nil? || SchemaUtils.polymorphic?(association)
206
+ next false unless PRELOADABLE_CROSS_DATABASE_MACROS.include?(association.macro)
207
+
208
+ separate_database?(resource, association) && instance_dependent_hop(association).nil?
209
+ end
210
+ end
211
+
212
+ # Same version split as preload_polymorphic_associations. Nothing reads the loaders back here:
213
+ # a plain relation lands in the association cache, where the serializer finds it.
214
+ def preload_cross_database_associations(records, associations)
215
+ return if associations.empty? || records.empty?
216
+
217
+ associations = associations.reject { |name| missing_preload_key?(records, name) }
218
+ return if associations.empty?
219
+
220
+ if Rails::VERSION::MAJOR >= 7
221
+ ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
222
+ else
223
+ ActiveRecord::Associations::Preloader.new.preload(records, associations)
224
+ end
225
+ end
226
+
227
+ # A missing key raises while resolving the query, out of MissingAttributeValve's reach, and
228
+ # takes down the whole list rather than the one field (PRD-1316 from the other direction).
229
+ # Not only a belongs_to: a has_one reads its owner key off this row too when it declares a
230
+ # primary_key of its own. Per record class, not projected_resource, because that is how the
231
+ # Preloader resolves the reflection — one record per class is enough, the select being shared.
232
+ def missing_preload_key?(records, association_name)
233
+ records.group_by(&:class).any? do |klass, klass_records|
234
+ association = klass._reflect_on_association(association_name)
235
+ next false if association.nil?
236
+
237
+ missing = preload_owner_keys(association).reject { |key| klass_records.first.has_attribute?(key) }
238
+ next false if missing.empty?
239
+
240
+ warn_cross_database_preload_skipped(association_name, missing)
241
+ true
242
+ end
243
+ end
244
+
245
+ def warn_cross_database_preload_skipped(association_name, missing_keys)
246
+ reason = "its \"#{missing_keys.join('", "')}\" key is not in the projected select"
247
+ return unless PRELOAD_SKIPS_WARNED.add?([@collection&.name, association_name, reason])
248
+
249
+ FOREST_LOGGER.warn "The \"#{association_name}\" relation of the \"#{@collection&.name}\" " \
250
+ "collection lives in another database and cannot be preloaded (#{reason}) — it falls back " \
251
+ 'to the lazy load, which cannot read that key either and resolves to a null relation.'
252
+ end
253
+
254
+ # The same question as missing_preload_key?, asked of a relation rather than of a page. A
255
+ # preload attached to a relation resolves after this method returns — per batch for an export,
256
+ # on #load for a list — so there is no record to test the key against, only the select the
257
+ # relation already carries. An empty one is SELECT *, which can never be missing anything; a
258
+ # narrowed one (a segment scope's .select, a default_scope's) can, and reading a key that is
259
+ # not there raises while resolving the query, out of MissingAttributeValve's reach, taking
260
+ # down the whole export where the lazy load it replaces degraded to a null relation.
261
+ def selectable_preloads(records, names)
262
+ return names if names.empty? || !records.respond_to?(:select_values)
263
+ return names if records.select_values.empty?
264
+
265
+ selected = selected_column_names(records)
266
+ return names if selected.include?('*')
267
+
268
+ names.reject do |name|
269
+ association = projected_resource.reflect_on_association(name)
270
+ next false if association.nil?
271
+
272
+ missing = preload_owner_keys(association).reject { |key| selected.include?(key.to_s) }
273
+ next false if missing.empty?
274
+
275
+ warn_unselected_preload_key(name, missing)
276
+ true
277
+ end
278
+ end
279
+
280
+ # Only a plain column reference can be read back off a select.
281
+ PLAIN_SELECT_REFERENCE = /\A(?:"?(?<table>\w+)"?\.)?"?(?<column>\w+|\*)"?\z/
282
+
283
+ def selected_column_names(records)
284
+ table = projected_resource.table_name
285
+
286
+ records.select_values.each_with_object(Set.new) do |value, names|
287
+ select_references(value).each do |reference|
288
+ match = PLAIN_SELECT_REFERENCE.match(reference)
289
+ next if match.nil? || (match[:table] && match[:table] != table)
290
+
291
+ names << match[:column]
292
+ end
293
+ end
294
+ end
295
+
296
+ # One select_value can name several columns, which splitting on commas recovers — but only
297
+ # while no parenthesis is in play: `COALESCE(uri, driver_id, name) AS x` would otherwise read
298
+ # as naming driver_id, and the preload that lets through raises on a column the row does not
299
+ # carry, which is the failure this guard exists to prevent. An expression names nothing here,
300
+ # so the preload is skipped and the relation left to the lazy load — the safe way to be wrong.
301
+ #
302
+ # SqlLiteral is a String, and is meant to be read like one. An Arel attribute is not, and
303
+ # carries its table and column apart, `products.*` included.
304
+ def select_references(value)
305
+ case value
306
+ when String, Symbol
307
+ text = value.to_s
308
+ text.include?('(') ? [] : text.split(',').map(&:strip)
309
+ when Arel::Attributes::Attribute
310
+ value.relation.respond_to?(:name) ? ["#{value.relation.name}.#{value.name}"] : []
311
+ else
312
+ []
313
+ end
314
+ end
315
+
316
+ # Re-checks the preloads a relation already carries, for the callers that attach them before
317
+ # the select is final — HasManyGetter builds its query in prepare_query and only projects in
318
+ # #perform. Judging the intermediate select drops preloads apply_projection was about to make
319
+ # safe: inherited_load_columns reads preload_values and selects their keys precisely because
320
+ # they are already attached. So the question is asked here, where the select is the one the
321
+ # query will run, and #preload only ever adds — removing one means rebuilding the relation.
322
+ def drop_unselected_preloads(records)
323
+ values = records.preload_values
324
+ return records if values.empty?
325
+
326
+ kept_names = selectable_preloads(records, association_names(values))
327
+ kept = values.select do |value|
328
+ names = value.is_a?(Hash) ? value.keys : [value]
329
+ names.all? { |name| kept_names.include?(name.to_sym) }
330
+ end
331
+
332
+ kept.size == values.size ? records : records.except(:preload).preload(kept)
333
+ end
334
+
335
+ def warn_unselected_preload_key(association_name, missing_keys)
336
+ reason = "its \"#{missing_keys.join('", "')}\" key is not in the query's select"
337
+ return unless PRELOAD_SKIPS_WARNED.add?([@collection&.name, association_name, reason])
338
+
339
+ FOREST_LOGGER.warn "The \"#{association_name}\" relation of the \"#{@collection&.name}\" " \
340
+ "collection cannot be preloaded (#{reason}) — it falls back to the lazy load, which reads " \
341
+ 'it once per record where that key is on the row and resolves to a null relation where ' \
342
+ 'it is not.'
343
+ end
344
+
195
345
  # records_by_owner's keys are the exact objects the Preloader was given, not copies — no need
196
346
  # to re-find them by id, which would also mis-assign on a nil or duplicate id (composite
197
347
  # primary keys are supported elsewhere in this gem).
@@ -364,6 +514,19 @@ module ForestLiana
364
514
  select_foreign_keys(select, projected_resource, association, joined?(association, joined_relations))
365
515
  end
366
516
 
517
+ # A cross-database to-one relation is never joined, so select_foreign_keys names nothing
518
+ # owner-side for the has_one half of it — and the preloader reads that key off this row.
519
+ # Without it here the guard in preload_cross_database_associations fires on every page and
520
+ # the relation keeps being read once per record, which is the N+1 this file removes. Off
521
+ # @includes for the same reason as the polymorphic loop above: the preload runs over all of
522
+ # it, not only the requested subset.
523
+ cross_database_associations(projected_resource).each do |name|
524
+ association = projected_resource.reflect_on_association(name)
525
+ preload_owner_keys(association).each do |key|
526
+ select << "#{projected_resource.table_name}.#{key}" if column?(projected_resource, key)
527
+ end
528
+ end
529
+
367
530
  @field_names_requested.each do |path|
368
531
  association = get_one_association(path)
369
532
  if association
@@ -85,11 +85,15 @@ module ForestLiana
85
85
  # batch never calls #perform and only reads ids, so it falls back to the plain
86
86
  # @base_records_for_batch, which needs no preload.
87
87
  def query_for_batch
88
- @unprojected_records ? apply_smart_field_preloads(@unprojected_records) : @base_records_for_batch
88
+ return @base_records_for_batch unless @unprojected_records
89
+
90
+ drop_unselected_preloads(apply_smart_field_preloads(@unprojected_records))
89
91
  end
90
92
 
93
+ # The preloads were attached in prepare_query, before #perform projected: this is the first
94
+ # point where the select is the one the query will run.
91
95
  def records
92
- records = @records.limit(limit).offset(offset)
96
+ records = drop_unselected_preloads(@records).limit(limit).offset(offset)
93
97
  polymorphic_associations, = analyze_associations(model_association)
94
98
 
95
99
  # Left a Relation (not resolved yet) when there is nothing to preload - some callers still
@@ -222,9 +226,10 @@ module ForestLiana
222
226
  # already routed those (and cross-DB ones) into `preload_loads`, so `move_to_preload` is
223
227
  # always safe to preload; only `preload_loads` needs the Rails 7+ gate.
224
228
  result = result.preload(move_to_preload)
225
- result = result.preload(preload_loads) if Rails::VERSION::MAJOR >= 7
226
229
 
227
- result
230
+ # Of the two shapes in preload_loads, 6.1's preloader only refuses the instance-dependent
231
+ # ones — gating both left it reading a cross-database relation once per row for nothing.
232
+ result.preload(Rails::VERSION::MAJOR >= 7 ? preload_loads : cross_database_associations(resource))
228
233
  end
229
234
 
230
235
  # Association names (symbols) whose JOIN the current request actually needs, so they must
@@ -4,9 +4,10 @@ module ForestLiana
4
4
 
5
5
  def initialize(parent_model, params, forest_user)
6
6
  @resource = parent_model
7
- @scoped_parent_model = get_scoped_model(parent_model, forest_user, params[:timezone])
8
- child_model = @scoped_parent_model.reflect_on_association(params[:relationshipFieldName]).klass
9
- @scoped_child_model = get_scoped_model(child_model, forest_user, params[:timezone])
7
+ @scoped_parent_model = get_scoped_model(parent_model, forest_user, params[:timezone]) || parent_model.unscoped
8
+ @relationship_field_name = params[:relationshipFieldName].to_sym
9
+ @child_model = @scoped_parent_model.reflect_on_association(@relationship_field_name).klass
10
+ @child_scope = get_scoped_model(@child_model, forest_user, params[:timezone])
10
11
  @label_field = params[:labelFieldName]
11
12
  @aggregate = params[:aggregator].downcase
12
13
  @aggregate_field = params[:aggregateFieldName]
@@ -15,17 +16,25 @@ module ForestLiana
15
16
  end
16
17
 
17
18
  def perform
18
- includes = ForestLiana::QueryHelper.get_one_association_names_symbol(@scoped_child_model)
19
-
20
19
  alias_name = aggregation_alias(@aggregate, @aggregate_field)
21
-
22
- result = @scoped_child_model
23
- .joins(includes)
24
- .where({ @scoped_parent_model.name.downcase.to_sym => @scoped_parent_model })
20
+ # NOTICE: The chart groups on the parent collection and aggregates its related records, so
21
+ # the query starts from the parent: querying the related model leaves the parent
22
+ # table out of the FROM clause unless it happens to be one of its belongs_to.
23
+ aggregation = aggregation_sql(@aggregate, @aggregate_field, @child_model)
24
+ records = @scoped_parent_model.joins(@relationship_field_name)
25
+
26
+ # NOTICE: The related collection scope goes through a subquery: merging it would re-root its
27
+ # joins onto the parent, and reorder drops the ordering of the parent default_scope.
28
+ if @child_scope
29
+ child_key = @child_model.primary_key
30
+ records = records.where(@child_model.table_name => { child_key => @child_scope.select(child_key) })
31
+ end
32
+
33
+ result = records
25
34
  .group(@group_by)
26
- .order(Arel.sql("#{alias_name} DESC"))
35
+ .reorder(Arel.sql("#{alias_name} DESC"))
27
36
  .limit(@limit)
28
- .pluck(@group_by, Arel.sql("#{aggregation_sql(@aggregate, @aggregate_field)} AS #{alias_name}"))
37
+ .pluck(@group_by, Arel.sql("#{aggregation} AS #{alias_name}"))
29
38
  .map { |key, value| { key: key, value: value } }
30
39
 
31
40
  @record = Model::Stat.new(value: result)
@@ -34,7 +43,7 @@ module ForestLiana
34
43
  def get_scoped_model(model, forest_user, timezone)
35
44
  scope_filters = ForestLiana::ScopeManager.get_scope(model.name, forest_user)
36
45
 
37
- return model.unscoped if scope_filters.blank?
46
+ return if scope_filters.blank?
38
47
 
39
48
  FiltersParser.new(scope_filters, model, timezone, @params).apply_filters
40
49
  end
@@ -75,15 +75,25 @@ module ForestLiana
75
75
  # See HasManyGetter#query_for_batch: same dual-use split between a CSV export (which calls
76
76
  # #perform first, so this picks up its smart-field preload) and a bulk "select all" batch
77
77
  # (which never does, per initialize_resources_getter below, and only reads ids).
78
+ # On the relation, not a page: find_in_batches then resolves it per batch, so the key guard
79
+ # reads the select rather than a row. "Unprojected" only means this class did not narrow it —
80
+ # a segment whose scope calls .select narrows it before prepare_query ever returns.
78
81
  def query_for_batch
79
- @unprojected_records ? apply_smart_field_preloads(@unprojected_records) : @base_records_for_batch
82
+ return @base_records_for_batch unless @unprojected_records
83
+
84
+ records = apply_smart_field_preloads(@unprojected_records)
85
+ cross_database = selectable_preloads(records, cross_database_associations(@resource))
86
+
87
+ cross_database.empty? ? records : records.preload(cross_database)
80
88
  end
81
89
 
82
90
  def records
83
91
  records = @records.offset(offset).limit(limit).to_a
84
- polymorphic_association, preload_loads = analyze_associations(@resource)
92
+ polymorphic_association, = analyze_associations(@resource)
85
93
 
86
94
  preload_polymorphic_associations(records, polymorphic_association)
95
+ # On the page, not @records: count and query_for_batch build off the same relation.
96
+ preload_cross_database_associations(records, cross_database_associations(@resource))
87
97
 
88
98
  records
89
99
  end
@@ -130,7 +130,7 @@ module ForestLiana
130
130
  def self.disable_filter_and_sort_if_cross_db!(opts, name, collection_name)
131
131
  return unless opts[:reference]
132
132
 
133
- assoc_name = opts[:reference].split('.').first&.underscore&.to_sym || name
133
+ assoc_name = name.to_sym
134
134
  model = find_model_from_collection_name(collection_name)
135
135
  return unless model
136
136
 
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.1"
2
+ VERSION = "9.22.3"
3
3
  end
@@ -1,3 +1,12 @@
1
1
  class Car < GarageRecord
2
2
  belongs_to :driver
3
- end
3
+
4
+ # Cross-database, keyed on a column that is not Driver's primary key.
5
+ belongs_to :pilot, class_name: 'Driver', primary_key: :firstname, foreign_key: :model,
6
+ optional: true
7
+
8
+ # The same, plus a scope: the preloader applies it and a bare find_by does not, so the
9
+ # serializer's two branches only agree once the fallback is aligned on the preloader.
10
+ belongs_to :active_pilot, -> { where.not(firstname: 'retired') }, class_name: 'Driver',
11
+ primary_key: :firstname, foreign_key: :model, optional: true
12
+ end
@@ -1,3 +1,6 @@
1
1
  class Driver < UserRecord
2
2
  has_one :car
3
- end
3
+
4
+ # The inverse of Car#pilot: the preload reads "firstname" off this row, and nothing projects it.
5
+ has_one :piloted_car, class_name: 'Car', primary_key: :firstname, foreign_key: :model
6
+ end
@@ -1,3 +1,11 @@
1
1
  class Manufacturer < ApplicationRecord
2
2
  has_many :products
3
+
4
+ # A scope narrowing the select, which HasManyGetter attaches its preload to before
5
+ # apply_projection widens it — the select the guard must not judge.
6
+ has_many :narrowed_products, -> { select(:id, :name, :manufacturer_id) }, class_name: 'Product'
7
+
8
+ # Cross-database, and named after neither its target model nor anything else declared here.
9
+ belongs_to :chief, class_name: 'Driver', foreign_key: :name, primary_key: :firstname,
10
+ optional: true
3
11
  end
@@ -2,5 +2,16 @@ class Product < ApplicationRecord
2
2
  belongs_to :manufacturer
3
3
  belongs_to :driver, optional: true
4
4
 
5
+ # Same database, but the same shape the serializer intercepts: a primary_key that is not the
6
+ # target's, plus a scope. Joined rather than preloaded, so it pins that the intercept is about
7
+ # the declared key, not about crossing a database. On a column of its own: a foreign key is not
8
+ # a serialized attribute, so keying this on `name` would take `name` off every Product payload
9
+ # in the suite.
10
+ belongs_to :maker, -> { where.not(name: 'retired') }, class_name: 'Manufacturer',
11
+ primary_key: :name, foreign_key: :maker_name, optional: true
12
+
13
+ # What a segment scope calling .select does to a relation before any getter sees it.
14
+ scope :narrowed_select, -> { select(:id, :name) }
15
+
5
16
  validates :uri, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp }
6
17
  end
@@ -0,0 +1,5 @@
1
+ class AddMakerNameToProducts < ActiveRecord::Migration[6.0]
2
+ def change
3
+ add_column :products, :maker_name, :string
4
+ end
5
+ end
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema.define(version: 2026_09_22_120000) do
13
+ ActiveRecord::Schema.define(version: 2026_09_24_120000) do
14
14
 
15
15
  create_table "addresses", force: :cascade do |t|
16
16
  t.string "line1"
@@ -75,6 +75,7 @@ ActiveRecord::Schema.define(version: 2026_09_22_120000) do
75
75
  t.string "name"
76
76
  t.integer "manufacturer_id"
77
77
  t.integer "driver_id"
78
+ t.string "maker_name"
78
79
  t.index ["driver_id"], name: "index_products_on_driver_id"
79
80
  t.index ["manufacturer_id"], name: "index_products_on_manufacturer_id"
80
81
  end