module Neo4j::Shared module Property extend ActiveSupport::Concern include Neo4j::Shared::MassAssignment include Neo4j::Shared::TypecastedAttributes include ActiveModel::Dirty class UndefinedPropertyError < Neo4j::Error; end class MultiparameterAssignmentError < Neo4j::Error; end attr_reader :_persisted_obj # This list should not be statically created. All types which have converters should by type casted NEO4J_DRIVER_DATA_TYPES = [Hash, Neo4j::Driver::Types::Bytes, ActiveSupport::Duration, Neo4j::Driver::Types::Point, Neo4j::Driver::Types::OffsetTime, Neo4j::Driver::Types::LocalTime, Neo4j::Driver::Types::LocalDateTime] # TODO: Set @attribute correctly using class ActiveModel::Attribute, and after that # remove mutations_from_database and other ActiveModel::Dirty overrided methods def mutations_from_database @mutations_from_database ||= defined?(ActiveModel::ForcedMutationTracker) ? ActiveModel::ForcedMutationTracker.new(self) : ActiveModel::NullMutationTracker.instance end def inspect attribute_descriptions = inspect_attributes.map do |key, value| "#{Neo4j::ANSI::CYAN}#{key}: #{Neo4j::ANSI::CLEAR}#{value.inspect}" end.join(', ') separator = ' ' unless attribute_descriptions.empty? "#<#{Neo4j::ANSI::YELLOW}#{self.class.name}#{Neo4j::ANSI::CLEAR}#{separator}#{attribute_descriptions}>" end def initialize(attributes = nil) @attributes ||= Neo4j::AttributeSet.new({}, self.class.attributes.keys) attributes = process_attributes(attributes) modded_attributes = inject_defaults!(attributes) validate_attributes!(modded_attributes) writer_method_props = extract_writer_methods!(modded_attributes) send_props(writer_method_props) self.undeclared_properties = attributes @_persisted_obj = nil end def undeclared_properties=(_); end def inject_defaults!(starting_props) return starting_props if self.class.declared_properties.declared_property_defaults.empty? self.class.declared_properties.inject_defaults!(self, starting_props || {}) end def read_attribute(name) respond_to?(name) ? send(name) : nil end alias [] read_attribute def send_props(hash) return hash if hash.blank? hash.each { |key, value| send("#{key}=", value) } end def reload_properties!(properties) @attributes = nil convert_and_assign_attributes(properties) end private # Changes attributes hash to remove relationship keys # Raises an error if there are any keys left which haven't been defined as properties on the model # TODO: use declared_properties instead of self.attributes def validate_attributes!(attributes) return attributes if attributes.blank? invalid_properties = attributes.keys.map(&:to_s) - self.attributes.keys invalid_properties.reject! { |name| self.respond_to?("#{name}=") } fail UndefinedPropertyError, "Undefined properties: #{invalid_properties.join(',')}" if !invalid_properties.empty? end def extract_writer_methods!(attributes) return attributes if attributes.blank? {}.tap do |writer_method_props| attributes.each_key do |key| writer_method_props[key] = attributes.delete(key) if self.respond_to?("#{key}=") end end end DATE_KEY_REGEX = /\A([^\(]+)\((\d+)([ifs])\)$/ # Gives support for Rails date_select, datetime_select, time_select helpers. def process_attributes(attributes = nil) return attributes if attributes.blank? multi_parameter_attributes = {} new_attributes = {} attributes.each_pair do |key, value| if key.match(DATE_KEY_REGEX) match = key.to_s.match(DATE_KEY_REGEX) found_key = match[1] index = match[2].to_i (multi_parameter_attributes[found_key] ||= {})[index] = value.empty? ? nil : value.send("to_#{$3}") else new_attributes[key] = value end end multi_parameter_attributes.empty? ? new_attributes : process_multiparameter_attributes(multi_parameter_attributes, new_attributes) end def process_multiparameter_attributes(multi_parameter_attributes, new_attributes) multi_parameter_attributes.each_with_object(new_attributes) do |(key, values), attributes| values = (values.keys.min..values.keys.max).map { |i| values[i] } if (field = self.class.attributes[key.to_sym]).nil? fail MultiparameterAssignmentError, "error on assignment #{values.inspect} to #{key}" end attributes[key] = instantiate_object(field, values) end end def instantiate_object(field, values_with_empty_parameters) return nil if values_with_empty_parameters.all?(&:nil?) values = values_with_empty_parameters.collect { |v| v.nil? ? 1 : v } klass = field.type klass ? klass.new(*values) : values end module ClassMethods extend Forwardable def_delegators :declared_properties, :serialized_properties, :serialized_properties=, :serialize, :declared_property_defaults VALID_PROPERTY_OPTIONS = %w(type default index constraint serializer typecaster).map(&:to_sym) # Defines a property on the class # # See active_attr gem for allowed options, e.g which type # Notice, in Neo4j you don't have to declare properties before using them, see the neo4j-core api. # # @example Without type # class Person # # declare a property which can have any value # property :name # end # # @example With type and a default value # class Person # # declare a property which can have any value # property :score, type: Integer, default: 0 # end # # @example With an index # class Person # # declare a property which can have any value # property :name, index: :exact # end # # @example With a constraint # class Person # # declare a property which can have any value # property :name, constraint: :unique # end def property(name, options = {}) invalid_option_keys = options.keys.map(&:to_sym) - VALID_PROPERTY_OPTIONS fail ArgumentError, "Invalid options for property `#{name}` on `#{self.name}`: #{invalid_option_keys.join(', ')}" if invalid_option_keys.any? build_property(name, options) do |prop| attribute(prop) end end # @param [Symbol] name The property name # @param [Neo4j::Shared::AttributeDefinition] attr_def A cloned AttributeDefinition to reuse # @param [Hash] options An options hash to use in the new property definition def inherit_property(name, attr_def, options = {}) build_property(name, options) do |prop_name| attributes[prop_name] = attr_def end end def build_property(name, options) decl_prop = DeclaredProperty.new(name, options).tap do |prop| prop.register declared_properties.register(prop) yield name constraint_or_index(name, options) end # If this class has already been inherited, make sure subclasses inherit property subclasses.each do |klass| klass.inherit_property name, decl_prop.clone, declared_properties[name].options end decl_prop end def undef_property(name) undef_constraint_or_index(name) declared_properties.unregister(name) attribute_methods(name).each { |method| undef_method(method) } end def declared_properties @_declared_properties ||= DeclaredProperties.new(self) end # @return [Hash] A frozen hash of all model properties with nil values. It is used during node loading and prevents # an extra call to a slow dependency method. def attributes_nil_hash declared_properties.attributes_nil_hash end def extract_association_attributes!(props) props end private def attribute!(name) remove_instance_variable('@attribute_methods_generated') if instance_variable_defined?('@attribute_methods_generated') define_attribute_methods([name]) unless attribute_names.include?(name) attributes[name.to_s] = declared_properties[name] define_method("#{name}=") do |value| typecast_value = if NEO4J_DRIVER_DATA_TYPES.include?(_attribute_type(name)) value else typecast_attribute(_attribute_typecaster(name), value) end send("#{name}_will_change!") unless typecast_value == read_attribute(name) super(value) end end def constraint_or_index(name, options) # either constraint or index, do not set both if options[:constraint] fail "unknown constraint type #{options[:constraint]}, only :unique supported" if options[:constraint] != :unique constraint(name, type: :unique) elsif options[:index] fail "unknown index type #{options[:index]}, only :exact supported" if options[:index] != :exact index(name) if options[:index] == :exact end end def undef_constraint_or_index(name) prop = declared_properties[name] return unless prop.index_or_constraint? type = prop.constraint? ? :constraint : :index send(:"drop_#{type}", name) end end end end