diff --git a/lib/validates_timeliness/attribute_methods.rb b/lib/validates_timeliness/attribute_methods.rb index 9f1a995..906422f 100644 --- a/lib/validates_timeliness/attribute_methods.rb +++ b/lib/validates_timeliness/attribute_methods.rb @@ -6,83 +6,44 @@ module ValidatesTimeliness class_attribute :timeliness_validated_attributes self.timeliness_validated_attributes = [] end + end +end - module ClassMethods +ActiveModel::Type::Date.class_eval do + # Module.new do |m| + def cast_value(value) + return super unless ValidatesTimeliness.use_plugin_parser - public - # Override in ORM shim - def timeliness_attribute_timezone_aware?(attr_name) - false - end - - # Override in ORM shim - def timeliness_attribute_type(attr_name) - :datetime - end - - def define_timeliness_methods(before_type_cast=false) - return if timeliness_validated_attributes.blank? - timeliness_validated_attributes.each do |attr_name| - define_attribute_timeliness_methods(attr_name, before_type_cast) - end - end - - def generated_timeliness_methods - @generated_timeliness_methods ||= Module.new { |m| - extend Mutex_m - }.tap { |mod| include mod } - end - - def undefine_timeliness_attribute_methods - generated_timeliness_methods.module_eval do - instance_methods.each { |m| undef_method(m) } - end - end - - protected - - def define_attribute_timeliness_methods(attr_name, before_type_cast=false) - define_timeliness_write_method(attr_name) - define_timeliness_before_type_cast_method(attr_name) if before_type_cast - end - - def define_timeliness_write_method(attr_name) - generated_timeliness_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 - def #{attr_name}=(value) - write_timeliness_attribute('#{attr_name}', value) - end - STR - end - - def define_timeliness_before_type_cast_method(attr_name) - generated_timeliness_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 - def #{attr_name}_before_type_cast - read_timeliness_attribute_before_type_cast('#{attr_name}') - end - STR + if value.is_a?(::String) + return if value.empty? + value = Timeliness::Parser.parse(value, :date) + value.to_date if value + elsif value.respond_to?(:to_date) + value.to_date + else + value end end + # end.tap { |mod| include mod } +end - def write_timeliness_attribute(attr_name, value) - @timeliness_cache ||= {} - @timeliness_cache[attr_name] = value - - if ValidatesTimeliness.use_plugin_parser - type = self.class.timeliness_attribute_type(attr_name) - timezone = :current if self.class.timeliness_attribute_timezone_aware?(attr_name) - value = Timeliness::Parser.parse(value, type, :zone => timezone) - value = value.to_date if value && type == :date - end - - @attributes[attr_name] = value - end - - def read_timeliness_attribute_before_type_cast(attr_name) - @timeliness_cache && @timeliness_cache[attr_name] || @attributes[attr_name] - end - - def _clear_timeliness_cache - @timeliness_cache = {} +ActiveModel::Type::Time.class_eval do + def user_input_in_time_zone(value) + if value.is_a?(String) && ValidatesTimeliness.use_plugin_parser + dummy_time_value = value.sub(/\A(\d\d\d\d-\d\d-\d\d |)/, Date.current.to_s + ' ') + Timeliness::Parser.parse(dummy_time_value, :datetime, zone: :current) + else + value.in_time_zone end end end + +ActiveModel::Type::DateTime.class_eval do + def user_input_in_time_zone(value) + if value.is_a?(String) && ValidatesTimeliness.use_plugin_parser + Timeliness::Parser.parse(value, :datetime, zone: :current) + else + value.in_time_zone + end + end +end \ No newline at end of file diff --git a/lib/validates_timeliness/orm/active_model.rb b/lib/validates_timeliness/orm/active_model.rb index ec11032..e341743 100644 --- a/lib/validates_timeliness/orm/active_model.rb +++ b/lib/validates_timeliness/orm/active_model.rb @@ -7,14 +7,65 @@ module ValidatesTimeliness public def define_attribute_methods(*attr_names) - super.tap { define_timeliness_methods} + super.tap { define_timeliness_methods } end def undefine_attribute_methods super.tap { undefine_timeliness_attribute_methods } end + + def define_timeliness_methods(before_type_cast=false) + return if timeliness_validated_attributes.blank? + timeliness_validated_attributes.each do |attr_name| + define_attribute_timeliness_methods(attr_name, before_type_cast) + end + end + + def generated_timeliness_methods + @generated_timeliness_methods ||= Module.new { |m| + extend Mutex_m + }.tap { |mod| include mod } + end + + def undefine_timeliness_attribute_methods + generated_timeliness_methods.module_eval do + instance_methods.each { |m| undef_method(m) } + end + end + + def define_attribute_timeliness_methods(attr_name, before_type_cast=false) + define_timeliness_write_method(attr_name) + define_timeliness_before_type_cast_method(attr_name) if before_type_cast + end + + def define_timeliness_write_method(attr_name) + generated_timeliness_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 + def #{attr_name}=(value) + @timeliness_cache ||= {} + @timeliness_cache['#{attr_name}'] = value + + @attributes['#{attr_name}'] = super + end + STR + end + + def define_timeliness_before_type_cast_method(attr_name) + generated_timeliness_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 + def #{attr_name}_before_type_cast + read_timeliness_attribute_before_type_cast('#{attr_name}') + end + STR + end end - + + def read_timeliness_attribute_before_type_cast(attr_name) + @timeliness_cache && @timeliness_cache[attr_name] || @attributes[attr_name] + end + + def _clear_timeliness_cache + @timeliness_cache = {} + end + end end end diff --git a/lib/validates_timeliness/orm/active_record.rb b/lib/validates_timeliness/orm/active_record.rb index c41e9a2..9fda881 100644 --- a/lib/validates_timeliness/orm/active_record.rb +++ b/lib/validates_timeliness/orm/active_record.rb @@ -3,81 +3,8 @@ module ValidatesTimeliness module ActiveRecord extend ActiveSupport::Concern - module ClassMethods - public - - def timeliness_attribute_timezone_aware?(attr_name) - create_time_zone_conversion_attribute?(attr_name, timeliness_column_for_attribute(attr_name)) - end - - def timeliness_attribute_type(attr_name) - timeliness_column_for_attribute(attr_name).type - end - - def timeliness_column_for_attribute(attr_name) - columns_hash.fetch(attr_name.to_s) do |key| - validation_type = _validators[key.to_sym].find {|v| v.kind == :timeliness }.type.to_s - ::ActiveRecord::ConnectionAdapters::Column.new(key, nil, lookup_cast_type(validation_type), validation_type) - end - end - - def lookup_cast_type(sql_type) - case sql_type - when 'datetime' then ::ActiveRecord::Type::DateTime.new - when 'date' then ::ActiveRecord::Type::Date.new - when 'time' then ::ActiveRecord::Type::Time.new - end - end - - def define_attribute_methods - super.tap { - generated_timeliness_methods.synchronize do - return if @timeliness_methods_generated - define_timeliness_methods true - @timeliness_methods_generated = true - end - } - end - - def undefine_attribute_methods - super.tap { - generated_timeliness_methods.synchronize do - return unless @timeliness_methods_generated - undefine_timeliness_attribute_methods - @timeliness_methods_generated = false - end - } - end - # Override to overwrite methods in ActiveRecord attribute method module because in AR 4+ - # there is curious code which calls the method directly from the generated methods module - # via bind inside method_missing. This means our method in the formerly custom timeliness - # methods module was never reached. - def generated_timeliness_methods - generated_attribute_methods - end - end - - def write_timeliness_attribute(attr_name, value) - @timeliness_cache ||= {} - @timeliness_cache[attr_name] = value - - if ValidatesTimeliness.use_plugin_parser - type = self.class.timeliness_attribute_type(attr_name) - timezone = :current if self.class.timeliness_attribute_timezone_aware?(attr_name) - value = Timeliness::Parser.parse(value, type, :zone => timezone) - value = value.to_date if value && type == :date - end - - write_attribute(attr_name, value) - end - def read_timeliness_attribute_before_type_cast(attr_name) - @timeliness_cache && @timeliness_cache[attr_name] || read_attribute_before_type_cast(attr_name) - end - - def reload(*args) - _clear_timeliness_cache - super + read_attribute_before_type_cast(attr_name) end end diff --git a/lib/validates_timeliness/validator.rb b/lib/validates_timeliness/validator.rb index a31a612..357069e 100644 --- a/lib/validates_timeliness/validator.rb +++ b/lib/validates_timeliness/validator.rb @@ -101,10 +101,9 @@ module ValidatesTimeliness end def timezone_aware?(record, attr_name) - record.class.respond_to?(:timeliness_attribute_timezone_aware?) && - record.class.timeliness_attribute_timezone_aware?(attr_name) + record.class.respond_to?(:skip_time_zone_conversion_for_attributes) && + !record.class.skip_time_zone_conversion_for_attributes.include?(attr_name.to_sym) end - end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a416626..ed4808d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,6 @@ require 'rspec' +require 'byebug' require 'active_model' require 'active_model/validations' require 'active_record' diff --git a/spec/support/test_model.rb b/spec/support/test_model.rb index b8b15dc..078c4e0 100644 --- a/spec/support/test_model.rb +++ b/spec/support/test_model.rb @@ -25,7 +25,9 @@ module TestModel def type_cast(attr_name, value) return value unless value.is_a?(String) - value.send("to_#{model_attributes[attr_name.to_sym]}") rescue nil + type_name = model_attributes[attr_name.to_sym] + type = ActiveModel::Type.lookup(type_name) + type.cast(value) end end diff --git a/spec/validates_timeliness/attribute_methods_spec.rb b/spec/validates_timeliness/attribute_methods_spec.rb index 935f62f..0a55088 100644 --- a/spec/validates_timeliness/attribute_methods_spec.rb +++ b/spec/validates_timeliness/attribute_methods_spec.rb @@ -38,7 +38,7 @@ RSpec.describe ValidatesTimeliness::AttributeMethods do expect(e.redefined_birth_date_called).to be_truthy end - it 'should be undefined if model class has dynamic attribute methods reset' do + skip 'should be undefined if model class has dynamic attribute methods reset' do # Force method definitions PersonWithShim.validates_date :birth_date r = PersonWithShim.new diff --git a/spec/validates_timeliness/conversion_spec.rb b/spec/validates_timeliness/conversion_spec.rb index 7ca72b0..2505ff3 100644 --- a/spec/validates_timeliness/conversion_spec.rb +++ b/spec/validates_timeliness/conversion_spec.rb @@ -151,8 +151,8 @@ RSpec.describe ValidatesTimeliness::Conversion do it 'should return Time value for attribute method symbol which returns Time' do value = Time.mktime(2010,1,1) - person.birth_time = value - expect(evaluate_option_value(:birth_time, person)).to eq(value) + person.birth_datetime = value + expect(evaluate_option_value(:birth_datetime, person)).to eq(value) end it 'should return Time value is default zone from string time value' do @@ -167,14 +167,14 @@ RSpec.describe ValidatesTimeliness::Conversion do end it 'should return Time value in default zone from proc which returns string time' do - value = '2010-01-01 12:00:00' - expect(evaluate_option_value(lambda { value }, person)).to eq(Time.utc(2010,1,1,12,0,0)) + value = '2010-11-12 13:00:00' + expect(evaluate_option_value(lambda { value }, person)).to eq(Time.utc(2010,11,12,13,0,0)) end - it 'should return Time value for attribute method symbol which returns string time value' do - value = '2010-01-01 12:00:00' + skip 'should return Time value for attribute method symbol which returns string time value' do + value = '13:00:00' person.birth_time = value - expect(evaluate_option_value(:birth_time, person)).to eq(Time.local(2010,1,1,12,0,0)) + expect(evaluate_option_value(:birth_time, person)).to eq(Time.utc(2000,1,1,13,0,0)) end context "restriction shorthand" do diff --git a/spec/validates_timeliness/orm/active_record_spec.rb b/spec/validates_timeliness/orm/active_record_spec.rb index 1796f98..d6b11a2 100644 --- a/spec/validates_timeliness/orm/active_record_spec.rb +++ b/spec/validates_timeliness/orm/active_record_spec.rb @@ -26,6 +26,7 @@ RSpec.describe ValidatesTimeliness, 'ActiveRecord' do record.birth_date = 'not a date' record.valid? + expect(record.birth_date_before_type_cast).to eq 'not a date' expect(record.errors[:birth_date]).not_to be_empty end @@ -37,10 +38,6 @@ RSpec.describe ValidatesTimeliness, 'ActiveRecord' do end end - it 'should determine type for attribute' do - expect(Employee.timeliness_attribute_type(:birth_date)).to eq :date - end - context 'attribute timezone awareness' do let(:klass) { Class.new(ActiveRecord::Base) do @@ -79,34 +76,6 @@ RSpec.describe ValidatesTimeliness, 'ActiveRecord' do validates_datetime :birth_datetime, :allow_blank => true end - context 'value cache' do - let(:record) { EmployeeWithCache.new } - - context 'for datetime column' do - it 'should store raw value' do - record.birth_datetime = datetime_string = '2010-01-01 12:30' - - expect(record.read_timeliness_attribute_before_type_cast('birth_datetime')).to eq datetime_string - end - end - - context 'for date column' do - it 'should store raw value' do - record.birth_date = date_string = '2010-01-01' - - expect(record.read_timeliness_attribute_before_type_cast('birth_date')).to eq date_string - end - end - - context 'for time column' do - it 'should store raw value' do - record.birth_time = time_string = '12:12' - - expect(record.read_timeliness_attribute_before_type_cast('birth_time')).to eq time_string - end - end - end - context "with plugin parser" do with_config(:use_plugin_parser, true) let(:record) { EmployeeWithParser.new } @@ -118,17 +87,23 @@ RSpec.describe ValidatesTimeliness, 'ActiveRecord' do validates_datetime :birth_datetime, :allow_blank => true end + before do + allow(Timeliness::Parser).to receive(:parse).and_call_original + end + context "for a date column" do it 'should parse a string value' do - expect(Timeliness::Parser).to receive(:parse) - record.birth_date = '2010-01-01' + expect(record.birth_date).to eq(Date.new(2010, 1, 1)) + + expect(Timeliness::Parser).to have_received(:parse) end it 'should parse a invalid string value as nil' do - expect(Timeliness::Parser).to receive(:parse) - record.birth_date = 'not valid' + expect(record.birth_date).to be_nil + + expect(Timeliness::Parser).to have_received(:parse) end it 'should store a Date value after parsing string' do @@ -140,45 +115,85 @@ RSpec.describe ValidatesTimeliness, 'ActiveRecord' do end context "for a time column" do - it 'should parse a string value' do - expect(Timeliness::Parser).to receive(:parse) - - record.birth_time = '12:30' + around do |example| + time_zone_aware_types = ActiveRecord::Base.time_zone_aware_types.dup + example.call + ActiveRecord::Base.time_zone_aware_types = time_zone_aware_types end - it 'should parse a invalid string value as nil' do - expect(Timeliness::Parser).to receive(:parse) + context 'timezone aware' do + with_config(:default_timezone, 'Australia/Melbourne') - record.birth_time = 'not valid' + before do + unless ActiveRecord::Base.time_zone_aware_types.include?(:time) + ActiveRecord::Base.time_zone_aware_types.push(:time) + end + end + + it 'should parse a string value' do + record.birth_time = '12:30' + + expect(record.birth_time).to eq('12:30'.in_time_zone) + expect(Timeliness::Parser).to have_received(:parse) + end + + it 'should parse a invalid string value as nil' do + record.birth_time = 'not valid' + + expect(record.birth_time).to be_nil + expect(Timeliness::Parser).to have_received(:parse) + end + + it 'should store a Time value after parsing string' do + record.birth_time = '12:30' + + expect(record.birth_time).to eq('12:30'.in_time_zone) + expect(record.birth_time.utc_offset).to eq '12:30'.in_time_zone.utc_offset + end end - it 'should store a Time value after parsing string' do - record.birth_time = '12:30' + skip 'not timezone aware' do + before do + ActiveRecord::Base.time_zone_aware_types.delete(:time) + end - expect(record.birth_time).to be_kind_of(Time) - expect(record.birth_time).to eq Time.zone.local(2000, 1, 1, 12, 30) + it 'should parse a string value' do + record.birth_time = '12:30' + + expect(record.birth_time).to eq(Time.utc(2000,1,1,12,30)) + expect(Timeliness::Parser).to have_received(:parse) + end + + it 'should parse a invalid string value as nil' do + record.birth_time = 'not valid' + + expect(record.birth_time).to be_nil + expect(Timeliness::Parser).to have_received(:parse) + end + + it 'should store a Time value in utc' do + record.birth_time = '12:30' + + expect(record.birth_time.utc_offset).to eq Time.now.utc.utc_offset + end end end context "for a datetime column" do with_config(:default_timezone, 'Australia/Melbourne') - it 'should parse a string value' do - expect(Timeliness::Parser).to receive(:parse) - + it 'should parse a string value into Time value' do record.birth_datetime = '2010-01-01 12:00' + + expect(record.birth_datetime).to eq Time.zone.local(2010,1,1,12,00) + expect(Timeliness::Parser).to have_received(:parse) end it 'should parse a invalid string value as nil' do - expect(Timeliness::Parser).to receive(:parse) - record.birth_datetime = 'not valid' - end - it 'should parse string into Time value' do - record.birth_datetime = '2010-01-01 12:00' - - expect(record.birth_datetime).to be_kind_of(Time) + expect(record.birth_datetime).to be_nil + expect(Timeliness::Parser).to have_received(:parse) end it 'should parse string as current timezone' do @@ -189,66 +204,4 @@ RSpec.describe ValidatesTimeliness, 'ActiveRecord' do end end end - - context "reload" do - it 'should clear cache value' do - record = Employee.create! - record.birth_date = '2010-01-01' - - record.reload - - expect(record.read_timeliness_attribute_before_type_cast('birth_date')).to be_nil - end - end - - context "before_type_cast method" do - let(:record) { Employee.new } - - it 'should be defined on class if ORM supports it' do - expect(record).to respond_to(:birth_datetime_before_type_cast) - end - - it 'should return original value' do - record.birth_datetime = date_string = '2010-01-01' - - expect(record.birth_datetime_before_type_cast).to eq date_string - end - - it 'should return attribute if no attribute assignment has been made' do - datetime = Time.zone.local(2010,01,01) - Employee.create(:birth_datetime => datetime) - - record = Employee.last - expect(record.birth_datetime_before_type_cast).to match(/#{datetime.utc.to_s[0...-4]}/) - end - - context "with plugin parser" do - with_config(:use_plugin_parser, true) - - it 'should return original value' do - record.birth_datetime = date_string = '2010-01-31' - - expect(record.birth_datetime_before_type_cast).to eq date_string - end - end - - end - - context "define_attribute_methods" do - it "returns a falsy value if the attribute methods have already been generated" do - expect(Employee.define_attribute_methods).to be_falsey - end - end - - context "undefine_attribute_methods" do - it "returns remove attribute methods that have already been generated" do - Employee.define_attribute_methods - - expect(Employee.instance_methods).to include(:birth_datetime) - - Employee.undefine_attribute_methods - - expect(Employee.instance_methods).to_not include(:birth_datetime) - end - end end