Skip to content

Commit 72a380b

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 72a380b

File tree

4 files changed

+185
-44
lines changed

4 files changed

+185
-44
lines changed

lib/active_resource/base.rb

Lines changed: 76 additions & 13 deletions
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
@@ -1213,6 +1252,7 @@ def known_attributes
12131252
def initialize(attributes = {}, persisted = false)
12141253
@attributes = {}.with_indifferent_access
12151254
@prefix_options = {}
1255+
@schema = self.class.schema_definition.new
12161256
@persisted = persisted
12171257
load(attributes, false, persisted)
12181258
end
@@ -1246,6 +1286,7 @@ def clone
12461286
resource = self.class.new({})
12471287
resource.prefix_options = self.prefix_options
12481288
resource.send :instance_variable_set, "@attributes", cloned
1289+
resource.send :instance_variable_set, "@schema", @schema.clone
12491290
resource
12501291
end
12511292

@@ -1285,12 +1326,24 @@ def persisted?
12851326

12861327
# Gets the <tt>\id</tt> attribute of the resource.
12871328
def id
1288-
attributes[self.class.primary_key]
1329+
primary_key = self.class.primary_key
1330+
1331+
if @schema.respond_to?(primary_key)
1332+
@schema.send(primary_key)
1333+
else
1334+
attributes[primary_key]
1335+
end
12891336
end
12901337

12911338
# Sets the <tt>\id</tt> attribute of the resource.
12921339
def id=(id)
1293-
attributes[self.class.primary_key] = id
1340+
primary_key = self.class.primary_key
1341+
1342+
if @schema.respond_to?(:"#{primary_key}=")
1343+
@schema.send(:"#{primary_key}=", id)
1344+
else
1345+
attributes[primary_key] = id
1346+
end
12941347
end
12951348

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

14831536
attributes.each do |key, value|
1484-
@attributes[key.to_s] =
1537+
value =
14851538
case value
14861539
when Array
14871540
resource = nil
@@ -1499,6 +1552,12 @@ def load(attributes, remove_root = false, persisted = false)
14991552
else
15001553
value.duplicable? ? value.dup : value
15011554
end
1555+
1556+
if @schema.respond_to?("#{key}=")
1557+
@schema.send("#{key}=", value)
1558+
else
1559+
@attributes[key.to_s] = value
1560+
end
15021561
end
15031562
self
15041563
end
@@ -1541,7 +1600,9 @@ def update_attributes(attributes)
15411600
# <tt>my_person.respond_to?(:name?)</tt>.
15421601
def respond_to_missing?(method, include_priv = false)
15431602
method_name = method.to_s
1544-
if attributes.nil?
1603+
if @schema.respond_to?(method)
1604+
true
1605+
elsif attributes.nil?
15451606
super
15461607
elsif known_attributes.include?(method_name)
15471608
true
@@ -1701,7 +1762,9 @@ def split_options(options = {})
17011762
def method_missing(method_symbol, *arguments) # :nodoc:
17021763
method_name = method_symbol.to_s
17031764

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

lib/active_resource/schema.rb

Lines changed: 58 additions & 31 deletions
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

Lines changed: 39 additions & 0 deletions
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
1819
end
1920

2021

@@ -425,4 +426,42 @@ 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 "known primary_key attributes should be cast" do
431+
Person.schema cast_values: true do
432+
attribute Person.primary_key, :integer
433+
end
434+
435+
person = Person.new(Person.primary_key => "1")
436+
437+
assert_equal 1, person.send(Person.primary_key)
438+
end
439+
440+
test "known attributes should be cast" do
441+
Person.schema cast_values: true do
442+
attribute :born_on, :date
443+
end
444+
445+
person = Person.new(born_on: "2000-01-01")
446+
447+
assert_equal Date.new(2000, 1, 1), person.born_on
448+
end
449+
450+
test "known attributes should be support default values" do
451+
Person.schema cast_values: true do
452+
attribute :name, :string, default: "Default Name"
453+
end
454+
455+
person = Person.new
456+
457+
assert_equal "Default Name", person.name
458+
end
459+
460+
test "unknown attributes should not be cast" do
461+
Person.cast_values = true
462+
463+
person = Person.new(age: "10")
464+
465+
assert_equal "10", person.age
466+
end
428467
end

test/cases/base_test.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,18 @@ def test_clone
11171117
end
11181118
end
11191119

1120+
def test_clone_with_schema_that_casts_values
1121+
Person.cast_values = true
1122+
Person.schema = { "age" => "integer" }
1123+
person = Person.new({ Person.primary_key => 1, "age" => "10" }, true)
1124+
1125+
person_c = person.clone
1126+
1127+
assert_predicate person_c, :new?
1128+
assert_nil person_c.send(Person.primary_key)
1129+
assert_equal 10, person_c.age
1130+
end
1131+
11201132
def test_nested_clone
11211133
addy = StreetAddress.find(1, params: { person_id: 1 })
11221134
addy_c = addy.clone

0 commit comments

Comments
 (0)