forest_admin_datasource_active_record 1.34.0 → 1.34.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: ff6eb62a2a078ff9d06f84b9c9d74d426f9840dd96cc1255bcbbdb63e289eee4
4
- data.tar.gz: 280037a300a634f82ccc897afddde0a524154dc4d4bc992fdf6048587a155c45
3
+ metadata.gz: 5c3a3205453469399c853ddd3984585890df288ad63968596b3f069c34bd3ff5
4
+ data.tar.gz: c565bc960082fcc9b93ddf5bef63922c6abe7ea30092301cd48a9053a1340d82
5
5
  SHA512:
6
- metadata.gz: adbf8689fefc5765bfd89d19c151d33ad2c7172febc552ae185cae11e34333dab6bfd528c34800d338d2451d53a4808db9e4a60386f97a7fda1d009f1fb62d5c
7
- data.tar.gz: 7bb1557e8ac0d859cfe78b5b032d4e90c8fb9740d626d02db55d1262f53d27d5bfb004d93a3748cb48eb757d5749986ad7d15d8c213f69a0c5b13002fbf87020
6
+ metadata.gz: 986559472d2810f5636947ed6bfd1c539865f4323dc7c1edadaea3e225cdfe2716d388982e307e41f17e105b55bdbd09f8b54f43a1a7d15438a22a0472cc0a9b
7
+ data.tar.gz: 4424bbacd8dcaddb77f76f80d3121ef555cc2f6bc88242f4593b17bc66e332e9d96892bc6180bf94d8f7adab0dc25e7eda53b23054abd04c612eef0c354e1d7b
@@ -26,8 +26,9 @@ module ForestAdminDatasourceActiveRecord
26
26
 
27
27
  def list(_caller, filter, projection)
28
28
  query = Utils::Query.new(self, projection, filter)
29
+ records = query.get
29
30
 
30
- query.get.map { |record| Utils::ActiveRecordSerializer.new(record).to_hash(projection) }
31
+ records.map { |record| Utils::ActiveRecordSerializer.new(record, query.joined_relations).to_hash(projection) }
31
32
  end
32
33
 
33
34
  def aggregate(_caller, filter, aggregation, limit = nil)
@@ -1,53 +1,85 @@
1
1
  module ForestAdminDatasourceActiveRecord
2
2
  module Utils
3
- ActiveRecordSerializer = Struct.new(:object) do
3
+ # Relations in `joined_relations` (see Query#collect_joined_selects) are hydrated from the flat
4
+ # row's aliased columns; every other relation is read from its preloaded ActiveRecord association.
5
+ ActiveRecordSerializer = Struct.new(:object, :joined_relations) do
4
6
  def to_hash(projection)
5
7
  hash_object(object, projection)
6
8
  end
7
9
 
8
- def hash_object(object, projection = nil, with_associations: true)
9
- hash = {}
10
-
10
+ def hash_object(object, projection = nil, path: [])
11
11
  return if object.nil?
12
12
 
13
- hash.merge! object.attributes
13
+ # root keeps all its selected columns (attributes + FKs); a related record is restricted to
14
+ # its projected columns, matching the JOINed hydration
15
+ hash = path.empty? || projection.nil? ? base_attributes(object) : projected_columns(object, projection)
14
16
 
15
- serialize_associations(object, projection, hash) if with_associations
17
+ serialize_associations(object, projection, hash, path) if projection
16
18
 
17
19
  hash
18
20
  end
19
21
 
20
- def serialize_associations(object, projection, hash)
22
+ def base_attributes(object)
23
+ return object.attributes if join_aliases.empty?
24
+
25
+ object.attributes.except(*join_aliases)
26
+ end
27
+
28
+ def projected_columns(object, projection)
29
+ projection.columns.to_h { |column| [column, object[column]] }
30
+ end
31
+
32
+ def serialize_associations(object, projection, hash, path)
21
33
  one_associations = %i[has_one belongs_to]
22
34
  many_associations = %i[has_many has_and_belongs_to_many]
23
35
 
