forest_admin_datasource_active_record 1.34.2 → 1.35.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: 144849075d84ddc59403e90f55215ff673e7a499ea2a4e9c7bde79b92bedf560
4
- data.tar.gz: 718e037d8de68a33682f00a4c47579f04d63aa8346c2bf2fe33057697adb58d7
3
+ metadata.gz: df0d3da32f3e5533efcd50c768ed24a103f7b442e956b48c7a359c07b3d3c721
4
+ data.tar.gz: 1af868e9ed17dc80de89648eb262c9fe960a2e35af5aa2515d7d802bd129557f
5
5
  SHA512:
6
- metadata.gz: 3481314773b0f72bd27df1af19a36bda78698f697b427d463a99d2868399890b37cd5ada815a58713d4b09715928c98a2a8895f3c386e6d1fe1f58bbf88e00ca
7
- data.tar.gz: 83bf7e9db1113e836ec18686f718dff66cc13fee0a485b91e09e44b913d04b9da57c539d1a329fcc6eeb93f5a43666bdd38bd77168ce29fb26f9c374f51c1055
6
+ metadata.gz: 415b589ca9918863682926543313e9c2917baf7736eb4333e876c9aafa9873580ddc6b4c15e9e9c64e7fdefbcea2a4617d9b9212479652d75d1855a277b82615
7
+ data.tar.gz: 7acfbd16b68262aca27a07e52cf2c76d1145f6e0fffc53d923a11a295475c171014d81c615469f6f738f79752475c7475a1fdc5cd36d16595b9beeb92ad58848
@@ -100,21 +100,32 @@ module ForestAdminDatasourceActiveRecord
100
100
  source_polymorphic = association.source_reflection&.polymorphic? &&
101
101
  association.options[:source_type].present?
102
102
 
