Skip to content

Commit

Permalink
Integrate with Active Model Attributes
Browse files Browse the repository at this point in the history
The `schema { ... }` interface pre-dates the Active Model Attributes API
(defined as early as [v5.2.0][]), but clearly draws inspiration from
Active Record's Database Schema and Attribute casting (which was
extracted into `ActiveModel::Attributes`).

However, the type information captured in `schema { ... }` blocks or
assigned as `Hash` arguments to `schema=` is purely inert metadata.

Proposal
---

This commit aims to integrate with [ActiveModel::Model][] and
[ActiveModel::Attributes][]. Through the introduction of both modules,
subclasses of `ActiveResource::Schema` can benefit from type casting
attributes and constructing instances with default values.

This commit makes minimally incremental changes, prioritizing backwards
compatibility. The reliance on `#respond_to_missing?` and
`#method_missing` is left largely unchanged. Similarly, the `Schema`
interface continues to provide metadata about its attributes through the
`Schema#attr` method (instead of reading from
`ActiveModel::Attributes#attribute_names` or
`ActiveModel::Attributes.attribute_types`).

API Changes
---

To cast values to their specified types, declare the Schema with the
`:cast_values` set to true.

```ruby
class Person < ActiveResource::Base
  schema cast_values: true do
    integer 'age'
  end
end

p = Person.new
p.age = "18"
p.age # => 18
```

To configure inheriting resources to cast values, set the `cast_values`
class attribute:

```ruby
class ApplicationResource < ActiveResource::Base
  self.cast_values = true
end

class Person < ApplicationResource
  schema do
    integer 'age'
  end
end

p = Person.new
p.age = "18"
p.age # => 18
```

To set all resources application-wide to cast values, set
`config.active_resource.cast_values`:

```ruby
  # config/application.rb
  config.active_resource.cast_values = true
```

[v5.2.0]: https://api.rubyonrails.org/v5.2.0/classes/ActiveModel/Attributes/ClassMethods.html
[ActiveModel::Model]: https://api.rubyonrails.org/classes/ActiveModel/Model.html
[ActiveModel::Attributes]: https://api.rubyonrails.org/classes/ActiveModel/Attributes/ClassMethods.html
  • Loading branch information
seanpdoyle committed Jan 27, 2025
1 parent 9c8a2ee commit 5e5344a
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 45 deletions.
90 changes: 76 additions & 14 deletions lib/active_resource/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,12 @@ def self.logger=(logger)
class_attribute :connection_class
self.connection_class = Connection

class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc:
self.cast_values = false

class_attribute :schema_definition, instance_accessor: false, instance_predicate: false # :nodoc:
self.schema_definition = Schema

class << self
include ThreadsafeAttributes
threadsafe_attribute :_headers, :_connection, :_user, :_password, :_bearer_token, :_site, :_proxy
Expand Down Expand Up @@ -385,16 +391,49 @@ class << self
#
# Attribute-types must be one of: <tt>string, text, integer, float, decimal, datetime, timestamp, time, date, binary, boolean</tt>
#
# Note: at present the attribute-type doesn't do anything, but stay
# tuned...
# Shortly it will also *cast* the value of the returned attribute.
# ie:
# j.age # => 34 # cast to an integer
# j.weight # => '65' # still a string!
# Note: By default, the attribute-type is ignored and will not cast its
# value.
#
# To cast values to their specified types, declare the Schema with the
# +:cast_values+ set to true.
#
# class Person < ActiveResource::Base
# schema cast_values: true do
# integer 'age'
# end
# end
#
# p = Person.new
# p.age = "18"
# p.age # => 18
#
# To configure inheriting resources to cast values, set the +cast_values+
# class attribute:
#
# class ApplicationResource < ActiveResource::Base
# self.cast_values = true
# end
#
# class Person < ApplicationResource
# schema do
# integer 'age'
# end
# end
#
# p = Person.new
# p.age = "18"
# p.age # => 18
#
# To set all resources application-wide to cast values, set
# +config.active_resource.cast_values+:
#
# # config/application.rb
# config.active_resource.cast_values = true
#
def schema(&block)
def schema(cast_values: self.cast_values, &block)
if block_given?
schema_definition = Schema.new
self.schema_definition = Class.new(Schema)
schema_definition.cast_values = cast_values
schema_definition.instance_eval(&block)