24
- # Handle one-to-one and many-to-one associations
25
- object.class.reflect_on_all_associations
26
- .filter { |a| one_associations.include?(a.macro) && projection.relations.key?(a.name.to_s) }
27
- .each do |association|
28
- association_name = association.name.to_s
29
- hash[association_name] = hash_object(
30
- object.send(association_name),
31
- projection.relations[association_name],
32
- with_associations: projection.relations.key?(association_name)
33
- )
34
- end
35
-
36
- # Handle one-to-many and many-to-many associations
37
- object.class.reflect_on_all_associations
38
- .filter { |a| many_associations.include?(a.macro) && projection.relations.key?(a.name.to_s) }
39
- .each do |association|
40
- association_name = association.name.to_s
41
- collection = object.send(association_name)
42
- # Serialize the collection as an array
43
- hash[association_name] = collection.map do |item|
44
- hash_object(
45
- item,
46
- projection.relations[association_name],
47
- with_associations: projection.relations.key?(association_name)
48
- )
49
- end
50
- end
36
+ projection.relations.each_key do |association_name|
37
+ relation_path = path + [association_name]
38
+
39
+ if joined_relation?(relation_path)
40
+ hash[association_name] = hash_joined_relation(projection.relations[association_name], relation_path)
41
+ next
42
+ end
43
+
44
+ association = object.class.reflect_on_association(association_name.to_sym)
45
+ next if association.nil?
46
+
47
+ if one_associations.include?(association.macro)
48
+ hash[association_name] = hash_object(
49
+ object.send(association_name),
50
+ projection.relations[association_name],
51
+ path: relation_path
52
+ )
53
+ elsif many_associations.include?(association.macro)
54
+ hash[association_name] = object.send(association_name).map do |item|
55
+ hash_object(item, projection.relations[association_name], path: relation_path)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Reads a JOINed relation's columns from the aliases on the root object (not a nested one).
62
+ def hash_joined_relation(projection, relation_path)
63
+ meta = joined_relations[relation_path.join('.')]
64
+ return nil if object[meta[:pk_alias]].nil?
65
+
66
+ hash = {}
67
+ projection.columns.each { |column| hash[column] = object[meta[:columns][column]] }
68
+ projection.relations.each_key do |nested_name|
69
+ hash[nested_name] = hash_joined_relation(projection.relations[nested_name], relation_path + [nested_name])
70
+ end
71
+
72
+ hash
73
+ end
74
+
75
+ private
76
+
77
+ def joined_relation?(relation_path)
78
+ !joined_relations.nil? && joined_relations.key?(relation_path.join('.'))
79
+ end
80
+
81
+ def join_aliases
82
+ @join_aliases ||= (joined_relations || {}).values.flat_map { |meta| meta[:columns].values }.uniq
51
83
  end
52
84
  end
53
85
  end
@@ -1,9 +1,11 @@
1
+ require 'set'
2
+
1
3
  module ForestAdminDatasourceActiveRecord
2
4
  module Utils
3
5
  class Query
4
6
  include ForestAdminDatasourceToolkit::Components::Query::ConditionTree
5
7
 
6
- attr_reader :query, :select
8
+ attr_reader :query, :select, :joined_relations
7
9
 
8
10
  def initialize(collection, projection, filter)
9
11
  @collection = collection
@@ -12,6 +14,11 @@ module ForestAdminDatasourceActiveRecord
12
14
  @filter = filter
13
15
  @arel_table = @collection.model.arel_table
14
16
  @select = []
17
+ # relation path (e.g. "bank_account.organizations_view") => { columns: { col => sql_alias }, pk_alias: }
18
+ @joined_relations = {}
19
+ @alias_counter = 0
20
+ # tables already joined by filters/sorts, so apply_select does not join them a second time
21
+ @filter_joined_tables = Set.new
15
22
  end
16
23
 
17
24
  def build
@@ -204,12 +211,119 @@ module ForestAdminDatasourceActiveRecord
204
211
  end
205
212
 
206
213
  def apply_select
214
+ unless @projection.nil?
215
+ join_tree, preload_tree = split_relations
216
+
217
+ @query = @query.left_outer_joins(join_tree) unless join_tree.empty?
218
+ @query = @query.includes(preload_tree) unless preload_tree.empty?
219
+ end
207
220
  @query = @query.select(@select.join(', ')) if @select
208
- @query = @query.includes(format_relation_projection(@projection)) unless @projection.nil?
209
221
 
210
222
  @query
211
223
  end
212
224
 
