Skip to content

Commit 5e5344a

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 9c8a2ee commit 5e5344a

File tree

3 files changed

+185
-45
lines changed

3 files changed

+185
-45
lines changed

lib/active_resource/base.rb

+76-14
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,12 @@ def self.logger=(logger)
335335
class_attribute :connection_class
336336
self.connection_class = Connection
337337

338+
class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc:
339+
self.cast_values = false
340+
341+
class_attribute :schema_definition, instance_accessor: false, instance_predicate: false # :nodoc:
342+
self.schema_definition = Schema
343+
338344
class << self
339345
include ThreadsafeAttributes
340346
threadsafe_attribute :_headers, :_connection, :_user, :_password, :_bearer_token, :_site, :_proxy
@@ -385,16 +391,49 @@ class << self
385391
#
386392
# Attribute-types must be one of: <tt>string, text, integer, float, decimal, datetime, timestamp, time, date, binary, boolean</tt>
387393
#
388-
# Note: at present the attribute-type doesn't do anything, but stay
389-
# tuned...
390-
# Shortly it will also *cast* the value of the returned attribute.
391-
# ie:
392-
# j.age # => 34 # cast to an integer
393-
# j.weight # => '65' # still a string!
394+
# Note: By default, the attribute-type is ignored and will not cast its
395+
# value.
396+
#
397+
# To cast values to their specified types, declare the Schema with the
398+
# +:cast_values+ set to true.
399+
#
400+
# class Person < ActiveResource::Base
401+
# schema cast_values: true do
402+
# integer 'age'
403+
# end
404+
# end
405+
#
406+
# p = Person.new
407+
# p.age = "18"
408+
# p.age # => 18
409+
#
410+
# To configure inheriting resources to cast values, set the +cast_values+
411+
# class attribute:
412+
#
413+
# class ApplicationResource < ActiveResource::Base
414+
# self.cast_values = true
415+
# end
416+
#
417+
# class Person < ApplicationResource
418+
# schema do
419+
# integer 'age'
420+
# end
421+
# end
422+
#
423+
# p = Person.new
424+
# p.age = "18"
425+
# p.age # => 18
426+
#
427+
# To set all resources application-wide to cast values, set
428+
# +config.active_resource.cast_values+:
429+
#
430+
# # config/application.rb
431+
# config.active_resource.cast_values = true
394432
#
395-
def schema(&block)
433+
def schema(cast_values: self.cast_values, &block)
396434
if block_given?
397-
schema_definition = Schema.new
435+
self.schema_definition = Class.new(Schema)
436+
schema_definition.cast_values = cast_values
398437
schema_definition.instance_eval(&block)
399438

400439
# skip out if we didn't define anything
@@ -434,6 +473,7 @@ def schema(&block)
434473
def schema=(the_schema)
435474
unless the_schema.present?
436475
# purposefully nulling out the schema
476+
self.schema_definition = Schema
437477
@schema = nil
438478
@known_attributes = []
439479
return
@@ -1213,6 +1253,7 @@ def known_attributes
12131253
def initialize(attributes = {}, persisted = false)
12141254
@attributes = {}.with_indifferent_access
12151255
@prefix_options = {}
1256+
@schema = self.class.schema_definition.new
12161257
@persisted = persisted
12171258
load(attributes, false, persisted)
12181259
end
@@ -1246,6 +1287,7 @@ def clone
12461287
resource = self.class.new({})
12471288
resource.prefix_options = self.prefix_options
12481289
resource.send :instance_variable_set, "@attributes", cloned
1290+
resource.send :instance_variable_set, "@schema", @schema.clone
12491291
resource
12501292
end
12511293

@@ -1285,12 +1327,12 @@ def persisted?
12851327

12861328
# Gets the <tt>\id</tt> attribute of the resource.
12871329
def id
1288-
attributes[self.class.primary_key]
1330+
_read_attribute(self.class.primary_key)
12891331
end
12901332

12911333
# Sets the <tt>\id</tt> attribute of the resource.
12921334
def id=(id)
1293-
attributes[self.class.primary_key] = id
1335+
_write_attribute(self.class.primary_key, id)
12941336
end
12951337

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

14831525
attributes.each do |key, value|
1484-
@attributes[key.to_s] =
1526+
_write_attribute(key,
14851527
case value
14861528
when Array
14871529
resource = nil
@@ -1498,7 +1540,7 @@ def load(attributes, remove_root = false, persisted = false)
14981540
resource.new(value, persisted)
14991541
else
15001542
value.duplicable? ? value.dup : value
1501-
end
1543+
end)
15021544
end
15031545
self
15041546
end
@@ -1541,7 +1583,9 @@ def update_attributes(attributes)
15411583
# <tt>my_person.respond_to?(:name?)</tt>.
15421584
def respond_to_missing?(method, include_priv = false)
15431585
method_name = method.to_s
1544-
if attributes.nil?
1586+
if @schema.respond_to?(method)
1587+
true
1588+
elsif attributes.nil?
15451589
super
15461590
elsif known_attributes.include?(method_name)
15471591
true
@@ -1698,10 +1742,28 @@ def split_options(options = {})
16981742
self.class.__send__(:split_options, options)
16991743
end
17001744

