From 0d99c0e68a0978aa45d9f19b023daa0088a7257f Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sun, 30 Nov 2025 19:40:47 -0500 Subject: [PATCH] Mix `ActiveModel::AttributeMethods` into `ActiveResource::Base` The problem --- The current attribute reading and writing implementation relies upon `#method_missing` and `#respond_to_missing?` for access. When attributes are defined by a schema, it's possible to define methods for them at "declaration time", rather than implementing access entirely through method missing reflection. The solution --- Mix [ActiveModel::AttributeMethods][] into `ActiveResource::Base`, then hook into `schema` assignment. When assigning attributes, invoke [define_attribute_methods][]. When the schema is reset to be `nil`, invoke [undefine_attribute_methods][]. Similarly, invoke `define_attribute_methods` on an instance's `singleton_class` when writing to an attribute through the `#load` method. Subsequent reads and writes will rely on the ephemeral method (which will invoke `#write_attribute` and `#read_attribute`) rather than `#method_missing`. [ActiveModel::AttributeMethods]: https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods.html [define_attribute_methods]: https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods/ClassMethods.html#method-i-define_attribute_methods [undefine_attribute_methods]: https://edgeapi.rubyonrails.org/classes/ActiveModel/AttributeMethods/ClassMethods.html#method-i-undefine_attribute_methods --- lib/active_resource/base.rb | 7 ++++- test/cases/attribute_methods_test.rb | 44 +++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/active_resource/base.rb b/lib/active_resource/base.rb index 10312bb6f5..d2ec73d054 100644 --- a/lib/active_resource/base.rb +++ b/lib/active_resource/base.rb @@ -463,6 +463,7 @@ def schema(&block) @schema[k] = v @known_attributes << k end + define_attribute_methods @known_attributes @schema else @@ -492,6 +493,7 @@ def schema=(the_schema) # purposefully nulling out the schema @schema = nil @known_attributes = [] + undefine_attribute_methods return end @@ -1735,13 +1737,16 @@ def read_attribute(attr_name) name = self.class.primary_key if name == "id" && self.class.primary_key @attributes[name] end + alias_method :attribute, :read_attribute def write_attribute(attr_name, value) name = attr_name.to_s name = self.class.primary_key if name == "id" && self.class.primary_key + singleton_class.define_attribute_methods(name) unless known_attributes.include?(name) @attributes[name] = value end + alias_method :attribute=, :write_attribute protected def connection(refresh = false) @@ -1904,7 +1909,7 @@ class Base extend ActiveResource::Associations include Callbacks, CustomMethods, Validations, Serialization - include ActiveModel::Conversion + include ActiveModel::Conversion, ActiveModel::AttributeMethods include ActiveModel::ForbiddenAttributesProtection include ActiveModel::Serializers::JSON include ActiveModel::Serializers::Xml diff --git a/test/cases/attribute_methods_test.rb b/test/cases/attribute_methods_test.rb index 8e4483903f..05f7d91594 100644 --- a/test/cases/attribute_methods_test.rb +++ b/test/cases/attribute_methods_test.rb @@ -4,7 +4,49 @@ require "fixtures/person" class AttributeMethodsTest < ActiveSupport::TestCase - setup :setup_response + setup do + setup_response + @previous_schema = Person.schema + end + + teardown do + Person.schema = nil + Person.schema = @previous_schema + end + + test "setting the schema defines attribute methods" do + assert_changes -> { Person.public_instance_methods.include?(:name) }, from: false, to: true do + Person.schema { attribute :name, :string } + end + end + + test "setting the schema to nil undefines attribute methods" do + Person.schema { attribute :name, :string } + + assert_changes -> { Person.public_instance_methods.include?(:name) }, from: true, to: false do + Person.schema = nil + end + end + + test "assigning an attribute during #load defines attribute methods for the instance" do + resource = Person.new + + assert_changes -> { resource.public_methods.include?(:name) }, from: false, to: true do + resource.load name: "changed" + end + + assert_not_includes Person.public_instance_methods, :name + end + + test "reads and writes attribute methods declared by the schema without method missing" do + Person.schema { attribute :name, :string } + + resource = Person.new + + assert_changes -> { resource.name }, from: nil, to: "changed" do + resource.name = "changed" + end + end test "write_attribute string" do matz = Person.find(1)