-
Notifications
You must be signed in to change notification settings - Fork 1
fix(datasource-active-record): normalize demodulized polymorphic types & reuse shared joins #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
14ef31b
bfcff93
e100c13
4e5d8a0
c9caa98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ def hash_object(object, projection = nil, path: []) | |
| # root keeps all its selected columns (attributes + FKs); a related record is restricted to | ||
| # its projected columns, matching the JOINed hydration | ||
| hash = path.empty? || projection.nil? ? base_attributes(object) : projected_columns(object, projection) | ||
| hash = normalize_polymorphic_types(object.class, hash) | ||
|
|
||
| serialize_associations(object, projection, hash, path) if projection | ||
|
|
||
|
|
@@ -65,15 +66,51 @@ def hash_joined_relation(projection, relation_path) | |
|
|
||
| hash = {} | ||
| projection.columns.each { |column| hash[column] = object[meta[:columns][column]] } | ||
| hash = normalize_polymorphic_types(target_model(relation_path), hash) | ||
| projection.relations.each_key do |nested_name| | ||
| hash[nested_name] = hash_joined_relation(projection.relations[nested_name], relation_path + [nested_name]) | ||
| end | ||
|
|
||
| hash | ||
| end | ||
|
|
||
| def normalize_polymorphic_types(model_class, hash) | ||
| return hash if model_class.nil? | ||
|
|
||
| polymorphic_belongs_to(model_class).each do |association| | ||
| stored = hash[association.foreign_type] | ||
| next if stored.nil? | ||
|
|
||
| hash = hash.merge(association.foreign_type => model_class.polymorphic_class_for(stored).name) | ||
| rescue NameError => e | ||
| warn_unable(association.name, model_class, e) | ||
| end | ||
| hash | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| # Target model of a JOINed relation path (only belongs_to / has_one :through are ever JOINed). | ||
| def target_model(relation_path) | ||
| relation_path.reduce(object.class) do |model, name| | ||
| model&.reflect_on_association(name.to_sym)&.klass | ||
| end | ||
| rescue NameError | ||
| nil | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def polymorphic_belongs_to(model_class) | ||
| (@polymorphic_belongs_to ||= {})[model_class] ||= | ||
| model_class.reflect_on_all_associations(:belongs_to).select(&:polymorphic?) | ||
| end | ||
|
|
||
| def warn_unable(name, model_class, error) | ||
| ActiveSupport::Logger.new($stdout).warn( | ||
| "[ForestAdmin] Unable to normalize polymorphic type of '#{name}' " \ | ||
| "in model '#{model_class.name}': #{error.message}. Keeping the stored value." | ||
| ) | ||
| end | ||
|
|
||
| def joined_relation?(relation_path) | ||
| !joined_relations.nil? && joined_relations.key?(relation_path.join('.')) | ||
| end | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -229,12 +229,13 @@ def apply_select | |
| def split_relations | ||
| join_tree = {} | ||
| preload_tree = {} | ||
| used_tables = Set[@collection.model.table_name] | @filter_joined_tables | ||
| used_joins = { @collection.model.table_name => :root } | ||
| @filter_joined_tables.each { |table| used_joins[table] ||= :filter } # never reuse a filter/sort join | ||
|
|
||
| @projection.relations.each do |relation_name, sub_projection| | ||
| tables = joinable_tables(@collection, relation_name, sub_projection, used_tables) | ||
| if tables | ||
| used_tables |= tables | ||
| joins = joinable_joins(@collection, relation_name, sub_projection, used_joins) | ||
| if joins | ||
| used_joins.merge!(joins) | ||
| join_tree[relation_name.to_sym] = format_relation_projection(sub_projection) | ||
| collect_joined_selects(@collection, relation_name, sub_projection, [relation_name]) | ||
| else | ||
|
|
@@ -275,22 +276,28 @@ def next_join_alias | |
| end | ||
|
|
||
| # Set of tables the subtree adds via JOIN, or nil if any relation in it can't be safely joined. | ||
| def joinable_tables(collection, relation_name, sub_projection, used_tables) | ||
| target = joinable_target(collection, relation_name, used_tables) | ||
| def joinable_joins(collection, relation_name, sub_projection, used_joins) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| target = joinable_target(collection, relation_name) | ||
| return nil if target.nil? | ||
|
|
||
| tables = Set[target.model.table_name] | through_tables(collection, relation_name) | ||
| joins = join_signatures(collection, relation_name) | ||
| return nil if joins.nil? || conflicting?(joins, used_joins) | ||
|
|
||
| sub_projection.relations.each do |nested_name, nested_projection| | ||
| nested = joinable_tables(target, nested_name, nested_projection, used_tables | tables) | ||
| nested = joinable_joins(target, nested_name, nested_projection, used_joins.merge(joins)) | ||
| return nil if nested.nil? | ||
|
|
||
| tables |= nested | ||
| joins = joins.merge(nested) | ||
| end | ||
| tables | ||
| joins | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def conflicting?(new_joins, used_joins) | ||
| new_joins.any? { |table, signature| used_joins.key?(table) && used_joins[table] != signature } | ||
| end | ||
|
|
||
| # The target collection when this hop is safe to collapse into a JOIN, else nil (-> preload). | ||
| def joinable_target(collection, relation_name, used_tables) | ||
| def joinable_target(collection, relation_name) | ||
| relation_schema = collection.schema[:fields][relation_name] | ||
| return unless relation_schema.respond_to?(:foreign_collection) | ||
|
|
||
|
|
@@ -308,19 +315,29 @@ def joinable_target(collection, relation_name, used_tables) | |
| target = local_ar_collection(collection.datasource, relation_schema.foreign_collection) | ||
| return if target.nil? || !target.model.default_scopes.empty? # same risk as a scoped association | ||
| return unless same_database?(collection.model, target.model) | ||
| return if used_tables.include?(target.model.table_name) # a table joined twice would be aliased by AR | ||
| return if through_tables(collection, relation_name).intersect?(used_tables) | ||
|
|
||
| target | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def through_tables(collection, relation_name) | ||
| through = collection.model.reflect_on_association(relation_name.to_sym)&.through_reflection | ||
| return Set[] unless through | ||
|
|
||
| Set[through.table_name] | ||
| def join_signatures(collection, relation_name) | ||
| reflection = collection.model.reflect_on_association(relation_name.to_sym) | ||
| if reflection.through_reflection? | ||
| { reflection.through_reflection.klass.table_name => signature(reflection.through_reflection), | ||
| reflection.klass.table_name => signature(reflection.source_reflection) } | ||
| else | ||
| { reflection.klass.table_name => signature(reflection) } | ||
| end | ||
| rescue StandardError | ||
| Set[] | ||
| nil | ||
| end | ||
|
|
||
| def signature(reflection) | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| "#{reflection.active_record.table_name}.#{Array(reflection.foreign_key).join(",")}" \ | ||
| "->#{reflection.klass.table_name}.#{Array(join_key(reflection)).join(",")}" | ||
| end | ||
|
|
||
| def join_key(reflection) | ||
| reflection.respond_to?(:join_primary_key) ? reflection.join_primary_key : reflection.association_primary_key | ||
| end | ||
|
|
||
| def belongs_to_chain_through?(reflection) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| module Api | ||
| class Note < ApplicationRecord | ||
| # legacy/demodulized polymorphic types: the column stores "Topic", not "Api::Topic" | ||
| self.store_full_class_name = false | ||
| belongs_to :notable, polymorphic: true, optional: true | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| module Api | ||
| class Topic < ApplicationRecord | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| class AddSecondaryHistoryToAccounts < ActiveRecord::Migration[7.1] | ||
| def change | ||
| add_column :accounts, :secondary_history_id, :integer, null: true | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| class CreateNotesAndTopics < ActiveRecord::Migration[7.1] | ||
| def change | ||
| create_table :topics | ||
|
|
||
| create_table :notes do |t| | ||
| t.string :notable_type | ||
| t.integer :notable_id | ||
| end | ||
|
|
||
| add_column :accounts, :note_id, :integer, null: true | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| require 'spec_helper' | ||
|
|
||
| module ForestAdminDatasourceActiveRecord | ||
| include ForestAdminDatasourceToolkit::Components::Query | ||
|
|
||
| describe Utils::ActiveRecordSerializer do | ||
| subject(:serializer) { described_class.new(Account.new, {}) } | ||
|
|
||
| describe '#target_model' do | ||
| it 'resolves a belongs_to hop to its target model' do | ||
| expect(serializer.target_model(['supplier'])).to eq(Supplier) | ||
| end | ||
|
|
||
| it 'resolves a has_one :through chain to the final target model' do | ||
| expect(serializer.target_model(['order'])).to eq(Order) | ||
| end | ||
|
|
||
| it 'returns nil when a hop is not an association' do | ||
| expect(serializer.target_model(['not_a_relation'])).to be_nil | ||
| end | ||
| end | ||
|
|
||
| # Api::Topic is namespaced but stored demodulized as "Topic" (store_full_class_name = false on | ||
| # Api::Note), so polymorphic_class_for("Topic").name == "Api::Topic" -> a real transform. | ||
| # Without normalization these assertions would read the raw "Topic". | ||
| describe 'polymorphic type normalization', :db_truncation do | ||
| let(:datasource) { Datasource.new({ adapter: 'sqlite3', database: 'db/database.db' }) } | ||
| let(:note) do | ||
| n = Api::Note.create! | ||
| n.update_columns(notable_type: 'Topic', notable_id: Api::Topic.create!.id) # legacy demodulized value | ||
| n.reload | ||
| end | ||
|
|
||
| before do | ||
| Account.delete_all | ||
| Api::Note.delete_all | ||
| Api::Topic.delete_all | ||
| end | ||
|
|
||
| it 'qualifies the stored type on the preloaded (hash_object) path' do | ||
| result = described_class.new(note, {}).to_hash(Projection.new(['id', 'notable_type'])) | ||
| expect(result['notable_type']).to eq('Api::Topic') | ||
| end | ||
|
|
||
| it 'qualifies the stored type on the JOINed (hash_joined_relation) path' do | ||
| account = Account.create!(supplier: Supplier.create!(name: 'ACME'), | ||
| account_history: AccountHistory.create!, note: note) | ||
|
|
||
| query = Utils::Query.new(Collection.new(datasource, Account), Projection.new(['id', 'note:notable_type']), | ||
| Filter.new) | ||
| query.build | ||
| expect(query.joined_relations).to have_key('note') # proves the JOINed path, not preload | ||
|
|
||
| result = Collection.new(datasource, Account).list(nil, Filter.new, Projection.new(['id', 'note:notable_type'])) | ||
| expect(result.find { |r| r['id'] == account.id }['note']['notable_type']).to eq('Api::Topic') | ||
| end | ||
| end | ||
| end | ||
| end |
Uh oh!
There was an error while loading. Please reload this page.