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}),
# 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.
#
# This mixin defines a number of class methods, see #{ClassMethods}.
#
module Attributes
extend ActiveSupport::Concern
extend TxMethods
included do
include ActiveModel::Dirty # track changes to attributes
include ActiveModel::MassAssignmentSecurity # handle attribute hash assignment
class << self
attr_accessor :attribute_defaults
end
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
def self.inherited(sub_klass)
super
return if sub_klass.to_s[0..0] == '#' # this is really for anonymous test classes
setup_neo4j_subclass(sub_klass)
sub_klass.send(:define_method, :attribute_defaults) do
self.class.attribute_defaults
end
sub_klass.attribute_defaults = self.attribute_defaults.clone
# Hmm, could not do this from the Finders Mixin Module - should be moved
sub_klass.rule(:_all, :functions => Neo4j::Wrapper::Rule::Functions::Size.new) if sub_klass.respond_to?(:rule)
end
end
# Is called when a node neo4j entity is created and we need to save attributes
# @private
def init_on_create(*)
self._classname = self.class.to_s
write_default_attributes
write_changed_attributes
clear_changes
end
# Setup this mixins instance variables
# @private
def initialize_attributes(attributes)
@_properties = {}
@_properties_before_type_cast={}
self.attributes = attributes if attributes
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
def attribute_defaults
self.class.attribute_defaults
end
# Updates this resource with all the attributes from the passed-in Hash and requests that the record be saved.
# If saving fails because the resource is invalid then false will be returned.
def update_attributes(attributes)
self.attributes = attributes
save
end
tx_methods :update_attributes
# Same as {#update_attributes}, but raises an exception if saving fails.
def update_attributes!(attributes)
self.attributes = attributes
save!
end
tx_methods :update_attributes!
# @private
def reset_attributes
@_properties = {}
end
# Updates a single attribute and saves the record.
# This is especially useful for boolean flags on existing records. Also note that
#
# * Validation is skipped.
# * Callbacks are invoked.
# * Updates all the attributes that are dirty in this object.
#
def update_attribute(name, value)
respond_to?("#{name}=") ? send("#{name}=", value) : self[name] = value
save(:validate => false)
end
def hash
persisted? ? _java_entity.neo_id.hash : super
end
def to_param
persisted? ? neo_id.to_s : nil
end
def to_model
self
end
# Returns an Enumerable of all (primary) key attributes
# or nil if model.persisted? is false
def to_key
persisted? ? [id] : nil
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] = self.class._decl_props[attribute_name.to_sym] ? 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
# initialize @_properties if needed since
# we can ask property names before the object is initialized (active_support initialize callbacks, respond_to?)
@_properties ||= {}
keys = @_properties.keys + self.class._decl_props.keys.map(&: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| _invalid_attribute_name?(property_name) }
end
# Known properties are either in the @_properties, the declared
# properties or the property keys for the persisted node
def property?(name)
return false unless @_properties
@_properties.has_key?(name) ||
self.class._decl_props.has_key?(name) ||
persisted? && super
end
def property_changed?
return !@_properties.empty? unless persisted?
!!@_properties.keys.find { |k| self._java_node[k] != @_properties[k] }
end
# Return true if method_name is the name of an appropriate attribute
# method
def attribute?(name)
name[0] != ?_ && property?(name)
end
# Wrap the getter in a conversion from Java to Ruby
def read_local_property_with_type_conversion(property)
self.class._converter(property).to_ruby(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)
@_properties_before_type_cast[property.to_sym]=value if self.class._decl_props.has_key? property.to_sym
conv_value = self.class._converter(property.to_sym).to_java(value)
write_local_property_without_type_conversion(property, conv_value)
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.nil? ? attribute_defaults[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
module ClassMethods
# Returns all defined properties
def columns
self._decl_props.keys
end
# Declares a property.
# It support the following hash options:
# :default,:null,:limit,:type,:index,:converter
#
# @example Set the property type,
# class Person < Neo4j::RailsModel
# property :age, :type => Time
# end
#
# @example Set the property type,
# class Person < Neo4j::RailsModel
# property :age, :default => 0
# end
# @example
# class Person < Neo4j::RailsModel
# property :age, :null => false
# end
# Property must be there
#
# @example Property has a length limit
# class Person < Neo4j::RailsModel
# property :name, :limit => 128
# end
#
# @example Index with lucene.
# class Person < Neo4j::RailsModel
# property :name, :index => :exact
# property :year, :index => :exact, :type => Fixnum # index as fixnum too
# property :description, :index => :fulltext
# end
#
# @example Using a custom converter
# module MyConverter
# def to_java(v)
# "Java:#{v}"
# end
#
# def to_ruby(v)
# "Ruby:#{v}"
# end
#
# def index_as
# String
# end
#
# extend self
# end
#
# class Person < Neo4j::RailsModel
# property :name, :converter => MyConverter
# end
#
def property(*args)
options = args.extract_options!
args.each do |property_sym|
property_setup(property_sym, options)
end
end
protected
def property_setup(property, options)
_decl_props[property] = options
handle_property_options_for(property, options)
define_property_methods_for(property, options)
define_property_before_type_cast_methods_for(property, options)
end
def handle_property_options_for(property, options)
attribute_defaults[property.to_s] = options[:default] if options.has_key?(:default)
converter = options[:converter] || Neo4j::TypeConverters.converter(_decl_props[property][:type])
_decl_props[property][:converter] = converter
if options.include?(:index)
_decl_props[property][:index] = options[:index]
raise "Indexing boolean property is not allowed" if options[:type] && options[:type] == :boolean
index(property, :type => options[:index], :field_type => converter.index_as)
end
if options.has_key?(:null) && options[:null] === false
validates(property, :non_nil => true, :on => :create)
validates(property, :non_nil => true, :on => :update)
end
validates(property, :length => {:maximum => options[:limit]}) if options[:limit]
end
def define_property_methods_for(property, options)
unless method_defined?(property)
class_eval <<-RUBY, __FILE__, __LINE__
def #{property}
send(:[], "#{property}")
end
RUBY
end
unless method_defined?("#{property}=".to_sym)
class_eval <<-RUBY, __FILE__, __LINE__
def #{property}=(value)
send(:[]=, "#{property}", value)
end
RUBY
end
end
def define_property_before_type_cast_methods_for(property, options)
property_before_type_cast = "#{property}_before_type_cast"
class_eval <<-RUBY, __FILE__, __LINE__
def #{property_before_type_cast}=(value)
@_properties_before_type_cast[:#{property}]=value
end
def #{property_before_type_cast}
@_properties_before_type_cast.has_key?(:#{property}) ? @_properties_before_type_cast[:#{property}] : self.#{property}
end
RUBY
end
end
protected
# Ensure any defaults are stored in the DB
def write_default_attributes
self.class.attribute_defaults.each do |attribute, value|
write_attribute(attribute, Neo4j::TypeConverters.convert(value, attribute, self.class, false)) unless changed_attributes.has_key?(attribute) || _java_node.has_property?(attribute)
end
end
# Write attributes to the Neo4j DB only if they're altered
def write_changed_attributes
@_properties.each do |attribute, value|
write_attribute(attribute, value) if changed_attributes.has_key?(attribute)
end
end
def attribute_missing(method_id, *args, &block)
method_name = method_id.method_name
if property?(method_name)
self[method_name]
else
super
end
end
# TODO THIS IS ONLY NEEDED IN ACTIVEMODEL < 3.2, ?
# 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 _invalid_attribute_name?(attr_name)
attr_name.to_s[0] == ?_ && !self.class._decl_props.include?(attr_name.to_sym)
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
#TODO: Consider extracting hardcoded assignments into "Binders"
value = if Neo4j::TypeConverters::TimeConverter.convert?(decl_type)
instantiate_time_object(name, values)
elsif Neo4j::TypeConverters::DateConverter.convert?(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 Neo4j::TypeConverters::DateTimeConverter.convert?(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
def _classname
self.class.to_s
end
def _classname=(value)
write_local_property_without_type_conversion("_classname", value)
end
end
end
end