1745+
def _read_attribute(name)
1746+
if @schema.respond_to?(name)
1747+
@schema.send(name)
1748+
else
1749+
attributes[name.to_s]
1750+
end
1751+
end
1752+
1753+
def _write_attribute(name, value)
1754+
if @schema.respond_to?("#{name}=")
1755+
@schema.send("#{name}=", value)
1756+
else
1757+
attributes[name.to_s] = value
1758+
end
1759+
end
1760+
17011761
def method_missing(method_symbol, *arguments) # :nodoc:
17021762
method_name = method_symbol.to_s
17031763

1704-
if method_name =~ /(=|\?)$/
1764+
if @schema.respond_to?(method_name)
1765+
@schema.send(method_name, *arguments)
1766+
elsif method_name =~ /(=|\?)$/
17051767
case $1
17061768
when "="
17071769
attributes[$`] = arguments.first

lib/active_resource/schema.rb

+58-31
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@
22

33
module ActiveResource # :nodoc:
44
class Schema # :nodoc:
5+
include ActiveModel::Model
6+
include ActiveModel::Attributes
7+
58
# attributes can be known to be one of these types. They are easy to
69
# cast to/from.
710
KNOWN_ATTRIBUTE_TYPES = %w( string text integer float decimal datetime timestamp time date binary boolean )
811

912
# An array of attribute definitions, representing the attributes that
1013
# have been defined.
11-
attr_accessor :attrs
14+
class_attribute :attrs, instance_accessor: false, instance_predicate: false # :nodoc:
15+
self.attrs = {}
16+
17+
class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc:
18+
self.cast_values = false
1219

20+
##
21+
# :method: initialize
22+
#
1323
# The internals of an Active Resource Schema are very simple -
1424
# unlike an Active Record TableDefinition (on which it is based).
1525
# It provides a set of convenience methods for people to define their
@@ -22,39 +32,56 @@ class Schema # :nodoc:
2232
# The schema stores the name and type of each attribute. That is then
2333
# read out by the schema method to populate the schema of the actual
2434
# resource.
25-
def initialize
26-
@attrs = {}
27-
end
28-
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)
3135

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
36+
class << self
37+
def inherited(subclass)
38+
super
39+
subclass.attrs = attrs.dup
40+
end
3941

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

test/cases/base/schema_test.rb

+51
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def setup
1515

1616
def teardown
1717
Person.schema = nil # hack to stop test bleedthrough...
18+
Person.cast_values = false # hack to stop test bleedthrough...
1819
end
1920

2021

@@ -425,4 +426,54 @@ def teardown
425426
Person.schema = new_schema
426427
assert_equal Person.new(age: 20, name: "Matz").known_attributes, ["age", "name"]
427428
end
429+
430+
test "clone with schema that casts values" do
431+
Person.cast_values = true
432+
Person.schema = { "age" => "integer" }
433+
person = Person.new({ Person.primary_key => 1, "age" => "10" }, true)
434+
435+
person_c = person.clone
436+
437+
assert_predicate person_c, :new?
438+
assert_nil person_c.send(Person.primary_key)
439+
assert_equal 10, person_c.age
440+
end
441+
442+
test "known primary_key attributes should be cast" do
443+
Person.schema cast_values: true do
444+
attribute Person.primary_key, :integer
445+
end
446+
447+
person = Person.new(Person.primary_key => "1")
448+
449+
assert_equal 1, person.send(Person.primary_key)
450+
end
451+
452+
test "known attributes should be cast" do
453+
Person.schema cast_values: true do
454+
attribute :born_on, :date
455+
end
456+
457+
person = Person.new(born_on: "2000-01-01")
458+
459+
assert_equal Date.new(2000, 1, 1), person.born_on
460+
end
461+
462+
test "known attributes should be support default values" do
463+
Person.schema cast_values: true do
464+
attribute :name, :string, default: "Default Name"
465+
end
466+
467+
person = Person.new
468+
469+
assert_equal "Default Name", person.name
470+
end
471+
472+
test "unknown attributes should not be cast" do
473+
Person.cast_values = true
474+
475+
person = Person.new(age: "10")
476+
477+
assert_equal "10", person.age
478+
end
428479
end

0 commit comments

Comments
 (0)