diff --git a/lib/model_attribute.rb b/lib/model_attribute.rb index f740f6d..39b870a 100644 --- a/lib/model_attribute.rb +++ b/lib/model_attribute.rb @@ -4,19 +4,35 @@ require "time" module ModelAttribute - SUPPORTED_TYPES = [:integer, :float, :boolean, :string, :time, :json] - def self.extended(base) base.send(:include, InstanceMethods) base.instance_variable_set('@attribute_names', []) base.instance_variable_set('@attribute_types', {}) base.instance_variable_set('@attribute_defaults', {}) + base.class_eval do + define_method(:initialize) do |attributes| + set_attributes(attributes, true) + end + end end - def attribute(name, type, opts = {}) + def attribute(name, type = :string, opts = {}) name = name.to_sym - type = type.to_sym - raise UnsupportedTypeError.new(type) unless SUPPORTED_TYPES.include?(type) + + classify = -> (value) do + value.to_s.split('_').collect(&:capitalize).join + end + + type = if type.is_a?(Symbol) + type.to_sym + elsif type.is_a?(Class) + type.to_s + else + classify.call(type.to_s) + end + + raise UnsupportedTypeError.new(type) unless Casts::SUPPORTED_TYPES.include?(type) || + (Object.const_defined?(type) rescue Object.const_defined?(classify.call(type))) @attribute_names << name @attribute_types[name] = type diff --git a/lib/model_attribute/casts.rb b/lib/model_attribute/casts.rb index 4eec0ed..02dedbb 100644 --- a/lib/model_attribute/casts.rb +++ b/lib/model_attribute/casts.rb @@ -1,57 +1,62 @@ module ModelAttribute module Casts + SUPPORTED_TYPES = %i[integer float boolean string time json] + + # TODO: Refactor and improve by adding types as middleware class << self def cast(value, type) - return nil if value.nil? + return if value.nil? + return send("valid_#{type}", value) if SUPPORTED_TYPES.include? type + return raise UnsupportedTypeError.new(type) unless Object.const_defined?(type) || + value.is_a?(type) + value + end + + private + + def valid_integer(value) + int = Integer(value) + float = Float(value) + raise ArgumentError, "Can't cast #{value.inspect} to an integer without loss of precision" unless int == float + int + end + + def valid_float(value) + Float(value) + end - case type - when :integer - int = Integer(value) - float = Float(value) - raise ArgumentError, "Can't cast #{value.inspect} to an integer without loss of precision" unless int == float - int - when :float - Float(value) - when :boolean - if !!value == value - value - elsif value == 't' || value == 'true' - true - elsif value == 'f' || value == 'false' - false - else - raise ArgumentError, "Can't cast #{value.inspect} to boolean" - end - when :time - case value - when Time - value - when Date, DateTime - value.to_time - when Integer - # Assume milliseconds since epoch. - Time.at(value / 1000.0) - when Numeric - # Numeric, but not an integer. Assume seconds since epoch. - Time.at(value) - else - Time.parse(value) - end - when :string - String(value) - when :json - if valid_json?(value) - value - else - raise ArgumentError, "JSON only supports nil, numeric, string, boolean and arrays and hashes of those." - end + def valid_boolean(value) + return value if !!value == value + return true if %w[t true].include?(value) + return false if %w[f false].include?(value) + raise ArgumentError, "Can't cast #{value.inspect} to boolean" + end + + def valid_time(value) + { + "Time" => -> (val) { val }, + "Date" => -> (val) { val.to_time }, + "DateTime" => -> (val) { val.to_time }, + "Integer" => -> (val) { Time.at(val / 1000.0) }, + "Numeric" => -> (val) { Time.at(val) }, + "Float" => -> (val) { Time.at(val) } + }[value.class.to_s].call(value) + rescue NoMethodError + Time.parse(value) + end + + def valid_string(value) + String(value) + end + + def valid_json(value) + if valid_json?(value) + value else - raise UnsupportedTypeError.new(type) + raise ArgumentError, "JSON only supports nil, numeric, string, boolean and arrays and hashes of those." end end - private - def valid_json?(value) (value == nil || value == true || @@ -59,7 +64,7 @@ def valid_json?(value) value.is_a?(Numeric) || value.is_a?(String) || (value.is_a?(Array) && valid_json_array?(value)) || - (value.is_a?(Hash) && valid_json_hash?(value) )) + (value.is_a?(Hash) && valid_json_hash?(value))) end def valid_json_array?(array) diff --git a/lib/model_attribute/errors.rb b/lib/model_attribute/errors.rb index 2e5ef1a..627654b 100644 --- a/lib/model_attribute/errors.rb +++ b/lib/model_attribute/errors.rb @@ -7,8 +7,16 @@ def initialize(attribute_name) class UnsupportedTypeError < StandardError def initialize(type) - types_list = ModelAttribute::SUPPORTED_TYPES.map(&:inspect).join(', ') + types_list = Casts::SUPPORTED_TYPES.map(&:inspect).join(', ') super "Unsupported type #{type.inspect}. Must be one of #{types_list}." end end + + class RequiredFieldError < StandardError + def initialize(name) + super "Field #{name} is required" + end + + + end end diff --git a/spec/model_attributes_spec.rb b/spec/model_attributes_spec.rb index a5d1593..62ca3b2 100644 --- a/spec/model_attributes_spec.rb +++ b/spec/model_attributes_spec.rb @@ -36,11 +36,19 @@ def initialize(attributes = {}) "Must be one of :integer, :float, :boolean, :string, :time, :json.") end end + + context "passed unsupported, but defined type" do + it "should accept defined type" do + address_class = Object.const_set "Address", Class.new + User.attribute :address, address_class + expect(User.attributes).to include(:address) + end + end end describe ".attributes" do it "returns an array of attribute names as symbols" do - expect(User.attributes).to eq([:id, :paid, :name, :created_at, :profile, :reward_points, :win_rate]) + expect(User.attributes).to include(*[:id, :paid, :name, :created_at, :profile, :reward_points, :win_rate]) end end @@ -603,8 +611,14 @@ def initialize(attributes = {}) expect(user.inspect).to include("User") end + it 'should be able to take a defined type attribute' do + user.address = Address.new + expect(user.address).not_to be_nil + expect(user.address.class).to eql Address + end + it "looks like '#'" do - expect(user.inspect).to eq("#[\"coding\", \"social networks\"], \"rank\"=>15}, reward_points: 0, win_rate: 35.62>") + expect(user.inspect).to eq("#[\"coding\", \"social networks\"], \"rank\"=>15}, reward_points: 0, win_rate: 35.62, address: nil>") end end @@ -675,5 +689,16 @@ def initialize(attributes = {}) end end end + + describe 'defined class passing' do + context 'using defined class' do + it "should be have an attribute of specific type" do + klass = Object.const_set "SomeClass", Class.new + User.attribute :new_field, klass + user = User.new new_field: klass.new + expect(user.new_field.class).to eql klass + end + end + end end end