Skip to content

Commit 503a25a

Browse files
authored
fix(datasource-active-record): normalize demodulized polymorphic types & reuse shared joins (#328)
1 parent ed22a3c commit 503a25a

9 files changed

Lines changed: 212 additions & 30 deletions

File tree

packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/active_record_serializer.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def hash_object(object, projection = nil, path: [])
1313
# root keeps all its selected columns (attributes + FKs); a related record is restricted to
1414
# its projected columns, matching the JOINed hydration
1515
hash = path.empty? || projection.nil? ? base_attributes(object) : projected_columns(object, projection)
16+
hash = normalize_polymorphic_types(object.class, hash)
1617

1718
serialize_associations(object, projection, hash, path) if projection
1819

@@ -65,15 +66,51 @@ def hash_joined_relation(projection, relation_path)
6566

6667
hash = {}
6768
projection.columns.each { |column| hash[column] = object[meta[:columns][column]] }
69+
hash = normalize_polymorphic_types(target_model(relation_path), hash)
6870
projection.relations.each_key do |nested_name|
6971
hash[nested_name] = hash_joined_relation(projection.relations[nested_name], relation_path + [nested_name])
7072
end
7173

7274
hash
7375
end
7476

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+
75100
private
76101

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+
77114
def joined_relation?(relation_path)
78115
!joined_relations.nil? && joined_relations.key?(relation_path.join('.'))
79116
end

packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,13 @@ def apply_select
229229
def split_relations
230230
join_tree = {}
231231
preload_tree = {}
232-
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
233234

234235
@projection.relations.each do |relation_name, sub_projection|
235-
tables = joinable_tables(@collection, relation_name, sub_projection, used_tables)
236-
if tables
237-
used_tables |= tables
236+
joins = joinable_joins(@collection, relation_name, sub_projection, used_joins)
237+
if joins
238+
used_joins.merge!(joins)
238239
join_tree[relation_name.to_sym] = format_relation_projection(sub_projection)
239240
collect_joined_selects(@collection, relation_name, sub_projection, [relation_name])
240241
else
@@ -275,22 +276,28 @@ def next_join_alias
275276
end
276277

277278
# Set of tables the subtree adds via JOIN, or nil if any relation in it can't be safely joined.
278-
def joinable_tables(collection, relation_name, sub_projection, used_tables)
279-
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)
280281
return nil if target.nil?
281282

282-
tables = Set[target.model.table_name] | through_tables(collection, relation_name)
283+
joins = join_signatures(collection, relation_name)
284+
return nil if joins.nil? || conflicting?(joins, used_joins)
285+
283286
sub_projection.relations.each do |nested_name, nested_projection|
284-
nested = joinable_tables(target, nested_name, nested_projection, used_tables | tables)
287+
nested = joinable_joins(target, nested_name, nested_projection, used_joins.merge(joins))
285288
return nil if nested.nil?
286289

287-
tables |= nested
290+
joins = joins.merge(nested)
288291
end
289-
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 }
290297
end
291298

292299
# The target collection when this hop is safe to collapse into a JOIN, else nil (-> preload).
293-
def joinable_target(collection, relation_name, used_tables)
300+
def joinable_target(collection, relation_name)
294301
relation_schema = collection.schema[:fields][relation_name]
295302
return unless relation_schema.respond_to?(:foreign_collection)
296303

@@ -308,19 +315,29 @@ def joinable_target(collection, relation_name, used_tables)
308315
target = local_ar_collection(collection.datasource, relation_schema.foreign_collection)
309316
return if target.nil? || !target.model.default_scopes.empty? # same risk as a scoped association
310317
return unless same_database?(collection.model, target.model)
311-
return if used_tables.include?(target.model.table_name) # a table joined twice would be aliased by AR
312-
return if through_tables(collection, relation_name).intersect?(used_tables)
313318

314319
target
315320
end
316321

317-
def through_tables(collection, relation_name)
318-
through = collection.model.reflect_on_association(relation_name.to_sym)&.through_reflection
319-
return Set[] unless through
320-
321-
Set[through.table_name]
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
322330
rescue StandardError
323-
Set[]
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
324341
end
325342

326343
def belongs_to_chain_through?(reflection)

