Skip to content

Commit 6c5b5fa

Browse files
committed
Integrate with Active Model Attributes
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
1 parent d821ec5 commit 6c5b5fa

File tree

4 files changed

+291
-43
lines changed

4 files changed

+291
-43
lines changed

lib/active_resource/base.rb

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,12 @@ def self.logger=(logger)
381381
class_attribute :connection_class
382382
self.connection_class = Connection
383383

384+
class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc:
385+
self.cast_values = false
386+
387+
class_attribute :schema_definition, instance_accessor: false, instance_predicate: false # :nodoc:
388+
self.schema_definition = Schema
389+
384390
class << self
385391
include ThreadsafeAttributes
386392
threadsafe_attribute :_headers, :_connection, :_user, :_password, :_bearer_token, :_site, :_proxy
@@ -441,16 +447,48 @@ def new_lazy_collections=(value)
441447
#
442448
# Attribute-types must be one of: <tt>string, text, integer, float, decimal, datetime, timestamp, time, date, binary, boolean</tt>
443449
#
444-
# Note: at present the attribute-type doesn't do anything, but stay
445-
# tuned...
446-
# Shortly it will also *cast* the value of the returned attribute.
447-
# ie:
448-
# j.age # => 34 # cast to an integer
449-
# j.weight # => '65' # still a string!
450+
# Note: By default, the attribute-type is ignored and will not cast its
451+
# value.
452+
#
453+
# To cast values to their specified types, declare the Schema with the
454+
# +:cast_values+ set to true.
455+
#
456+
# class Person < ActiveResource::Base
457+
# schema cast_values: true do
458+
# integer 'age'
459+
# end
460+
# end
461+
#
462+
# p = Person.new
463+
# p.age = "18"
464+
# p.age # => 18
465+
#
466+
# To configure inheriting resources to cast values, set the +cast_values+
467+
# class attribute:
450468
#
451-
def schema(&block)
469+
# class ApplicationResource < ActiveResource::Base
470+
# self.cast_values = true
471+
# end
472+
#
473+
# class Person < ApplicationResource
474+
# schema do
475+
# integer 'age'
476+
# end
477+
# end
478+
#
479+
# p = Person.new
480+
# p.age = "18"
481+
# p.age # => 18
482+
#
483+
# To set all resources application-wide to cast values, set
484+
# +config.active_resource.cast_values+:
485+
#
486+
# # config/application.rb
487+
# config.active_resource.cast_values = true
488+
def schema(cast_values: self.cast_values, &block)
452489
if block_given?
453-
schema_definition = Schema.new
490+
self.schema_definition = Class.new(schema_definition)
491+
schema_definition.cast_values = cast_values
454492
schema_definition.instance_eval(&block)
455493

456494
# skip out if we didn't define anything
@@ -490,6 +528,7 @@ def schema(&block)
490528
def schema=(the_schema)
491529
unless the_schema.present?
492530
# purposefully nulling out the schema
531+
self.schema_definition = Schema
493532
@schema = nil
494533
@known_attributes = []
495534
return
@@ -1336,6 +1375,7 @@ def known_attributes
13361375
def initialize(attributes = {}, persisted = false)
13371376
@attributes = {}.with_indifferent_access
13381377
@prefix_options = {}
1378+
@schema = self.class.schema_definition.new
13391379
@persisted = persisted
13401380
load(attributes, false, persisted)
13411381
end
@@ -1369,6 +1409,7 @@ def clone
13691409
resource = self.class.new({})
13701410
resource.prefix_options = self.prefix_options
13711411
resource.send :instance_variable_set, "@attributes", cloned
1412+
resource.send :instance_variable_set, "@schema", @schema.clone
13721413
resource
13731414
end
13741415

@@ -1702,7 +1743,7 @@ def respond_to_missing?(method, include_priv = false)
17021743
method_name = method.to_s
17031744
if attributes.nil?
17041745
super
1705-
elsif known_attributes.include?(method_name)
1746+
elsif known_attributes.include?(method_name) || @schema.respond_to?(method)
17061747
true
17071748
elsif method_name =~ /(?:=|\?)$/ && known_attributes.include?($`)
17081749
true
@@ -1713,6 +1754,10 @@ def respond_to_missing?(method, include_priv = false)
17131754
end
17141755
end
17151756

1757+
def serializable_hash(options = nil)
1758+
@schema.serializable_hash(options).merge!(super)
1759+
end
1760+
17161761
def to_json(options = {})
17171762
super(include_root_in_json ? { root: self.class.element_name }.merge(options) : options)
17181763
end
@@ -1733,14 +1778,22 @@ def read_attribute(attr_name)
17331778
name = attr_name.to_s
17341779

17351780
name = self.class.primary_key if name == "id" && self.class.primary_key
1736-
@attributes[name]
1781+
if @schema.respond_to?(name)
1782+
@schema.send(name)
1783+
else
1784+
@attributes[name]
1785+
end
17371786
end
17381787

17391788
def write_attribute(attr_name, value)
17401789
name = attr_name.to_s
17411790

17421791
name = self.class.primary_key if name == "id" && self.class.primary_key
1743-
@attributes[name] = value
1792+
if @schema.respond_to?("#{name}=")
1793+
@schema.send("#{name}=", value)
1794+
else
1795+
@attributes[name] = value
1796+
end
17441797
end
17451798

17461799
protected
@@ -1882,7 +1935,9 @@ def split_options(options = {})
18821935
def method_missing(method_symbol, *arguments) # :nodoc:
18831936
method_name = method_symbol.to_s
18841937

1885-
if method_name =~ /(=|\?)$/
1938+
if @schema.respond_to?(method_name)
1939+
@schema.send(method_name, *arguments)
1940+
elsif method_name =~ /(=|\?)$/
18861941
case $1
18871942
when "="
18881943
write_attribute($`, arguments.first)