# skip out if we didn't define anything
Expand Down Expand Up @@ -434,6 +473,7 @@ def schema(&block)
def schema=(the_schema)
unless the_schema.present?
# purposefully nulling out the schema
self.schema_definition = Schema
@schema = nil
@known_attributes = []
return
Expand Down Expand Up @@ -1213,6 +1253,7 @@ def known_attributes
def initialize(attributes = {}, persisted = false)
@attributes = {}.with_indifferent_access
@prefix_options = {}
@schema = self.class.schema_definition.new
@persisted = persisted
load(attributes, false, persisted)
end
Expand Down Expand Up @@ -1246,6 +1287,7 @@ def clone
resource = self.class.new({})
resource.prefix_options = self.prefix_options
resource.send :instance_variable_set, "@attributes", cloned
resource.send :instance_variable_set, "@schema", @schema.clone
resource
end

Expand Down Expand Up @@ -1285,12 +1327,12 @@ def persisted?

# Gets the <tt>\id</tt> attribute of the resource.
def id
attributes[self.class.primary_key]
_read_attribute(self.class.primary_key)
end

# Sets the <tt>\id</tt> attribute of the resource.
def id=(id)
attributes[self.class.primary_key] = id
_write_attribute(self.class.primary_key, id)
end

# Test for equality. Resource are equal if and only if +other+ is the same object or
Expand Down Expand Up @@ -1481,7 +1523,7 @@ def load(attributes, remove_root = false, persisted = false)
attributes = Formats.remove_root(attributes) if remove_root

attributes.each do |key, value|
@attributes[key.to_s] =
_write_attribute(key,
case value
when Array
resource = nil
Expand All @@ -1498,7 +1540,7 @@ def load(attributes, remove_root = false, persisted = false)
resource.new(value, persisted)
else
value.duplicable? ? value.dup : value
end
end)
end
self
end
Expand Down Expand Up @@ -1541,7 +1583,9 @@ def update_attributes(attributes)
# <tt>my_person.respond_to?(:name?)</tt>.
def respond_to_missing?(method, include_priv = false)
method_name = method.to_s
if attributes.nil?
if @schema.respond_to?(method)
true
elsif attributes.nil?
super
elsif known_attributes.include?(method_name)
true
Expand Down Expand Up @@ -1698,10 +1742,28 @@ def split_options(options = {})
self.class.__send__(:split_options, options)
end

def _read_attribute(name)
if @schema.respond_to?(name)
@schema.send(name)
else
attributes[name.to_s]
end
end

def _write_attribute(name, value)
if @schema.respond_to?("#{name}=")
@schema.send("#{name}=", value)
else
attributes[name.to_s] = value
end
end

def method_missing(method_symbol, *arguments) # :nodoc:
method_name = method_symbol.to_s