packages/forest_admin_datasource_active_record/spec/dummy/app/models/account.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ class Account < ApplicationRecord
22
belongs_to :supplier
33
belongs_to :account_history
44
has_one :order, through: :account_history
5+
belongs_to :secondary_history, class_name: 'AccountHistory', optional: true
6+
belongs_to :note, class_name: 'Api::Note', optional: true
57
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module Api
2+
class Note < ApplicationRecord
3+
# legacy/demodulized polymorphic types: the column stores "Topic", not "Api::Topic"
4+
self.store_full_class_name = false
5+
belongs_to :notable, polymorphic: true, optional: true
6+
end
7+
end
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module Api
2+
class Topic < ApplicationRecord
3+
end
4+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddSecondaryHistoryToAccounts < ActiveRecord::Migration[7.1]
2+
def change
3+
add_column :accounts, :secondary_history_id, :integer, null: true
4+
end
5+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class CreateNotesAndTopics < ActiveRecord::Migration[7.1]
2+
def change
3+
create_table :topics
4+
5+
create_table :notes do |t|
6+
t.string :notable_type
7+
t.integer :notable_id
8+
end
9+
10+
add_column :accounts, :note_id, :integer, null: true
11+
end
12+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
require 'spec_helper'
2+
3+
module ForestAdminDatasourceActiveRecord
4+
include ForestAdminDatasourceToolkit::Components::Query
5+
6+
describe Utils::ActiveRecordSerializer do
7+
subject(:serializer) { described_class.new(Account.new, {}) }
8+
9+
describe '#target_model' do
10+
it 'resolves a belongs_to hop to its target model' do
11+
expect(serializer.target_model(['supplier'])).to eq(Supplier)
12+
end
13+
14+
it 'resolves a has_one :through chain to the final target model' do
15+
expect(serializer.target_model(['order'])).to eq(Order)
16+
end
17+
18+
it 'returns nil when a hop is not an association' do
19+
expect(serializer.target_model(['not_a_relation'])).to be_nil
20+
end
21+
end
22+
23+
# Api::Topic is namespaced but stored demodulized as "Topic" (store_full_class_name = false on
24+
# Api::Note), so polymorphic_class_for("Topic").name == "Api::Topic" -> a real transform.
25+
# Without normalization these assertions would read the raw "Topic".
26+
describe 'polymorphic type normalization', :db_truncation do
27+
let(:datasource) { Datasource.new({ adapter: 'sqlite3', database: 'db/database.db' }) }
28+
let(:note) do
29+
n = Api::Note.create!
30+
n.update_columns(notable_type: 'Topic', notable_id: Api::Topic.create!.id) # legacy demodulized value
31+
n.reload
32+
end
33+
34+
before do
35+
Account.delete_all
36+
Api::Note.delete_all
37+
Api::Topic.delete_all
38+
end
39+
40+
it 'qualifies the stored type on the preloaded (hash_object) path' do
41+
result = described_class.new(note, {}).to_hash(Projection.new(['id', 'notable_type']))
42+
expect(result['notable_type']).to eq('Api::Topic')
43+
end
44+
45+
it 'qualifies the stored type on the JOINed (hash_joined_relation) path' do
46+
account = Account.create!(supplier: Supplier.create!(name: 'ACME'),
47+
account_history: AccountHistory.create!, note: note)
48+
49+
query = Utils::Query.new(Collection.new(datasource, Account), Projection.new(['id', 'note:notable_type']),
50+
Filter.new)
51+
query.build
52+
expect(query.joined_relations).to have_key('note') # proves the JOINed path, not preload
53+
54+
result = Collection.new(datasource, Account).list(nil, Filter.new, Projection.new(['id', 'note:notable_type']))
55+
expect(result.find { |r| r['id'] == account.id }['note']['notable_type']).to eq('Api::Topic')
56+
end
57+
end
58+
end
59+
end

packages/forest_admin_datasource_active_record/spec/lib/forest_admin_datasource_active_record/utils/join_to_one_optimization_spec.rb

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,32 @@ def capture_sql
172172
result = collection.list(caller, Filter.new(condition_tree: condition), projection)
173173
expect(result.first['account_history']['id']).to eq(Account.first.account_history_id)
174174
end
175+
176+
it 'reuses the intermediate join when a plain belongs_to shares the same signature (one query)' do
177+
projection = Projection.new(['id', 'order:reference', 'account_history:id'])
178+
query = Utils::Query.new(collection, projection, filter)
179+
query.build
180+
181+
expect(query.query.to_sql.scan(/JOIN "account_histories"/i).size).to eq(1)
182+
expect(query.query.includes_values).to be_empty
183+
end
184+
185+
it 'preloads a belongs_to that would alias the through intermediate, and serves the right rows' do
186+
secondary = AccountHistory.create! # a row distinct from the account's own account_history
187+
Account.first.update!(secondary_history: secondary)
188+
189+
projection = Projection.new(['id', 'order:reference', 'secondary_history:id'])
190+
query = Utils::Query.new(collection, projection, filter)
191+
query.build
192+
193+
expect(query.query.to_sql.scan(/JOIN "account_histories"/i).size).to eq(1)
194+
expect(query.query.includes_values.to_s).to include('secondary_history')
195+
196+
# a wrong merge would collapse the two account_histories joins and serve secondary the through row
197+
result = collection.list(caller, filter, projection)
198+
expect(result.first['secondary_history']['id']).to eq(secondary.id)
199+
expect(result.first['order']['reference']).to eq('ORD-1')
200+
end
175201
end
176202

177203
describe 'a has_one :through with a has_one hop (supplier -> account_history) stays on preload' do
@@ -239,22 +265,35 @@ def capture_sql
239265
allow(scoped).to receive(:scope).and_return(-> { where('id > 0') })
240266
allow(Account).to receive(:reflect_on_association).and_return(scoped)
241267

242-
expect(query.send(:joinable_target, collection, 'supplier', Set['accounts'])).to be_nil
268+
expect(query.send(:joinable_target, collection, 'supplier')).to be_nil
243269
end
244270
end
245271

246-
describe 'safety guard: a table already present in the query is not joined again' do
247-
# ActiveRecord would alias a table joined twice; collect_joined_selects cannot reference
248-
# that alias, so such a relation must fall back to preload.
272+
describe 'safety guard: a table already present in the query is not joined with a different signature' do
273+
# ActiveRecord reuses a join with the same ON condition, but aliases one with a different
274+
# condition; collect_joined_selects cannot reference that alias, so a conflicting relation
275+
# must fall back to preload.
249276
let(:collection) { Collection.new(datasource, Account) }
250277
let(:query) { Utils::Query.new(collection, Projection.new(['id', 'supplier:name']), filter) }
251278

252-
it 'returns nil from joinable_tables when the target table is already used' do
253-
joinable = query.send(:joinable_tables, collection, 'supplier', Projection.new(['name']), Set['accounts'])
254-
expect(joinable).to eq(Set['suppliers'])
279+
it 'reuses the join for a matching signature and bails on a conflicting one' do
280+
joinable = query.send(:joinable_joins, collection, 'supplier', Projection.new(['name']), { 'accounts' => :root })
281+
expect(joinable).to eq('suppliers' => 'accounts.supplier_id->suppliers.id')
282+
283+
reused = query.send(:joinable_joins, collection, 'supplier', Projection.new(['name']),
284+
{ 'accounts' => :root, 'suppliers' => 'accounts.supplier_id->suppliers.id' })
285+
expect(reused).to eq('suppliers' => 'accounts.supplier_id->suppliers.id') # same signature -> reused, not aliased
286+
287+
conflicting = query.send(:joinable_joins, collection, 'supplier', Projection.new(['name']),
288+
{ 'accounts' => :root, 'suppliers' => 'account_histories.order_id->suppliers.id' })
289+
expect(conflicting).to be_nil # same target/FK from a different parent -> would alias
290+
end
255291

256-
already_used = query.send(:joinable_tables, collection, 'supplier', Projection.new(['name']), Set['accounts', 'suppliers'])
257-
expect(already_used).to be_nil
292+
it 'scopes a signature by its source table and target join key' do
293+
# the through order hop joins orders FROM account_histories ON orders.id = account_histories.order_id;
294+
# a differing source table OR target :primary_key must yield a differing signature.
295+
sigs = query.send(:join_signatures, collection, 'order')
296+
expect(sigs['orders']).to eq('account_histories.order_id->orders.id')
258297
end
259298
end
260299

@@ -317,10 +356,10 @@ def capture_sql
317356
expect(query.send(:local_ar_collection, foreign_ds, 'Supplier')).to be_nil
318357
end
319358

320-
it 'joinable_tables is nil when the target cannot be resolved locally' do
359+
it 'joinable_joins is nil when the target cannot be resolved locally' do
321360
allow(query).to receive(:local_ar_collection).and_return(nil)
322361
expect(collection.schema[:fields]['supplier'].type).to eq('ManyToOne') # joinable but for the stub
323-
expect(query.send(:joinable_tables, collection, 'supplier', Projection.new([]), Set['accounts'])).to be_nil
362+
expect(query.send(:joinable_joins, collection, 'supplier', Projection.new([]), { 'accounts' => :root })).to be_nil
324363
end
325364
end
326365
end

0 commit comments

Comments
 (0)