225
+ def split_relations
226
+ join_tree = {}
227
+ preload_tree = {}
228
+ used_tables = Set[@collection.model.table_name] | @filter_joined_tables
229
+
230
+ @projection.relations.each do |relation_name, sub_projection|
231
+ tables = joinable_tables(@collection, relation_name, sub_projection, used_tables)
232
+ if tables
233
+ used_tables |= tables
234
+ join_tree[relation_name.to_sym] = format_relation_projection(sub_projection)
235
+ collect_joined_selects(@collection, relation_name, sub_projection, [relation_name])
236
+ else
237
+ preload_tree[relation_name.to_sym] = format_relation_projection(sub_projection)
238
+ end
239
+ end
240
+
241
+ [join_tree, preload_tree]
242
+ end
243
+
244
+ def collect_joined_selects(collection, relation_name, sub_projection, path)
245
+ relation_schema = collection.schema[:fields][relation_name]
246
+ target = local_ar_collection(collection.datasource, relation_schema.foreign_collection)
247
+ table = target.model.table_name
248
+ pk_columns = Array(target.model.primary_key) # array for composite primary keys
249
+
250
+ alias_map = {}
251
+ # a pk column is always selected so the serializer can detect a NULL (absent) left-joined relation
252
+ target.model.connection_pool.with_connection do |connection|
253
+ (sub_projection.columns + pk_columns).uniq.each do |column|
254
+ sql_alias = next_join_alias
255
+ # quote via the adapter so identifiers are valid on every database (e.g. backticks on MySQL)
256
+ @select << "#{connection.quote_table_name(table)}.#{connection.quote_column_name(column)} " \
257
+ "AS #{connection.quote_column_name(sql_alias)}"
258
+ alias_map[column] = sql_alias
259
+ end
260
+ end
261
+ @joined_relations[path.join('.')] = { columns: alias_map, pk_alias: alias_map[pk_columns.first] }
262
+
263
+ sub_projection.relations.each do |nested_name, nested_projection|
264
+ collect_joined_selects(target, nested_name, nested_projection, path + [nested_name])
265
+ end
266
+ end
267
+
268
+ def next_join_alias
269
+ @alias_counter += 1
270
+ "fa_join_#{@alias_counter}"
271
+ end
272
+
273
+ # Set of tables the subtree adds via JOIN, or nil if any relation in it can't be safely joined.
274
+ def joinable_tables(collection, relation_name, sub_projection, used_tables)
275
+ target = joinable_target(collection, relation_name, used_tables)
276
+ return nil if target.nil?
277
+
278
+ tables = Set[target.model.table_name]
279
+ sub_projection.relations.each do |nested_name, nested_projection|
280
+ nested = joinable_tables(target, nested_name, nested_projection, used_tables | tables)
281
+ return nil if nested.nil?
282
+
283
+ tables |= nested
284
+ end
285
+ tables
286
+ end
287
+
288
+ # The target collection when this hop is safe to collapse into a JOIN, else nil (-> preload).
289
+ def joinable_target(collection, relation_name, used_tables)
290
+ relation_schema = collection.schema[:fields][relation_name]
291
+ # belongs_to only: it joins on the target's primary key, so it can't duplicate the parent
292
+ # (a has_one child may not be unique)
293
+ return unless relation_schema.type == 'ManyToOne' && relation_schema.respond_to?(:foreign_collection)
294
+
295
+ # a scoped association applies its scope to the JOIN and may inject raw/unqualified SQL or
296
+ # extra joins (e.g. `belongs_to :x, -> { where('id > ?', 1) }`)
297
+ reflection = collection.model.reflect_on_association(relation_name.to_sym)
298
+ return if reflection.nil? || reflection.scope
299
+
300
+ target = local_ar_collection(collection.datasource, relation_schema.foreign_collection)
301
+ return if target.nil? || !target.model.default_scopes.empty? # same risk as a scoped association
302
+ return unless same_database?(collection.model, target.model)
303
+ return if used_tables.include?(target.model.table_name) # a table joined twice would be aliased by AR
304
+
305
+ target
306
+ end
307
+
308
+ def same_database?(model_a, model_b)
309
+ # compare the pools, not connection_specification_name (only an owner class name, shared across shards)
310
+ model_a.connection_pool == model_b.connection_pool
311
+ rescue StandardError
312
+ false
313
+ end
314
+
315
+ # The target collection only if it is AR-backed AND belongs to this exact datasource, else nil.
316
+ # Guards (concrete class + identity, not just the name) against a foreign-datasource collection.
317
+ def local_ar_collection(datasource, name)
318
+ collection = datasource.get_collection(name)
319
+ return nil unless collection.is_a?(ForestAdminDatasourceActiveRecord::Collection)
320
+ return nil unless collection.datasource.equal?(datasource)
321
+
322
+ collection
323
+ rescue StandardError
324
+ nil
325
+ end
326
+
213
327
  def add_join_relation(relation_name)
214
328
  @query = @query.left_joins(relation_name.to_sym)
215
329
 
@@ -222,6 +336,7 @@ module ForestAdminDatasourceActiveRecord
222
336
  relation = @collection.schema[:fields][relation_name]
223
337
  related_collection = @collection.datasource.get_collection(relation.foreign_collection)
224
338
  add_join_relation(relation_name)
339
+ @filter_joined_tables << related_collection.model.table_name
225
340
 
226
341
  {
227
342
  formatted: "#{related_collection.model.table_name}.#{column_name}",
@@ -1,3 +1,3 @@
1
1
  module ForestAdminDatasourceActiveRecord
2
- VERSION = "1.34.0"
2
+ VERSION = "1.34.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_datasource_active_record
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.34.0
4
+ version: 1.34.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-06-26 00:00:00.000000000 Z
12
+ date: 2026-07-02 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord