module Neo4j
module Rails
# This module handles the getting, setting and updating of attributes or properties
# in a Railsy way. This typically means not writing anything to the DB until the
# object is saved (after validation).
#
# Externally, when we talk about properties (e.g. #property?, #property_names, #properties),
# we mean all of the stored properties for this object include the 'hidden' props
# with underscores at the beginning such as _neo_id and _classname. When we talk
# about attributes, we mean all the properties apart from those hidden ones.
module Attributes
extend ActiveSupport::Concern
included do
include ActiveModel::Dirty # track changes to attributes
include ActiveModel::MassAssignmentSecurity # handle attribute hash assignment
class_inheritable_accessor :attribute_defaults
self.attribute_defaults ||= {}
# save the original [] and []= to use as read/write to Neo4j
alias_method :read_attribute, :[]
alias_method :write_attribute, :[]=
# wrap the read/write in type conversion
alias_method_chain :read_local_property, :type_conversion
alias_method_chain :write_local_property, :type_conversion
# whenever we refer to [] or []=. use our local properties store
alias_method :[], :read_local_property
alias_method :[]=, :write_local_property
end
# The behaviour of []= changes with a Rails Model, where nothing gets written
# to Neo4j until the object is saved, during which time all the validations
# and callbacks are run to ensure correctness
def write_local_property(key, value)
key_s = key.to_s
if !@properties.has_key?(key_s) || @properties[key_s] != value
attribute_will_change!(key_s)
@properties[key_s] = value
end
value
end
# Returns the locally stored value for the key or retrieves the value from
# the DB if we don't have one
def read_local_property(key)
key = key.to_s
if @properties.has_key?(key)
@properties[key]
else
@properties[key] = (persisted? && _java_entity.has_property?(key)) ? read_attribute(key) : attribute_defaults[key]
end
end
# Mass-assign attributes. Stops any protected attributes from being assigned.
def attributes=(attributes, guard_protected_attributes = true)
attributes = sanitize_for_mass_assignment(attributes) if guard_protected_attributes
multi_parameter_attributes = []
attributes.each do |k, v|
if k.to_s.include?("(")
multi_parameter_attributes << [ k, v ]
else
respond_to?("#{k}=") ? send("#{k}=", v) : self[k] = v
end
end
assign_multiparameter_attributes(multi_parameter_attributes)
end
# Instantiates objects for all attribute classes that needs more than one constructor parameter. This is done
# by calling new on the column type or aggregation type (through composed_of) object with these parameters.
# So having the pairs written_on(1) = "2004", written_on(2) = "6", written_on(3) = "24", will instantiate
# written_on (a date type) with Date.new("2004", "6", "24"). You can also specify a typecast character in the
# parentheses to have the parameters typecasted before they're used in the constructor. Use i for Fixnum,
# f for Float, s for String, and a for Array. If all the values for a given attribute are empty, the
# attribute will be set to nil.
def assign_multiparameter_attributes(pairs)
execute_callstack_for_multiparameter_attributes(
extract_callstack_for_multiparameter_attributes(pairs)
)
end
def execute_callstack_for_multiparameter_attributes(callstack)
errors = []
callstack.each do |name, values_with_empty_parameters|
begin
# (self.class.reflect_on_aggregation(name.to_sym) || column_for_attribute(name)).klass
decl_type = self.class._decl_props[name.to_sym][:type]
raise "Not a multiparameter attribute, missing :type on property #{name} for #{self.class}" unless decl_type
# in order to allow a date to be set without a year, we must keep the empty values.
values = values_with_empty_parameters.reject { |v| v.nil? }
if values.empty?
send(name + "=", nil)
else
value = if :time == decl_type
instantiate_time_object(name, values)
elsif :date == decl_type
begin
values = values_with_empty_parameters.collect do |v| v.nil? ? 1 : v end
Date.new(*values)
rescue ArgumentError => ex # if Date.new raises an exception on an invalid date
instantiate_time_object(name, values).to_date # we instantiate Time object and convert it back to a date thus using Time's logic in handling invalid dates
end
elsif :datetime == decl_type
DateTime.new(*values)
else
raise "Unknown type #{decl_type}"
end
send(name + "=", value)
end
rescue Exception => ex
raise "error on assignment #{values.inspect} to #{name}, ex: #{ex}"
end
end
unless errors.empty?
raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes"
end
end
def instantiate_time_object(name, values)
# if self.class.send(:create_time_zone_conversion_attribute?, name, column_for_attribute(name))
# Time.zone.local(*values)
# else
Time.time_with_datetime_fallback(self.class.default_timezone, *values)
# end
end
def extract_callstack_for_multiparameter_attributes(pairs)
attributes = { }
for pair in pairs
multiparameter_name, value = pair
attribute_name = multiparameter_name.split("(").first
attributes[attribute_name] = [] unless attributes.include?(attribute_name)
parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value)
attributes[attribute_name] << [ find_parameter_position(multiparameter_name), parameter_value ]
end
attributes.each { |name, values| attributes[name] = values.sort_by{ |v| v.first }.collect { |v| v.last } }
end
def type_cast_attribute_value(multiparameter_name, value)
multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value
end
def find_parameter_position(multiparameter_name)
multiparameter_name.scan(/\(([0-9]*).*\)/).first.first
end
# Tracks the current changes and clears the changed attributes hash. Called
# after saving the object.
def clear_changes
@previously_changed = changes
@changed_attributes.clear
end
# Return the properties from the Neo4j Node, merged with those that haven't
# yet been saved
def props
ret = {}
property_names.each do |property_name|
ret[property_name] = respond_to?(property_name) ? send(property_name) : send(:[], property_name)
end
ret
end
# Return all the attributes for this model as a hash attr => value. Doesn't
# include properties that start with _.
def attributes
ret = {}
attribute_names.each do |attribute_name|
ret[attribute_name] = respond_to?(attribute_name) ? send(attribute_name) : send(:[], attribute_name)
end
ret
end
# Known properties are either in the @properties, the declared
# attributes or the property keys for the persisted node.
def property_names
keys = @properties.keys + self.class._decl_props.keys.map { |k| k.to_s }
keys += _java_entity.property_keys.to_a if persisted?
keys.flatten.uniq
end
# Known attributes are either in the @properties, the declared
# attributes or the property keys for the persisted node. Any attributes
# that start with _ are rejected
def attribute_names
property_names.reject { |property_name| property_name[0] == ?_ }
end
# Known properties are either in the @properties, the declared
# properties or the property keys for the persisted node
def property?(name)
@properties.keys.include?(name) ||
self.class._decl_props.map { |k| k.to_s }.include?(name) ||
begin
persisted? && super
rescue org.neo4j.graphdb.NotFoundException
set_deleted_properties
nil
end
end
# Return true if method_name is the name of an appropriate attribute
# method
def attribute?(name)
name[0] != ?_ && property?(name)
end
def _classname
self[:_classname]
end
# To get ActiveModel::Dirty to work, we need to be able to call undeclared
# properties as though they have get methods
def method_missing(method_id, *args, &block)
method_name = method_id.to_s
if property?(method_name)
self[method_name]
else
super
end
end
def respond_to?(method_id, include_private = false)
method_name = method_id.to_s
if property?(method_name)
true
else
super
end
end
# Wrap the getter in a conversion from Java to Ruby
def read_local_property_with_type_conversion(property)
Neo4j::TypeConverters.to_ruby(self.class, property, read_local_property_without_type_conversion(property))
end
# Wrap the setter in a conversion from Ruby to Java
def write_local_property_with_type_conversion(property, value)
self.send("#{property}_before_type_cast=", value) if respond_to?("#{property}_before_type_cast=")
write_local_property_without_type_conversion(property, Neo4j::TypeConverters.to_java(self.class, property, value))
end
end
end
end