Skip to content

Commit f6b5a24

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 f6b5a24

File tree

4 files changed

+270
-43
lines changed

4 files changed

+270
-43
lines changed

lib/active_resource/base.rb

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

384+
class_attribute :cast_values, default: false, instance_accessor: false # :nodoc:
385+
class_attribute :schema_definition, default: Schema, instance_accessor: false # :nodoc:
386+
384387
class << self
385388
include ThreadsafeAttributes
386389
threadsafe_attribute :_headers, :_connection, :_user, :_password, :_bearer_token, :_site, :_proxy
@@ -441,16 +444,45 @@ def new_lazy_collections=(value)
441444
#
442445
# Attribute-types must be one of: <tt>string, text, integer, float, decimal, datetime, timestamp, time, date, binary, boolean</tt>
443446
#
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!
447+
# To *cast* the value of the returned attribute, declare the Schema with
448+
# the +:cast_values+ set to true.
450449
#
451-
def schema(&block)
450+
# class Person < ActiveResource::Base
451+
# schema cast_values: true do
452+
# integer 'age'
453+
# end
454+
# end
455+
#
456+
# p = Person.new
457+
# p.age = "18"
458+
# p.age # => 18
459+
#
460+
# To configure inheriting resources to cast values, set the +cast_values+
461+
# class attribute:
462+
#
463+
# class ApplicationResource < ActiveResource::Base
464+
# self.cast_values = true
465+
# end
466+
#
467+
# class Person < ApplicationResource
468+
# schema do
469+
# integer 'age'
470+
# end
471+
# end
472+
#
473+
# p = Person.new
474+
# p.age = "18"
475+
# p.age # => 18
476+
#
477+
# To set all resources application-wide to cast values, set
478+
# +config.active_resource.cast_values+:
479+
#
480+
# # config/application.rb
481+
# config.active_resource.cast_values = true
482+
def schema(cast_values: self.cast_values, &block)
452483
if block_given?
453-
schema_definition = Schema.new
484+
self.schema_definition = Class.new(schema_definition)
485+
schema_definition.cast_values = cast_values
454486
schema_definition.instance_eval(&block)
455487

456488
# skip out if we didn't define anything
@@ -490,6 +522,7 @@ def schema(&block)
490522
def schema=(the_schema)
491523
unless the_schema.present?
492524
# purposefully nulling out the schema
525+
self.schema_definition = Schema
493526
@schema = nil
494527
@known_attributes = []
495528
return
@@ -1336,6 +1369,7 @@ def known_attributes
13361369
def initialize(attributes = {}, persisted = false)
13371370
@attributes = {}.with_indifferent_access
13381371
@prefix_options = {}
1372+
@schema = self.class.schema_definition.new
13391373
@persisted = persisted
13401374
load(attributes, false, persisted)
13411375
end
@@ -1369,6 +1403,7 @@ def clone
13691403
resource = self.class.new({})
13701404
resource.prefix_options = self.prefix_options
13711405
resource.send :instance_variable_set, "@attributes", cloned
1406+
resource.send :instance_variable_set, "@schema", @schema.clone
13721407
resource
13731408
end
13741409

