Skip to content

Commit 0389ff5

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 b897313 commit 0389ff5

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
@@ -380,6 +380,12 @@ def self.logger=(logger)
380380
class_attribute :connection_class
381381
self.connection_class = Connection
382382

383+
class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc:
384+
self.cast_values = false
385+
386+
class_attribute :schema_definition, instance_accessor: false, instance_predicate: false # :nodoc:
387+
self.schema_definition = Schema
388+
383389
class << self
384390
include ThreadsafeAttributes
385391
threadsafe_attribute :_headers, :_connection, :_user, :_password, :_bearer_token, :_site, :_proxy
@@ -430,16 +436,48 @@ class << self
430436
#
431437
# Attribute-types must be one of: <tt>string, text, integer, float, decimal, datetime, timestamp, time, date, binary, boolean</tt>
432438
#
433-
# Note: at present the attribute-type doesn't do anything, but stay
434-
# tuned...
435-
# Shortly it will also *cast* the value of the returned attribute.
436-
# ie:
437-
# j.age # => 34 # cast to an integer
438-
# j.weight # => '65' # still a string!
439+
# Note: By default, the attribute-type is ignored and will not cast its
440+
# value.
441+
#
442+
# To cast values to their specified types, declare the Schema with the
443+
# +:cast_values+ set to true.
444+
#
445+
# class Person < ActiveResource::Base
446+
# schema cast_values: true do
447+
# integer 'age'
448+
# end
449+
# end
450+
#
451+
# p = Person.new
452+
# p.age = "18"
453+
# p.age # => 18
454+
#
455+
# To configure inheriting resources to cast values, set the +cast_values+
456+
# class attribute:
439457
#
440-
def schema(&block)
458+
# class ApplicationResource < ActiveResource::Base
459+
# self.cast_values = true
460+
# end
461+
#
462+
# class Person < ApplicationResource
463+
# schema do
464+
# integer 'age'
465+
# end
466+
# end
467+
#
468+
# p = Person.new
469+
# p.age = "18"
470+
# p.age # => 18
471+
#
472+
# To set all resources application-wide to cast values, set
473+
# +config.active_resource.cast_values+:
474+
#
475+
# # config/application.rb
476+
# config.active_resource.cast_values = true
477+
def schema(cast_values: self.cast_values, &block)
441478
if block_given?
442-
schema_definition = Schema.new
479+
self.schema_definition = Class.new(schema_definition)
480+
schema_definition.cast_values = cast_values
443481
schema_definition.instance_eval(&block)
444482

445483
# skip out if we didn't define anything
@@ -479,6 +517,7 @@ def schema(&block)
479517
def schema=(the_schema)
480518
unless the_schema.present?
481519
# purposefully nulling out the schema
520+
self.schema_definition = Schema
482521
@schema = nil
483522
@known_attributes = []
484523
return
@@ -1308,6 +1347,7 @@ def known_attributes
13081347
def initialize(attributes = {}, persisted = false)
13091348
@attributes = {}.with_indifferent_access
13101349
@prefix_options = {}
1350+
@schema = self.class.schema_definition.new
13111351
@persisted = persisted
13121352
load(attributes, false, persisted)
13131353
end
@@ -1341,6 +1381,7 @@ def clone
13411381
resource = self.class.new({})
13421382
resource.prefix_options = self.prefix_options
13431383
resource.send :instance_variable_set, "@attributes", cloned
1384+
resource.send :instance_variable_set, "@schema", @schema.clone
13441385
resource
13451386
end
13461387

@@ -1674,7 +1715,7 @@ def respond_to_missing?(method, include_priv = false)
16741715
method_name = method.to_s
16751716
if attributes.nil?
16761717
super
1677-
elsif known_attributes.include?(method_name)
1718+
elsif known_attributes.include?(method_name) || @schema.respond_to?(method)
16781719
true
16791720
elsif method_name =~ /(?:=|\?)$/ && known_attributes.include?($`)
16801721
true
@@ -1685,6 +1726,10 @@ def respond_to_missing?(method, include_priv = false)
16851726
end
16861727
end
16871728

1729+
def serializable_hash(options = nil)
1730+
@schema.serializable_hash(options).merge!(super)
1731+
end
1732+
16881733
def to_json(options = {})
16891734
super(include_root_in_json ? { root: self.class.element_name }.merge(options) : options)
16901735
end
@@ -1707,14 +1752,22 @@ def read_attribute(attr_name)
17071752
name = attr_name.to_s
17081753

17091754
name = self.class.primary_key if name == "id" && self.class.primary_key
1710-
@attributes[name]
1755+
if @schema.respond_to?(name)
1756+
@schema.send(name)
1757+
else
1758+
@attributes[name]
1759+
end
17111760
end
17121761

17131762
def write_attribute(attr_name, value)
17141763
name = attr_name.to_s
17151764

17161765
name = self.class.primary_key if name == "id" && self.class.primary_key
1717-
@attributes[name] = value
1766+
if @schema.respond_to?("#{name}=")
1767+
@schema.send("#{name}=", value)
1768+
else
1769+
attributes[name] = value
1770+
end
17181771
end
17191772

17201773
protected
@@ -1856,7 +1909,9 @@ def split_options(options = {})
18561909
def method_missing(method_symbol, *arguments) # :nodoc:
18571910
method_name = method_symbol.to_s
18581911

1859-
if method_name =~ /(=|\?)$/
1912+
if @schema.respond_to?(method_name)
1913+
@schema.send(method_name, *arguments)
1914+
elsif method_name =~ /(=|\?)$/
18601915
case $1
18611916
when "="
18621917
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)