From f8689b5c7630e32cb0c21248939589923c5e596b Mon Sep 17 00:00:00 2001 From: Chris Grigg Date: Sun, 25 Jan 2015 21:22:05 -0500 Subject: [PATCH] easy type conversion for classes --- lib/neo4j.rb | 1 + lib/neo4j/shared/property.rb | 21 ++++++++-- lib/neo4j/shared/type_converters.rb | 65 ++++++++++++++++------------- lib/neo4j/shared/typecaster.rb | 9 ++++ spec/e2e/typecasting_spec.rb | 44 +++++++++++++++++++ spec/unit/persistance_spec.rb | 3 ++ spec/unit/validation_spec.rb | 3 ++ 7 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 lib/neo4j/shared/typecaster.rb create mode 100644 spec/e2e/typecasting_spec.rb diff --git a/lib/neo4j.rb b/lib/neo4j.rb index 256669a65..b5f4a0a81 100644 --- a/lib/neo4j.rb +++ b/lib/neo4j.rb @@ -31,6 +31,7 @@ require 'neo4j/shared/validations' require 'neo4j/shared/identity' require 'neo4j/shared/serialized_properties' +require 'neo4j/shared/typecaster' require 'neo4j/shared' require 'neo4j/active_rel/callbacks' diff --git a/lib/neo4j/shared/property.rb b/lib/neo4j/shared/property.rb index 1e56d6c6d..e39a37da3 100644 --- a/lib/neo4j/shared/property.rb +++ b/lib/neo4j/shared/property.rb @@ -109,6 +109,10 @@ def instantiate_object(field, values_with_empty_parameters) klass ? klass.new(*values) : values end + def magic_typecast_properties + self.class.magic_typecast_properties + end + module ClassMethods # Defines a property on the class # @@ -187,6 +191,10 @@ def attribute!(name, options = {}) end end + def magic_typecast_properties + @magic_typecast_properties ||= {} + end + private def constraint_or_index(name, options) @@ -201,19 +209,26 @@ def constraint_or_index(name, options) end def check_illegal_prop(name) - if ILLEGAL_PROPS.include?(name.to_s) - fail IllegalPropertyError, "#{name} is an illegal property" - end + fail IllegalPropertyError, "#{name} is an illegal property" if ILLEGAL_PROPS.include?(name.to_s) end # Tweaks properties def magic_properties(name, options) + magic_typecast(name, options) options[:type] ||= DateTime if name.to_sym == :created_at || name.to_sym == :updated_at # ActiveAttr does not handle "Time", Rails and Neo4j.rb 2.3 did # Convert it to DateTime in the interest of consistency options[:type] = DateTime if options[:type] == Time end + + def magic_typecast(name, options) + typecaster = Neo4j::Shared::TypeConverters.typecaster_for(options[:type]) + return unless typecaster && typecaster.respond_to?(:primitive_type) + magic_typecast_properties[name] = options[:type] + options[:type] = typecaster.primitive_type + options[:typecaster] = typecaster + end end end end diff --git a/lib/neo4j/shared/type_converters.rb b/lib/neo4j/shared/type_converters.rb index 378877773..918eacd5a 100644 --- a/lib/neo4j/shared/type_converters.rb +++ b/lib/neo4j/shared/type_converters.rb @@ -8,12 +8,10 @@ def convert_type end def to_db(value) - return nil if value.nil? Time.utc(value.year, value.month, value.day).to_i end def to_ruby(value) - return nil if value.nil? Time.at(value).utc.to_date end end @@ -29,7 +27,6 @@ def convert_type # Converts the given DateTime (UTC) value to an Integer. # DateTime values are automatically converted to UTC. def to_db(value) - return nil if value.nil? value = value.new_offset(0) if value.respond_to?(:new_offset) if value.class == Date Time.utc(value.year, value.month, value.day, 0, 0, 0).to_i @@ -38,13 +35,13 @@ def to_db(value) end end + DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S %z' def to_ruby(value) - return nil if value.nil? t = case value when Integer Time.at(value).utc when String - DateTime.strptime(value, '%Y-%m-%d %H:%M:%S %z') + DateTime.strptime(value, DATETIME_FORMAT) else fail ArgumentError, "Invalid value type for DateType property: #{value.inspect}" end @@ -63,7 +60,6 @@ def convert_type # Converts the given DateTime (UTC) value to an Integer. # Only utc times are supported ! def to_db(value) - return nil if value.nil? if value.class == Date Time.utc(value.year, value.month, value.day, 0, 0, 0).to_i else @@ -72,7 +68,6 @@ def to_db(value) end def to_ruby(value) - return nil if value.nil? Time.at(value).utc end end @@ -86,12 +81,10 @@ def convert_type end def to_db(value) - return nil if value.nil? Psych.dump(value) end def to_ruby(value) - return nil if value.nil? Psych.load(value) end end @@ -105,39 +98,53 @@ def convert_type end def to_db(value) - return nil if value.nil? value.to_json end def to_ruby(value) - return nil if value.nil? JSON.parse(value, quirks_mode: true) end end end - - def convert_properties_to(medium, properties) - # Perform type conversion - serialize = self.respond_to?(:serialized_properties) ? self.serialized_properties : {} + converter = medium == :ruby ? :to_ruby : :to_db + + properties.each_with_object({}) do |(attr, value), new_attributes| + next new_attributes if skip_conversion?(attr, value) + primitive = primitive_type(attr.to_sym) + new_attributes[attr] = converted_property(primitive, value, converter) + end + end - properties.each_with_object({}) do |key_value_pair, new_attributes| - attr, value = key_value_pair + private - # skip "secret" undeclared attributes such as uuid - next new_attributes unless self.class.attributes[attr] + def converted_property(type, value, converter) + TypeConverters.converters[type].nil? ? value : TypeConverters.send(converter, value, type) + end - type = serialize.key?(attr.to_sym) ? serialize[attr.to_sym] : self.class._attribute_type(attr) - new_attributes[attr] = if TypeConverters.converters[type].nil? - value - else - TypeConverters.send "to_#{medium}", value, type - end + # If the attribute is to be typecast using a custom converter, which converter should it use? If no, returns the type to find a native serializer. + def primitive_type(attr) + case + when serialized_properties.key?(attr) + serialized_properties[attr] + when magic_typecast_properties.key?(attr) + self.class.magic_typecast_properties[attr] + else + self.class._attribute_type(attr) end end + # Moves on if the property is either undeclared (UUID or just not included in the model) or nil and unchanged + def skip_conversion?(attr, value) + !self.class.attributes[attr] || (value.nil? && !changed_attributes.key?(attr)) + end + class << self + def typecaster_for(primitive_type) + converters.key?(primitive_type) ? converters[primitive_type] : nil + end + # Converts the value to ruby from a Neo4j database value if there is a converter for given type def to_ruby(value, type = nil) found_converter = converters[type] @@ -150,13 +157,15 @@ def to_db(value, type = nil) found_converter ? found_converter.to_db(value) : value end + def register_converter(converter) + converters[converter.convert_type] = converter + end + def converters @converters ||= begin Neo4j::Shared::TypeConverters.constants.each_with_object({}) do |constant_name, result| constant = Neo4j::Shared::TypeConverters.const_get(constant_name) - if constant.respond_to?(:convert_type) - result[constant.convert_type] = constant - end + result[constant.convert_type] = constant if constant.respond_to?(:convert_type) end end end diff --git a/lib/neo4j/shared/typecaster.rb b/lib/neo4j/shared/typecaster.rb new file mode 100644 index 000000000..2b8946bc9 --- /dev/null +++ b/lib/neo4j/shared/typecaster.rb @@ -0,0 +1,9 @@ +module Neo4j + module Shared + module Typecaster + def self.included(other) + Neo4j::Shared::TypeConverters.register_converter(other) + end + end + end +end diff --git a/spec/e2e/typecasting_spec.rb b/spec/e2e/typecasting_spec.rb new file mode 100644 index 000000000..f1d4f36b4 --- /dev/null +++ b/spec/e2e/typecasting_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe 'custom type conversion' do + class RangeConverter + class << self + def primitive_type + String + end + + def convert_type + Range + end + + def to_db(value) + value.to_s + end + + def to_ruby(value) + ends = value.to_s.split('..').map { |d| Integer(d) } + ends[0]..ends[1] + end + alias_method :call, :to_ruby + end + + include Neo4j::Shared::Typecaster + end + + class RangeConvertPerson + include Neo4j::ActiveNode + property :my_range, type: Range + end + + it 'registers the typecaster' do + expect(Neo4j::Shared::TypeConverters.converters).to have_key(Range) + end + + it 'uses the custom typecaster' do + r = RangeConvertPerson.new + r.my_range = 1..30 + r.save + r.reload + expect(r.my_range).to be_a(Range) + end +end diff --git a/spec/unit/persistance_spec.rb b/spec/unit/persistance_spec.rb index 36c73d214..1b4accf07 100644 --- a/spec/unit/persistance_spec.rb +++ b/spec/unit/persistance_spec.rb @@ -34,6 +34,7 @@ it 'creates a new node if not persisted before' do o = clazz.new(name: 'kalle', age: '42') + o.stub(:serialized_properties).and_return({}) clazz.stub(:cached_class?).and_return(false) clazz.should_receive(:neo4j_session).and_return(session) clazz.should_receive(:mapped_label_names).and_return(:MyClass) @@ -54,6 +55,7 @@ it 'updates node if already persisted before if an attribute was changed' do o = clazz.new o.name = 'sune' + o.stub(:serialized_properties).and_return({}) o.stub(:_persisted_obj).and_return(node) expect(node).to receive(:update_props).and_return(name: 'sune') o.save @@ -67,6 +69,7 @@ o = clazz.new o.stub(:props).and_return(start_props) + o.stub(:serialized_properties).and_return({}) o.class.stub(:name).and_return('MyClass') # set_classname looks for this clazz.stub(:neo4j_session).and_return(session) diff --git a/spec/unit/validation_spec.rb b/spec/unit/validation_spec.rb index a5b1b210f..b5d3166e5 100644 --- a/spec/unit/validation_spec.rb +++ b/spec/unit/validation_spec.rb @@ -37,6 +37,8 @@ def self.model_name it 'creates a new node if not persisted before' do o = clazz.new(name: 'kalle', age: '42') o.stub(:_persisted_obj).and_return(nil) + o.stub(:serialized_properties).and_return({}) + o.serialized_properties clazz.stub(:cached_class?).and_return(false) clazz.should_receive(:neo4j_session).and_return(session) node.should_receive(:props).and_return(name: 'kalle2', age: '43') @@ -49,6 +51,7 @@ def self.model_name o = clazz.new o.name = 'sune' o.stub(:_persisted_obj).and_return(node) + o.stub(:serialized_properties).and_return({}) node.should_receive(:update_props).and_return(name: 'sune') o.save.should be true end