@@ -1702,7 +1737,7 @@ def respond_to_missing?(method, include_priv = false)
17021737
method_name = method.to_s
17031738
if attributes.nil?
17041739
super
1705-
elsif known_attributes.include?(method_name)
1740+
elsif known_attributes.include?(method_name) || @schema.respond_to?(method)
17061741
true
17071742
elsif method_name =~ /(?:=|\?)$/ && known_attributes.include?($`)
17081743
true
@@ -1713,6 +1748,10 @@ def respond_to_missing?(method, include_priv = false)
17131748
end
17141749
end
17151750

1751+
def serializable_hash(options = nil)
1752+
@schema.serializable_hash(options).merge!(super)
1753+
end
1754+
17161755
def to_json(options = {})
17171756
super(include_root_in_json ? { root: self.class.element_name }.merge(options) : options)
17181757
end
@@ -1733,14 +1772,22 @@ def read_attribute(attr_name)
17331772
name = attr_name.to_s
17341773

17351774
name = self.class.primary_key if name == "id" && self.class.primary_key
1736-
@attributes[name]
1775+
if @schema.respond_to?(name)
1776+
@schema.send(name)
1777+
else
1778+
@attributes[name]
1779+
end
17371780
end
17381781

17391782
def write_attribute(attr_name, value)
17401783
name = attr_name.to_s
17411784

17421785
name = self.class.primary_key if name == "id" && self.class.primary_key
1743-
@attributes[name] = value
1786+
if @schema.respond_to?("#{name}=")
1787+
@schema.send("#{name}=", value)
1788+
else
1789+
@attributes[name] = value
1790+
end
17441791
end
17451792

17461793
protected
@@ -1882,7 +1929,9 @@ def split_options(options = {})
18821929
def method_missing(method_symbol, *arguments) # :nodoc:
18831930
method_name = method_symbol.to_s
18841931

1885-
if method_name =~ /(=|\?)$/
1932+
if @schema.respond_to?(method_name)
1933+
@schema.send(method_name, *arguments)
1934+
elsif method_name =~ /(=|\?)$/
18861935
case $1
18871936
when "="
18881937
write_attribute($`, arguments.first)

lib/active_resource/schema.rb

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@
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_predicate: false, default: {}.freeze # :nodoc:
16+
17+
class_attribute :cast_values, instance_accessor: false, default: false # :nodoc:
18+
attribute_method_suffix "?", parameters: false
19+
20+
alias_method :attribute?, :send
21+
private :attribute?
1222

1323
# The internals of an Active Resource Schema are very simple -
1424
# unlike an Active Record TableDefinition (on which it is based).
@@ -22,39 +32,42 @@ 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
2835

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

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
42+
def attribute(name, type = nil, options = {})
43+
raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || Schema::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s)
44+
45+
the_type = type&.to_s
46+
attrs[name.to_s] = the_type
47+
48+
super(name, cast_values ? type.try(:to_sym) : nil, **options)
49+
self
50+
end
51+
52+
# The following are the attribute types supported by Active Resource
53+
# migrations.
54+
KNOWN_ATTRIBUTE_TYPES.each do |attr_type|
55+
# def string(*args)
56+
# options = args.extract_options!
57+
# attr_names = args
58+
#
59+
# attr_names.each { |name| attribute(name, 'string', options) }
60+
# end
61+
class_eval <<-EOV, __FILE__, __LINE__ + 1
62+
# frozen_string_literal: true
63+
def #{attr_type}(*args)
64+
options = args.extract_options!
65+
attr_names = args
3966
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
46-
#
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
67+
attr_names.each { |name| attribute(name, '#{attr_type}', options) }
68+
end
69+
EOV
70+
end
5871
end
5972
end
6073
end

test/cases/base/schema_test.rb

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def setup
1414
end
1515

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

@@ -160,6 +161,51 @@ def teardown
160161
}
161162
end
162163

