From a5bd5dcbb5592063ac88642814765278badd2a13 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 9183cf9cbcdb8fa474d4faacbb13fdf9f23cd498 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 9a3f665ac897b5dda27e8929f572d5b70466f8fd 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..26f0384 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 + ids.flat_map { |id| klass.find_by_id(id) }.compact + else + super() + end + end + else + 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() + 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