103
- add_field(
104
- association.name.to_s,
105
- ForestAdminDatasourceToolkit::Schema::Relations::ManyToManySchema.new(
106
- foreign_collection: format_model_name(association.klass.name),
107
- origin_key: through_reflection.foreign_key,
108
- origin_key_target: through_reflection.join_foreign_key,
109
- foreign_key: association.join_foreign_key,
110
- foreign_key_target: association.association_primary_key,
111
- through_collection: format_model_name(through_reflection.klass.name),
112
- origin_type_field: is_polymorphic ? through_reflection.type : nil,
113
- origin_type_value: is_polymorphic ? @model.name : nil,
114
- foreign_type_field: source_polymorphic ? association.source_reflection.foreign_type : nil,
115
- foreign_type_value: source_polymorphic ? association.options[:source_type] : nil
103
+ if is_polymorphic || source_polymorphic
104
+ add_field(
105
+ association.name.to_s,
106
+ ForestAdminDatasourceToolkit::Schema::Relations::ManyToManySchema.new(
107
+ foreign_collection: format_model_name(association.klass.name),
108
+ origin_key: through_reflection.foreign_key,
109
+ origin_key_target: through_reflection.join_foreign_key,
110
+ foreign_key: association.join_foreign_key,
111
+ foreign_key_target: association.association_primary_key,
112
+ through_collection: format_model_name(through_reflection.klass.name),
113
+ origin_type_field: is_polymorphic ? through_reflection.type : nil,
114
+ origin_type_value: is_polymorphic ? @model.name : nil,
115
+ foreign_type_field: source_polymorphic ? association.source_reflection.foreign_type : nil,
116
+ foreign_type_value: source_polymorphic ? association.options[:source_type] : nil
117
+ )
116
118
  )
117
- )
119
+ else
120
+ add_field(
121
+ association.name.to_s,
122
+ ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new(
123
+ foreign_collection: format_model_name(association.klass.name),
124
+ origin_key: association.klass.primary_key,
125
+ origin_key_target: @model.primary_key
126
+ )
127
+ )
128
+ end
118
129
  elsif association.inverse_of&.polymorphic?
119
130
  add_field(
120
131
  association.name.to_s,
@@ -13,6 +13,7 @@ module ForestAdminDatasourceActiveRecord
13
13
  # root keeps all its selected columns (attributes + FKs); a related record is restricted to
14
14
  # its projected columns, matching the JOINed hydration
15
15
  hash = path.empty? || projection.nil? ? base_attributes(object) : projected_columns(object, projection)
16
+ hash = normalize_polymorphic_types(object.class, hash)
16
17
 
17
18
  serialize_associations(object, projection, hash, path) if projection
18
19
 
@@ -65,6 +66,7 @@ module ForestAdminDatasourceActiveRecord
65
66
 
66
67
  hash = {}
67
68
  projection.columns.each { |column| hash[column] = object[meta[:columns][column]] }
69
+ hash = normalize_polymorphic_types(target_model(relation_path), hash)
68
70
  projection.relations.each_key do |nested_name|
69
71
  hash[nested_name] = hash_joined_relation(projection.relations[nested_name], relation_path + [nested_name])
70
72
  end
@@ -72,8 +74,43 @@ module ForestAdminDatasourceActiveRecord
72
74
  hash
73
75
  end
74
76
 
77
+ def normalize_polymorphic_types(model_class, hash)
78
+ return hash if model_class.nil?
79
+
80
+ polymorphic_belongs_to(model_class).each do |association|
81
+ stored = hash[association.foreign_type]
82
+ next if stored.nil?
83
+
84
+ hash = hash.merge(association.foreign_type => model_class.polymorphic_class_for(stored).name)
85
+ rescue NameError => e
86
+ warn_unable(association.name, model_class, e)
87
+ end
88
+ hash
89
+ end
90
+
91
+ # Target model of a JOINed relation path (only belongs_to / has_one :through are ever JOINed).
92
+ def target_model(relation_path)
93
+ relation_path.reduce(object.class) do |model, name|
94
+ model&.reflect_on_association(name.to_sym)&.klass
95
+ end
96
+ rescue NameError
97
+ nil
98
+ end
99
+
75
100
  private
76
101
 
102
+ def polymorphic_belongs_to(model_class)
103
+ (@polymorphic_belongs_to ||= {})[model_class] ||=
104
+ model_class.reflect_on_all_associations(:belongs_to).select(&:polymorphic?)
105
+ end
106
+
107
+ def warn_unable(name, model_class, error)
108
+ ActiveSupport::Logger.new($stdout).warn(
109
+ "[ForestAdmin] Unable to normalize polymorphic type of '#{name}' " \
110
+ "in model '#{model_class.name}': #{error.message}. Keeping the stored value."
111
+ )
112
+ end
113
+
77
114
  def joined_relation?(relation_path)
78
115
  !joined_relations.nil? && joined_relations.key?(relation_path.join('.'))
79
116
  end
@@ -196,6 +196,10 @@ module ForestAdminDatasourceActiveRecord
196
196
  if collection.model.table_name == @collection.model.table_name
197
197
  if one_to_one_relations.include?(relation_schema.type)
198
198
  @select << "#{collection.model.table_name}.#{relation_schema.origin_key_target}"
199
+ # foreign_key is an array for a composite-key belongs_to through hop
200
+ Array(root_through_foreign_key(collection, relation_name)).each do |through_fk|
201
+ @select << "#{collection.model.table_name}.#{through_fk}"
202
+ end
199
203
  elsif many_to_one_relations.include?(relation_schema.type)
200
204
  @select << "#{collection.model.table_name}.#{relation_schema.foreign_key}"
201
205
  end
@@ -225,12 +229,13 @@ module ForestAdminDatasourceActiveRecord
225
229
  def split_relations
226
230
  join_tree = {}
227
231
  preload_tree = {}
228
- used_tables = Set[@collection.model.table_name] | @filter_joined_tables
232
+ used_joins = { @collection.model.table_name => :root }
233
+ @filter_joined_tables.each { |table| used_joins[table] ||= :filter } # never reuse a filter/sort join
229
234
 
230
235
  @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
236
+ joins = joinable_joins(@collection, relation_name, sub_projection, used_joins)
237
+ if joins
238
+ used_joins.merge!(joins)
234
239
  join_tree[relation_name.to_sym] = format_relation_projection(sub_projection)
235
240
  collect_joined_selects(@collection, relation_name, sub_projection, [relation_name])
236
241
  else
@@ -271,40 +276,83 @@ module ForestAdminDatasourceActiveRecord
271
276
  end
272
277
 
273
278
  # 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)
279
+ def joinable_joins(collection, relation_name, sub_projection, used_joins)
280
+ target = joinable_target(collection, relation_name)
276
281
  return nil if target.nil?
277
282
 
278
- tables = Set[target.model.table_name]
283
+ joins = join_signatures(collection, relation_name)
284
+ return nil if joins.nil? || conflicting?(joins, used_joins)
285
+
279
286
  sub_projection.relations.each do |nested_name, nested_projection|
280
- nested = joinable_tables(target, nested_name, nested_projection, used_tables | tables)
287
+ nested = joinable_joins(target, nested_name, nested_projection, used_joins.merge(joins))
281
288
  return nil if nested.nil?
282
289
 
283
- tables |= nested
290
+ joins = joins.merge(nested)
284
291
  end
285
- tables
292
+ joins
293
+ end
294
+
295
+ def conflicting?(new_joins, used_joins)
296
+ new_joins.any? { |table, signature| used_joins.key?(table) && used_joins[table] != signature }
286
297
  end
287
298
 
288
299
  # 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)
300
+ def joinable_target(collection, relation_name)
290
301
  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)
302
+ return unless relation_schema.respond_to?(:foreign_collection)
294
303
 
295
304
  # a scoped association applies its scope to the JOIN and may inject raw/unqualified SQL or
296
305
  # extra joins (e.g. `belongs_to :x, -> { where('id > ?', 1) }`)
297
306
  reflection = collection.model.reflect_on_association(relation_name.to_sym)
298
307
  return if reflection.nil? || reflection.scope
299
308
 
309
+ case relation_schema.type
310
+ when 'ManyToOne' then nil
311
+ when 'OneToOne' then return unless belongs_to_chain_through?(reflection)
312
+ else return
313
+ end
314
+
300
315
  target = local_ar_collection(collection.datasource, relation_schema.foreign_collection)
301
316
  return if target.nil? || !target.model.default_scopes.empty? # same risk as a scoped association
302
317
  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
318
 
305
319
  target
306
320
  end
307
321
 
322
+ def join_signatures(collection, relation_name)
323
+ reflection = collection.model.reflect_on_association(relation_name.to_sym)
324
+ if reflection.through_reflection?
325
+ { reflection.through_reflection.klass.table_name => signature(reflection.through_reflection),
326
+ reflection.klass.table_name => signature(reflection.source_reflection) }
327
+ else
328
+ { reflection.klass.table_name => signature(reflection) }
329
+ end
330
+ rescue StandardError
331
+ nil
332
+ end
333
+
334
+ def signature(reflection)
335
+ "#{reflection.active_record.table_name}.#{Array(reflection.foreign_key).join(",")}" \
336
+ "->#{reflection.klass.table_name}.#{Array(join_key(reflection)).join(",")}"
337
+ end
338
+
339
+ def join_key(reflection)
340
+ reflection.respond_to?(:join_primary_key) ? reflection.join_primary_key : reflection.association_primary_key
341
+ end
342
+
343
+ def belongs_to_chain_through?(reflection)
344
+ return false unless reflection.through_reflection?
345
+
346
+ through = reflection.through_reflection
347
+ source = reflection.source_reflection
348
+
349
+ through && source && through.belongs_to? && source.belongs_to? &&
350
+ through.scope.nil? && source.scope.nil? && through.klass.default_scopes.empty? &&
351
+ same_database?(reflection.active_record, through.klass)
352
+ rescue StandardError
353
+ false
354
+ end
355
+
308
356
  def same_database?(model_a, model_b)
309
357
  # compare the pools, not connection_specification_name (only an owner class name, shared across shards)
310
358
  model_a.connection_pool == model_b.connection_pool
@@ -330,6 +378,15 @@ module ForestAdminDatasourceActiveRecord
330
378
  @query
331
379
  end
332
380
 
381
+ def root_through_foreign_key(collection, relation_name)
382
+ through = collection.model.reflect_on_association(relation_name.to_sym)&.through_reflection
383
+ return unless through&.belongs_to?
384
+
385
+ through.foreign_key
386
+ rescue StandardError
387
+ nil
388
+ end
389
+
333
390
  def resolve_field(original_field)
334
391
  if original_field.include?(':')
335
392
  relation_name, column_name = original_field.split(':')
@@ -1,3 +1,3 @@
1
1
  module ForestAdminDatasourceActiveRecord
2
- VERSION = "1.34.2"
2
+ VERSION = "1.35.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.2
4
+ version: 1.35.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu