From adade0ab87c1af73468c50dd59d47e48504b9adb Mon Sep 17 00:00:00 2001 From: Keenan Brock Date: Wed, 13 Aug 2025 17:27:43 -0400 Subject: [PATCH 1/3] Silence spec duplicate const warnings We undefine these classes in the after {} So in theory, they should not be a duplicate. rspec often uses stub_const, but active record has issues with this since they dig into the class.name and stuff --- spec/associations/active_record_extensions_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/associations/active_record_extensions_spec.rb b/spec/associations/active_record_extensions_spec.rb index 50b6fb9..e5a95e1 100644 --- a/spec/associations/active_record_extensions_spec.rb +++ b/spec/associations/active_record_extensions_spec.rb @@ -5,7 +5,7 @@ def define_ephemeral_class(name, superclass, &block) klass = Class.new(superclass) - Object.const_set(name, klass) + Kernel::silence_warnings { Object.const_set(name, klass) } klass.class_eval(&block) if block_given? @ephemeral_classes << name end From 487de4e6b27d4b590da3f55ee2a9fc3f9d944099 Mon Sep 17 00:00:00 2001 From: Keenan Brock Date: Mon, 6 Apr 2026 22:20:38 -0400 Subject: [PATCH 2/3] Defer class resolution to runtime for has_many :through and belongs_to Move class resolution out of definition time and into method bodies. Call super first so AR registers reflections (preserving eager loading support), then override accessors with runtime ActiveHash checks. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/associations/associations.rb | 44 ++++++++++++------- .../active_record_extensions_spec.rb | 2 +- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/lib/associations/associations.rb b/lib/associations/associations.rb index 3ffd77d..09c0583 100644 --- a/lib/associations/associations.rb +++ b/lib/associations/associations.rb @@ -7,36 +7,48 @@ def self.extended(base) end def has_many(association_id, scope = nil, **options, &extension) + super + if options[:through] source_association_name = options[:source]&.to_s || association_id.to_s.singularize - through_klass = reflect_on_association(options[:through])&.klass - klass = through_klass&.reflect_on_association(source_association_name)&.klass + define_method(association_id) do + through_klass = self.class.reflect_on_association(options[:through])&.klass + source_klass = through_klass&.reflect_on_association(source_association_name)&.class_name&.safe_constantize - if klass && klass < ActiveHash::Base - define_method(association_id) do - join_models = send(options[:through]) - join_models.flat_map do |join_model| + if source_klass && source_klass < ActiveHash::Base + send(options[:through]).flat_map do |join_model| join_model.send(source_association_name) end.uniq + else + super() end - - return end end - - super end def belongs_to(name, scope = nil, **options) klass_name = options.key?(:class_name) ? options[:class_name] : name.to_s.camelize - klass = klass_name.safe_constantize + foreign_key = options[:foreign_key] || name.to_s.foreign_key - if klass && klass < ActiveHash::Base - options = { class_name: klass_name }.merge(options) - belongs_to_active_hash(name, options) - else - super + super + + define_method(name) do + klass = klass_name.safe_constantize + if klass && klass < ActiveHash::Base + klass.send("find_by_#{klass.primary_key}", send(foreign_key)) + else + super() + end + end + + define_method("#{name}=") do |new_value| + klass = klass_name.safe_constantize + if klass && klass < ActiveHash::Base + send("#{foreign_key}=", new_value ? new_value.send(klass.primary_key) : nil) + else + super(new_value) + end end end diff --git a/spec/associations/active_record_extensions_spec.rb b/spec/associations/active_record_extensions_spec.rb index e5a95e1..50b6fb9 100644 --- a/spec/associations/active_record_extensions_spec.rb +++ b/spec/associations/active_record_extensions_spec.rb @@ -5,7 +5,7 @@ def define_ephemeral_class(name, superclass, &block) klass = Class.new(superclass) - Kernel::silence_warnings { Object.const_set(name, klass) } + Object.const_set(name, klass) klass.class_eval(&block) if block_given? @ephemeral_classes << name end From 3591e76fb363751eaa3d6594a1d7113b4d13cd28 Mon Sep 17 00:00:00 2001 From: Keenan Brock Date: Tue, 7 Apr 2026 01:01:29 -0400 Subject: [PATCH 3/3] Handle polymorphic has_many :through with source_type When source_type is present, resolve the class at runtime and look up by foreign key directly, bypassing AR's polymorphic belongs_to which does not work with ActiveHash. Fixes #334. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/associations/associations.rb | 35 ++++++++---- .../active_record_extensions_spec.rb | 57 +++++++++++++++++++ 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/lib/associations/associations.rb b/lib/associations/associations.rb index 09c0583..8e279d5 100644 --- a/lib/associations/associations.rb +++ b/lib/associations/associations.rb @@ -12,16 +12,31 @@ def has_many(association_id, scope = nil, **options, &extension) if options[:through] source_association_name = options[:source]&.to_s || association_id.to_s.singularize - define_method(association_id) do - through_klass = self.class.reflect_on_association(options[:through])&.klass - source_klass = through_klass&.reflect_on_association(source_association_name)&.class_name&.safe_constantize - - if source_klass && source_klass < ActiveHash::Base - send(options[:through]).flat_map do |join_model| - join_model.send(source_association_name) - end.uniq - else - super() + if options[:source_type] + source_type = options[:source_type] + source_foreign_key = "#{source_association_name}_id" + + define_method(association_id) do + klass = source_type.safe_constantize + if klass < ActiveHash::Base + ids = send(options[:through]).map { |jm| jm.send(source_foreign_key) }.compact.uniq + klass.where(id: ids) + else + super() + end + end + else + define_method(association_id) do + through_klass = self.class.reflect_on_association(options[:through])&.klass + reflection = through_klass&.reflect_on_association(source_association_name) + source_klass = reflection&.class_name&.safe_constantize + + if source_klass && source_klass < ActiveHash::Base + ids = send(options[:through]).map { |jm| jm.send(reflection.foreign_key) }.compact.uniq + source_klass.where(source_klass.primary_key => ids) + else + super() + end end end end diff --git a/spec/associations/active_record_extensions_spec.rb b/spec/associations/active_record_extensions_spec.rb index 50b6fb9..d124357 100644 --- a/spec/associations/active_record_extensions_spec.rb +++ b/spec/associations/active_record_extensions_spec.rb @@ -124,6 +124,47 @@ def define_doctor_classes end + # Physician(AH) <-- Appointment(AR) --> Patient(AR) + # polymorphic: providerable on Appointment, source_type targets ActiveHash model + def define_polymorphic_doctor_classes + define_ephemeral_class(:Physician, ActiveHash::Base) do + include ActiveHash::Associations + + self.data = [ + {:id => 1, :name => "ikeda"}, + {:id => 2, :name => "sato"} + ] + end + + define_ephemeral_class(:Appointment, ActiveRecord::Base) do + establish_connection :adapter => "sqlite3", :database => ":memory:" + connection.create_table :appointments, force: true do |t| + t.references :providerable, polymorphic: true + t.references :patient + end + + extend ActiveHash::Associations::ActiveRecordExtensions + + # AR belongs_to (polymorphic) + belongs_to :providerable, polymorphic: true + # AR belongs_to + belongs_to :patient + end + + define_ephemeral_class(:Patient, ActiveRecord::Base) do + establish_connection :adapter => "sqlite3", :database => ":memory:" + connection.create_table :patients, force: true do |t| + end + + extend ActiveHash::Associations::ActiveRecordExtensions + + # AR has_many + has_many :appointments + # AR has_many :through (source_type points to ActiveHash model) + has_many :physicians, through: :appointments, source: :providerable, source_type: "Physician" + end + end + before do @ephemeral_classes = [] end @@ -286,6 +327,22 @@ def define_doctor_classes end end + describe ":through with a polymorphic source and source_type" do + before { define_polymorphic_doctor_classes } + + it "does not raise when defining the association" do + expect(Patient.instance_method(:physicians)).to be_a(UnboundMethod) + end + + it "returns the correct ActiveHash records" do + physician = Physician.find(1) + patient = Patient.create! + Appointment.create!(providerable_type: "Physician", providerable_id: physician.id, patient_id: patient.id) + + expect(patient.physicians).to contain_exactly(physician) + end + end + describe "with a lambda" do before do define_person_classes