lib/active_resource/schema.rb

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,26 @@
22

33
module ActiveResource # :nodoc:
44
class Schema # :nodoc:
5+
include ActiveModel::Model
6+
include ActiveModel::Attributes
7+
include ActiveModel::Serialization
8+
59
# attributes can be known to be one of these types. They are easy to
610
# cast to/from.
711
KNOWN_ATTRIBUTE_TYPES = %w[ string text integer float decimal datetime timestamp time date binary boolean ]
812

913
# An array of attribute definitions, representing the attributes that
1014
# have been defined.
11-
attr_accessor :attrs
15+
class_attribute :attrs, instance_accessor: false, instance_predicate: false # :nodoc:
16+
self.attrs = {}.freeze
17+
18+
class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc:
19+
self.cast_values = false
20+
21+
attribute_method_suffix "?", parameters: false
22+
23+
alias_method :attribute?, :send
24+
private :attribute?
1225

1326
# The internals of an Active Resource Schema are very simple -
1427
# unlike an Active Record TableDefinition (on which it is based).
@@ -22,39 +35,54 @@ class Schema # :nodoc:
2235
# The schema stores the name and type of each attribute. That is then
2336
# read out by the schema method to populate the schema of the actual
2437
# resource.
25-
def initialize
26-
@attrs = {}
27-
end
2838

29-
def attribute(name, type, options = {})
30-
raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || Schema::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s)
39+
class << self
40+
def inherited(subclass)
41+
super
42+
subclass.attrs = attrs.dup
43+
end
3144

32-
the_type = type.to_s
33-
# TODO: add defaults
34-
# the_attr = [type.to_s]
35-
# the_attr << options[:default] if options.has_key? :default
36-
@attrs[name.to_s] = the_type
37-
self
38-
end
39-
40-
# The following are the attribute types supported by Active Resource
41-
# migrations.
42-
KNOWN_ATTRIBUTE_TYPES.each do |attr_type|
43-
# def string(*args)
44-
# options = args.extract_options!
45-
# attr_names = args
45+
# The internals of an Active Resource Schema are very simple -
46+
# unlike an Active Record TableDefinition (on which it is based).
47+
# It provides a set of convenience methods for people to define their
48+
# schema using the syntax:
49+
# schema do
50+
# string :foo
51+
# integer :bar
52+
# end
4653
#
47-
# attr_names.each { |name| attribute(name, 'string', options) }
48-
# end
49-
class_eval <<-EOV, __FILE__, __LINE__ + 1
50-
# frozen_string_literal: true
51-
def #{attr_type}(*args)
52-
options = args.extract_options!
53-
attr_names = args
54-
55-
attr_names.each { |name| attribute(name, '#{attr_type}', options) }
56-
end
57-
EOV
54+
# The schema stores the name and type of each attribute. That is then
55+
# read out by the schema method to populate the schema of the actual
56+
# resource.
57+
def attribute(name, type = nil, options = {})
58+
raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || Schema::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s)
59+
60+
the_type = type&.to_s
61+
attrs[name.to_s] = the_type
62+
63+
super(name, cast_values ? type.try(:to_sym) : nil, **options)
64+
self
65+
end
66+
67+
# The following are the attribute types supported by Active Resource
68+
# migrations.
69+
KNOWN_ATTRIBUTE_TYPES.each do |attr_type|
70+
# def string(*args)
71+
# options = args.extract_options!
72+
# attr_names = args
73+
#
74+
# attr_names.each { |name| attribute(name, 'string', options) }
75+
# end
76+
class_eval <<-EOV, __FILE__, __LINE__ + 1
77+
# frozen_string_literal: true
78+
def #{attr_type}(*args)
79+
options = args.extract_options!
80+
attr_names = args
81+
82+
attr_names.each { |name| attribute(name, '#{attr_type}', options) }
83+
end
84+
EOV
85+
end
5886
end
5987
end
6088
end

0 commit comments

Comments
 (0)