diff --git a/lib/active_resource/base.rb b/lib/active_resource/base.rb index 10312bb6f5..e8f8675575 100644 --- a/lib/active_resource/base.rb +++ b/lib/active_resource/base.rb @@ -381,6 +381,9 @@ def self.logger=(logger) class_attribute :connection_class self.connection_class = Connection + class_attribute :cast_values, default: false, instance_accessor: false # :nodoc: + class_attribute :schema_definition, default: Schema, instance_accessor: false # :nodoc: + class << self include ThreadsafeAttributes threadsafe_attribute :_headers, :_connection, :_user, :_password, :_bearer_token, :_site, :_proxy @@ -441,16 +444,45 @@ def new_lazy_collections=(value) # # 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! + # To *cast* the value of the returned attribute, declare the Schema with + # the +:cast_values+ set to true. # - def schema(&block) + # 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(cast_values: self.cast_values, &block) if block_given? - schema_definition = Schema.new + self.schema_definition = Class.new(schema_definition) + schema_definition.cast_values = cast_values schema_definition.instance_eval(&block) # skip out if we didn't define anything @@ -490,6 +522,7 @@ def schema(&block) def schema=(the_schema) unless the_schema.present? # purposefully nulling out the schema + self.schema_definition = Schema @schema = nil @known_attributes = [] return @@ -1336,6 +1369,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 @@ -1369,6 +1403,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 @@ -1702,7 +1737,7 @@ def respond_to_missing?(method, include_priv = false) method_name = method.to_s if attributes.nil? super - elsif known_attributes.include?(method_name) + elsif known_attributes.include?(method_name) || @schema.respond_to?(method) true elsif method_name =~ /(?:=|\?)$/ && known_attributes.include?($`) true @@ -1713,6 +1748,10 @@ def respond_to_missing?(method, include_priv = false) end end + def serializable_hash(options = nil) + @schema.serializable_hash(options).merge!(super) + end + def to_json(options = {}) super(include_root_in_json ? { root: self.class.element_name }.merge(options) : options) end @@ -1733,14 +1772,22 @@ def read_attribute(attr_name) name = attr_name.to_s name = self.class.primary_key if name == "id" && self.class.primary_key - @attributes[name] + if @schema.respond_to?(name) + @schema.send(name) + else + @attributes[name] + end end def write_attribute(attr_name, value) name = attr_name.to_s name = self.class.primary_key if name == "id" && self.class.primary_key - @attributes[name] = value + if @schema.respond_to?("#{name}=") + @schema.send("#{name}=", value) + else + @attributes[name] = value + end end protected @@ -1882,7 +1929,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 "=" write_attribute($`, arguments.first) diff --git a/lib/active_resource/schema.rb b/lib/active_resource/schema.rb index b291c7492a..1f4b72ea7d 100644 --- a/lib/active_resource/schema.rb +++ b/lib/active_resource/schema.rb @@ -2,13 +2,18 @@ module ActiveResource # :nodoc: class Schema # :nodoc: + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Serialization + # 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_predicate: false, default: {}.freeze # :nodoc: + class_attribute :cast_values, instance_accessor: false, default: false # :nodoc: # The internals of an Active Resource Schema are very simple - # unlike an Active Record TableDefinition (on which it is based). @@ -22,18 +27,18 @@ 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 = {} + def self.inherited(subclass) + super + subclass.attrs = attrs.dup end - def attribute(name, type, options = {}) + def self.attribute(name, type = nil, 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 + attrs[name.to_s] = the_type.presence + + super(name, cast_values ? type&.to_sym : nil, **options) self end @@ -48,7 +53,7 @@ def attribute(name, type, options = {}) # end class_eval <<-EOV, __FILE__, __LINE__ + 1 # frozen_string_literal: true - def #{attr_type}(*args) + def self.#{attr_type}(*args) options = args.extract_options! attr_names = args diff --git a/test/cases/base/schema_test.rb b/test/cases/base/schema_test.rb index 931ccd4190..fd29ec2f43 100644 --- a/test/cases/base/schema_test.rb +++ b/test/cases/base/schema_test.rb @@ -14,6 +14,7 @@ def setup end def teardown + Person.cast_values = false Person.schema = nil # hack to stop test bleedthrough... end @@ -160,6 +161,51 @@ def teardown } end + test "classes can alias attributes for a schema they inherit from their ancestors" do + base = Class.new(ActiveResource::Base) do + schema { attribute :base_attribute } + end + person = Class.new(base) do + schema { alias_attribute :aliased_attribute, :base_attribute } + end + + resource = person.new + + assert_changes -> { resource.base_attribute }, to: "value" do + resource.aliased_attribute = "value" + end + assert_equal [ "base_attribute" ], resource.attribute_names + assert_equal "value", resource.serializable_hash["base_attribute"] + assert_not_includes resource.serializable_hash, "aliased_attribute" + end + + test "classes can extend the schema they inherit from their ancestors" do + base = Class.new(ActiveResource::Base) do + schema { attribute :created_at, :datetime } + end + cast_values = Class.new(base) do + schema(cast_values: true) { attribute :accepted_terms_and_conditions, :boolean } + end + uncast_values = Class.new(base) do + schema(cast_values: false) { attribute :line1, :string } + end + + cast_resource = cast_values.new + uncast_resource = uncast_values.new + + assert_changes -> { cast_resource.accepted_terms_and_conditions }, to: true do + cast_resource.accepted_terms_and_conditions = "1" + end + assert_changes -> { cast_resource.created_at.try(:to_date) }, from: nil, to: Date.new(2025, 1, 1) do + cast_resource.created_at = "2025-01-01" + end + assert_changes -> { uncast_resource.line1 }, to: 123 do + uncast_resource.line1 = 123 + end + assert_changes -> { uncast_resource.created_at }, from: nil, to: "2025-01-01" do + uncast_resource.created_at = "2025-01-01" + end + end ##################################################### # Using the schema syntax @@ -425,4 +471,87 @@ def teardown Person.schema = new_schema assert_equal Person.new(age: 20, name: "Matz").known_attributes, [ "age", "name" ] end + + test "clone with schema that casts values" do + 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 + + 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 "cast_values: true supports implicit types" do + Person.schema cast_values: true do + attribute :name + end + + person = Person.new(name: "String") + + assert_equal "String", person.name + 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 boolean attributes should be cast as predicates" do + Person.schema cast_values: true do + attribute :alive, :boolean + end + + assert_predicate Person.new(alive: "1"), :alive? + assert_predicate Person.new(alive: "true"), :alive? + assert_predicate Person.new(alive: true), :alive? + assert_not_predicate Person.new, :alive? + assert_not_predicate Person.new(alive: nil), :alive? + assert_not_predicate Person.new(alive: "0"), :alive? + assert_not_predicate Person.new(alive: "false"), :alive? + assert_not_predicate Person.new(alive: false), :alive? + 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 + + test "unknown attribute type raises ArgumentError" do + assert_raises ArgumentError, match: /Unknown Attribute type: :junk/ do + Person.schema cast_values: true do + attribute :name, :junk + end + end + end end diff --git a/test/cases/base_test.rb b/test/cases/base_test.rb index 1143d300a1..67a16fdfff 100644 --- a/test/cases/base_test.rb +++ b/test/cases/base_test.rb @@ -1510,6 +1510,42 @@ def test_exists_with_204_no_content assert Person.exists?(1) end + def test_serializable_hash + Person.schema do + attribute :name, :string + attribute :likes_hats, :boolean + end + resource = Person.new(id: 1, name: "Joe", likes_hats: true, non_attribute_field: "foo") + + serializable_hash = resource.serializable_hash + + assert_equal [ "id", "name", "likes_hats", "non_attribute_field" ].sort, serializable_hash.keys.sort + assert_equal 1, serializable_hash["id"] + assert_equal "Joe", serializable_hash["name"] + assert_equal true, serializable_hash["likes_hats"] + assert_equal "foo", serializable_hash["non_attribute_field"] + ensure + Person.schema = nil + end + + def test_serializable_hash_with_options + Person.schema do + attribute :name, :string + attribute :likes_hats, :boolean + end + resource = Person.new(id: 1, name: "Joe", likes_hats: true, non_attribute_field: "foo") + + serializable_hash = resource.serializable_hash(only: [ :id, :name, :non_attribute_field ]) + + assert_equal [ "id", "name", "non_attribute_field" ].sort, serializable_hash.keys.sort + assert_equal 1, serializable_hash["id"] + assert_equal "Joe", serializable_hash["name"] + assert_equal "foo", serializable_hash["non_attribute_field"] + assert_nil serializable_hash["likes_hats"] + ensure + Person.schema = nil + end + def test_read_attribute_for_serialization joe = Person.find(6) joe.singleton_class.class_eval do