diff --git a/lib/active_resource/base.rb b/lib/active_resource/base.rb index 29376f081d..ca0ce01070 100644 --- a/lib/active_resource/base.rb +++ b/lib/active_resource/base.rb @@ -335,6 +335,12 @@ def self.logger=(logger) class_attribute :connection_class self.connection_class = Connection + class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc: + self.cast_values = false + + class_attribute :schema_definition, instance_accessor: false, instance_predicate: false # :nodoc: + self.schema_definition = Schema + class << self include ThreadsafeAttributes threadsafe_attribute :_headers, :_connection, :_user, :_password, :_bearer_token, :_site, :_proxy @@ -385,16 +391,49 @@ class << self # # Attribute-types must be one of: string, text, integer, float, decimal, datetime, timestamp, time, date, binary, boolean # - # Note: at present the attribute-type doesn't do anything, but stay - # tuned... - # Shortly it will also *cast* the value of the returned attribute. - # ie: - # j.age # => 34 # cast to an integer - # j.weight # => '65' # still a string! + # Note: By default, the attribute-type is ignored and will not cast its + # value. + # + # To cast values to their specified types, declare the Schema with the + # +:cast_values+ set to true. + # + # 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: + # + # 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+: + # + # # config/application.rb + # config.active_resource.cast_values = true # - def schema(&block) + def schema(cast_values: self.cast_values, &block) if block_given? - schema_definition = Schema.new + self.schema_definition = Class.new(Schema) + schema_definition.cast_values = cast_values schema_definition.instance_eval(&block) # skip out if we didn't define anything @@ -1213,6 +1252,7 @@ def known_attributes def initialize(attributes = {}, persisted = false) @attributes = {}.with_indifferent_access @prefix_options = {} + @schema = self.class.schema_definition.new @persisted = persisted load(attributes, false, persisted) end @@ -1246,6 +1286,7 @@ def clone resource = self.class.new({}) resource.prefix_options = self.prefix_options resource.send :instance_variable_set, "@attributes", cloned + resource.send :instance_variable_set, "@schema", @schema.clone resource end @@ -1285,12 +1326,24 @@ def persisted? # Gets the \id attribute of the resource. def id - attributes[self.class.primary_key] + primary_key = self.class.primary_key + + if @schema.respond_to?(primary_key) + @schema.send(primary_key) + else + attributes[primary_key] + end end # Sets the \id attribute of the resource. def id=(id) - attributes[self.class.primary_key] = id + primary_key = self.class.primary_key + + if @schema.respond_to?(:"#{primary_key}=") + @schema.send(:"#{primary_key}=", id) + else + attributes[primary_key] = id + end end # 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) attributes = Formats.remove_root(attributes) if remove_root attributes.each do |key, value| - @attributes[key.to_s] = + value = case value when Array resource = nil @@ -1499,6 +1552,12 @@ def load(attributes, remove_root = false, persisted = false) else value.duplicable? ? value.dup : value end + + if @schema.respond_to?("#{key}=") + @schema.send("#{key}=", value) + else + @attributes[key.to_s] = value + end end self end @@ -1541,7 +1600,9 @@ def update_attributes(attributes) # my_person.respond_to?(:name?). def respond_to_missing?(method, include_priv = false) method_name = method.to_s - if attributes.nil? + if @schema.respond_to?(method) + true + elsif attributes.nil? super elsif known_attributes.include?(method_name) true @@ -1701,7 +1762,9 @@ def split_options(options = {}) def method_missing(method_symbol, *arguments) # :nodoc: method_name = method_symbol.to_s - if method_name =~ /(=|\?)$/ + if @schema.respond_to?(method_name) + @schema.send(method_name, *arguments) + elsif method_name =~ /(=|\?)$/ case $1 when "=" attributes[$`] = arguments.first diff --git a/lib/active_resource/schema.rb b/lib/active_resource/schema.rb index 40206c6441..5107fa15de 100644 --- a/lib/active_resource/schema.rb +++ b/lib/active_resource/schema.rb @@ -2,14 +2,24 @@ module ActiveResource # :nodoc: class Schema # :nodoc: + include ActiveModel::Model + include ActiveModel::Attributes + # attributes can be known to be one of these types. They are easy to # cast to/from. KNOWN_ATTRIBUTE_TYPES = %w( string text integer float decimal datetime timestamp time date binary boolean ) # An array of attribute definitions, representing the attributes that # have been defined. - attr_accessor :attrs + class_attribute :attrs, instance_accessor: false, instance_predicate: false # :nodoc: + self.attrs = {} + + class_attribute :cast_values, instance_accessor: false, instance_predicate: false # :nodoc: + self.cast_values = false + ## + # :method: initialize + # # The internals of an Active Resource Schema are very simple - # unlike an Active Record TableDefinition (on which it is based). # It provides a set of convenience methods for people to define their @@ -22,39 +32,56 @@ class Schema # :nodoc: # The schema stores the name and type of each attribute. That is then # read out by the schema method to populate the schema of the actual # resource. - def initialize - @attrs = {} - end - - def attribute(name, type, options = {}) - raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || Schema::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s) - the_type = type.to_s - # TODO: add defaults - # the_attr = [type.to_s] - # the_attr << options[:default] if options.has_key? :default - @attrs[name.to_s] = the_type - self - end + class << self + def inherited(subclass) + super + subclass.attrs = attrs.dup + end - # The following are the attribute types supported by Active Resource - # migrations. - KNOWN_ATTRIBUTE_TYPES.each do |attr_type| - # def string(*args) - # options = args.extract_options! - # attr_names = args + # The internals of an Active Resource Schema are very simple - + # unlike an Active Record TableDefinition (on which it is based). + # It provides a set of convenience methods for people to define their + # schema using the syntax: + # schema do + # string :foo + # integer :bar + # end # - # attr_names.each { |name| attribute(name, 'string', options) } - # end - class_eval <<-EOV, __FILE__, __LINE__ + 1 - # frozen_string_literal: true - def #{attr_type}(*args) - options = args.extract_options! - attr_names = args - - attr_names.each { |name| attribute(name, '#{attr_type}', options) } - end - EOV + # The schema stores the name and type of each attribute. That is then + # read out by the schema method to populate the schema of the actual + # resource. + def attribute(name, type, **options) + raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || Schema::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s) + + the_type = type.to_s + attrs[name.to_s] = the_type + + type = cast_values ? type.to_sym : nil + + super + self + end + + # The following are the attribute types supported by Active Resource + # migrations. + KNOWN_ATTRIBUTE_TYPES.each do |attr_type| + # def string(*args) + # options = args.extract_options! + # attr_names = args + # + # attr_names.each { |name| attribute(name, 'string', options) } + # end + class_eval <<-EOV, __FILE__, __LINE__ + 1 + # frozen_string_literal: true + def #{attr_type}(*args) + options = args.extract_options! + attr_names = args + + attr_names.each { |name| attribute(name, :#{attr_type}, **options) } + end + EOV + end end end end diff --git a/test/cases/base/schema_test.rb b/test/cases/base/schema_test.rb index 5b9de29b5e..fdfd89afa0 100644 --- a/test/cases/base/schema_test.rb +++ b/test/cases/base/schema_test.rb @@ -15,6 +15,7 @@ def setup def teardown Person.schema = nil # hack to stop test bleedthrough... + Person.cast_values = false end @@ -425,4 +426,42 @@ def teardown Person.schema = new_schema assert_equal Person.new(age: 20, name: "Matz").known_attributes, ["age", "name"] end + + test "known primary_key attributes should be cast" do + Person.schema cast_values: true do + attribute Person.primary_key, :integer + end + + person = Person.new(Person.primary_key => "1") + + assert_equal 1, person.send(Person.primary_key) + end + + test "known attributes should be cast" do + Person.schema cast_values: true do + attribute :born_on, :date + end + + person = Person.new(born_on: "2000-01-01") + + assert_equal Date.new(2000, 1, 1), person.born_on + end + + test "known attributes should be support default values" do + Person.schema cast_values: true do + attribute :name, :string, default: "Default Name" + end + + person = Person.new + + assert_equal "Default Name", person.name + end + + test "unknown attributes should not be cast" do + Person.cast_values = true + + person = Person.new(age: "10") + + assert_equal "10", person.age + end end diff --git a/test/cases/base_test.rb b/test/cases/base_test.rb index 3689b4234d..fb1a2bb53b 100644 --- a/test/cases/base_test.rb +++ b/test/cases/base_test.rb @@ -1117,6 +1117,18 @@ def test_clone end end + def test_clone_with_schema_that_casts_values + Person.cast_values = true + Person.schema = { "age" => "integer" } + person = Person.new({ Person.primary_key => 1, "age" => "10" }, true) + + person_c = person.clone + + assert_predicate person_c, :new? + assert_nil person_c.send(Person.primary_key) + assert_equal 10, person_c.age + end + def test_nested_clone addy = StreetAddress.find(1, params: { person_id: 1 }) addy_c = addy.clone