164+
test "classes can alias attributes for a schema they inherit from their ancestors" do
165+
base = Class.new(ActiveResource::Base) do
166+
schema { attribute :base_attribute }
167+
end
168+
person = Class.new(base) do
169+
schema { alias_attribute :aliased_attribute, :base_attribute }
170+
end
171+
172+
resource = person.new
173+
174+
assert_changes -> { resource.base_attribute }, to: "value" do
175+
resource.aliased_attribute = "value"
176+
end
177+
assert_equal [ "base_attribute" ], resource.attribute_names
178+
assert_equal "value", resource.serializable_hash["base_attribute"]
179+
assert_not_includes resource.serializable_hash, "aliased_attribute"
180+
end
181+
182+
test "classes can extend the schema they inherit from their ancestors" do
183+
base = Class.new(ActiveResource::Base) do
184+
schema { attribute :created_at, :datetime }
185+
end
186+
cast_values = Class.new(base) do
187+
schema(cast_values: true) { attribute :accepted_terms_and_conditions, :boolean }
188+
end
189+
uncast_values = Class.new(base) do
190+
schema(cast_values: false) { attribute :line1, :string }
191+
end
192+
193+
cast_resource = cast_values.new
194+
uncast_resource = uncast_values.new
195+
196+
assert_changes -> { cast_resource.accepted_terms_and_conditions }, to: true do
197+
cast_resource.accepted_terms_and_conditions = "1"
198+
end
199+
assert_changes -> { cast_resource.created_at.try(:to_date) }, from: nil, to: Date.new(2025, 1, 1) do
200+
cast_resource.created_at = "2025-01-01"
201+
end
202+
assert_changes -> { uncast_resource.line1 }, to: 123 do
203+
uncast_resource.line1 = 123
204+
end
205+
assert_changes -> { uncast_resource.created_at }, from: nil, to: "2025-01-01" do
206+
uncast_resource.created_at = "2025-01-01"
207+
end
208+
end
163209

164210
#####################################################
165211
# Using the schema syntax
@@ -425,4 +471,87 @@ def teardown
425471
Person.schema = new_schema
426472
assert_equal Person.new(age: 20, name: "Matz").known_attributes, [ "age", "name" ]
427473
end
474+
475+
test "clone with schema that casts values" do
476+
Person.cast_values = true
477+
Person.schema = { "age" => "integer" }
478+
person = Person.new({ Person.primary_key => 1, "age" => "10" }, true)
479+
480+
person_c = person.clone
481+
482+
assert_predicate person_c, :new?
483+
assert_nil person_c.send(Person.primary_key)
484+
assert_equal 10, person_c.age
485+
end
486+
487+
test "known primary_key attributes should be cast" do
488+
Person.schema cast_values: true do
489+
attribute Person.primary_key, :integer
490+
end
491+
492+
person = Person.new(Person.primary_key => "1")
493+
494+
assert_equal 1, person.send(Person.primary_key)
495+
end
496+
497+
test "cast_values: true supports implicit types" do
498+
Person.schema cast_values: true do
499+
attribute :name
500+
end
501+
502+
person = Person.new(name: "String")
503+
504+
assert_equal "String", person.name
505+
end
506+
507+
test "known attributes should be cast" do
508+
Person.schema cast_values: true do
509+
attribute :born_on, :date
510+
end
511+
512+
person = Person.new(born_on: "2000-01-01")
513+
514+
assert_equal Date.new(2000, 1, 1), person.born_on
515+
end
516+
517+
test "known boolean attributes should be cast as predicates" do
518+
Person.schema cast_values: true do
519+
attribute :alive, :boolean
520+
end
521+
522+
assert_predicate Person.new(alive: "1"), :alive?
523+
assert_predicate Person.new(alive: "true"), :alive?
524+
assert_predicate Person.new(alive: true), :alive?
525+
assert_not_predicate Person.new, :alive?
526+
assert_not_predicate Person.new(alive: nil), :alive?
527+
assert_not_predicate Person.new(alive: "0"), :alive?
528+
assert_not_predicate Person.new(alive: "false"), :alive?
529+
assert_not_predicate Person.new(alive: false), :alive?
530+
end
531+
532+
test "known attributes should be support default values" do
533+
Person.schema cast_values: true do
534+
attribute :name, :string, default: "Default Name"
535+
end
536+
537+
person = Person.new
538+
539+
assert_equal "Default Name", person.name
540+
end
541+
542+
test "unknown attributes should not be cast" do
543+
Person.cast_values = true
544+
545+
person = Person.new(age: "10")
546+
547+
assert_equal "10", person.age
548+
end
549+
550+
test "unknown attribute type raises ArgumentError" do
551+
assert_raises ArgumentError, match: /Unknown Attribute type: :junk/ do
552+
Person.schema cast_values: true do
553+
attribute :name, :junk
554+
end
555+
end
556+
end
428557
end

0 commit comments

Comments
 (0)