if method_name =~ /(=|\?)$/
if @schema.respond_to?(method_name)
@schema.send(method_name, *arguments)
elsif method_name =~ /(=|\?)$/
case $1
when "="
attributes[$`] = arguments.first
Expand Down
89 changes: 58 additions & 31 deletions lib/active_resource/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@

module ActiveResource # :nodoc:
class Schema # :nodoc:
include ActiveModel::Model
include ActiveModel::Attributes

# attributes can be known to be one of these types. They are easy to
# cast to/from.
KNOWN_ATTRIBUTE_TYPES = %w( string text integer float decimal datetime timestamp time date binary boolean )

# An array of attribute definitions, representing the attributes that
# have been defined.
attr_accessor :attrs
class_attribute :attrs, instance_accessor: false, instance_predicate: false # :nodoc:
self.attrs = {}

class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc:
self.cast_values = false

##
# :method: initialize
#
# The internals of an Active Resource Schema are very simple -
# unlike an Active Record TableDefinition (on which it is based).
# It provides a set of convenience methods for people to define their
Expand All @@ -22,39 +32,56 @@ class Schema # :nodoc:
# The schema stores the name and type of each attribute. That is then
# read out by the schema method to populate the schema of the actual
# resource.
def initialize
@attrs = {}
end

def attribute(name, type, options = {})
raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || Schema::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s)

the_type = type.to_s
# TODO: add defaults
# the_attr = [type.to_s]
# the_attr << options[:default] if options.has_key? :default
@attrs[name.to_s] = the_type
self
end
class << self
def inherited(subclass)
super
subclass.attrs = attrs.dup
end

# The following are the attribute types supported by Active Resource
# migrations.
KNOWN_ATTRIBUTE_TYPES.each do |attr_type|
# def string(*args)
# options = args.extract_options!
# attr_names = args
# The internals of an Active Resource Schema are very simple -
# unlike an Active Record TableDefinition (on which it is based).
# It provides a set of convenience methods for people to define their
# schema using the syntax:
# schema do
# string :foo
# integer :bar
# end
#
# attr_names.each { |name| attribute(name, 'string', options) }
# end
class_eval <<-EOV, __FILE__, __LINE__ + 1
# frozen_string_literal: true
def #{attr_type}(*args)
options = args.extract_options!
attr_names = args
attr_names.each { |name| attribute(name, '#{attr_type}', options) }
end
EOV
# The schema stores the name and type of each attribute. That is then
# read out by the schema method to populate the schema of the actual
# resource.
def attribute(name, type, **options)
raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || Schema::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s)

the_type = type.to_s
attrs[name.to_s] = the_type

type = cast_values ? type.to_sym : nil

super
self
end

# The following are the attribute types supported by Active Resource
# migrations.
KNOWN_ATTRIBUTE_TYPES.each do |attr_type|
# def string(*args)
# options = args.extract_options!
# attr_names = args
#
# attr_names.each { |name| attribute(name, 'string', options) }
# end
class_eval <<-EOV, __FILE__, __LINE__ + 1
# frozen_string_literal: true
def #{attr_type}(*args)
options = args.extract_options!
attr_names = args
attr_names.each { |name| attribute(name, :#{attr_type}, **options) }
end
EOV
end
end
end
end
51 changes: 51 additions & 0 deletions test/cases/base/schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def setup

def teardown
Person.schema = nil # hack to stop test bleedthrough...
Person.cast_values = false # hack to stop test bleedthrough...
end


Expand Down Expand Up @@ -425,4 +426,54 @@ def teardown
Person.schema = new_schema
assert_equal Person.new(age: 20, name: "Matz").known_attributes, ["age", "name"]
end

test "clone with schema that casts values" do
Person.cast_values = true
Person.schema = { "age" => "integer" }
person = Person.new({ Person.primary_key => 1, "age" => "10" }, true)

person_c = person.clone

assert_predicate person_c, :new?
assert_nil person_c.send(Person.primary_key)
assert_equal 10, person_c.age
end

test "known primary_key attributes should be cast" do
Person.schema cast_values: true do
attribute Person.primary_key, :integer
end

person = Person.new(Person.primary_key => "1")

assert_equal 1, person.send(Person.primary_key)
end

test "known attributes should be cast" do
Person.schema cast_values: true do
attribute :born_on, :date
end

person = Person.new(born_on: "2000-01-01")

assert_equal Date.new(2000, 1, 1), person.born_on
end

test "known attributes should be support default values" do
Person.schema cast_values: true do
attribute :name, :string, default: "Default Name"
end

person = Person.new

assert_equal "Default Name", person.name
end

test "unknown attributes should not be cast" do
Person.cast_values = true

person = Person.new(age: "10")

assert_equal "10", person.age
end
end

0 comments on commit 5e5344a

Please sign in to comment.