neo4jrb/neo4j

View on GitHub
lib/active_graph/node/has_n/association.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require 'active_support/inflector/inflections'
require 'active_graph/class_arguments'

module ActiveGraph
  module Node
    module HasN
      class Association
        include ActiveGraph::Shared::RelTypeConverters
        include ActiveGraph::Node::Dependent::AssociationMethods
        include ActiveGraph::Node::HasN::AssociationCypherMethods

        attr_reader :type, :name, :relationship, :direction, :dependent, :model_class

        def initialize(type, direction, name, options = {type: nil})
          validate_init_arguments(type, direction, name, options)
          @type = type.to_sym
          @name = name
          @direction = direction.to_sym
          @target_class_name_from_name = name.to_s.classify
          apply_vars_from_options(options)
        end

        def derive_model_class
          refresh_model_class! if pending_model_refresh?
          return @model_class unless @model_class.nil?
          return nil if relationship_class.nil?
          dir_class = direction == :in ? :from_class : :to_class
          return false if relationship_class.send(dir_class).to_s.to_sym == :any
          relationship_class.send(dir_class)
        end

        def refresh_model_class!
          @pending_model_refresh = @target_classes_or_nil = nil

          # Using #to_s on purpose here to take care of classes/strings/symbols
          @model_class = ClassArguments.constantize_argument(@model_class.to_s) if @model_class
        end

        def queue_model_refresh!
          @pending_model_refresh = true
        end

        def target_class_option(model_class)
          case model_class
          when nil
            @target_class_name_from_name ? "#{association_model_namespace}::#{@target_class_name_from_name}" : @target_class_name_from_name
          when Array
            model_class.map { |sub_model_class| target_class_option(sub_model_class) }
          when false
            false
          else
            model_class.to_s[0, 2] == '::' ? model_class.to_s : "::#{model_class}"
          end
        end

        def pending_model_refresh?
          !!@pending_model_refresh
        end

        def target_class_names
          option = target_class_option(derive_model_class)

          @target_class_names ||= if option.is_a?(Array)
                                    option.map(&:to_s)
                                  elsif option
                                    [option.to_s]
                                  end
        end

        def inverse_of?(other)
          origin_association == other
        end

        def target_classes
          ClassArguments.constantize_argument(target_class_names)
        end

        def target_classes_or_nil
          @target_classes_or_nil ||= discovered_model if target_class_names
        end

        def target_where_clause(var = name)
          return if model_class == false

          Array.new(target_classes).map do |target_class|
            "#{var}:`#{target_class.mapped_label_name}`"
          end.join(' OR ')
        end

        def discovered_model
          target_classes.select do |constant|
            constant.ancestors.include?(::ActiveGraph::Node)
          end
        end

        def target_class
          return @target_class if @target_class

          return if !(target_class_names && target_class_names.size == 1)

          class_const = ClassArguments.constantize_argument(target_class_names[0])

          @target_class = class_const
        end

        def relationship_type(create = false)
          case
          when relationship_class
            relationship_class_type
          when !@relationship_type.nil?
            @relationship_type
          when @origin
            origin_type
          else
            # I think that this line is no longer readed since we require either
            # `type`, `rel_class`, or `origin` in associations
            (create || exceptional_target_class?) && decorated_rel_type(@name)
          end
        end

        attr_reader :relationship_class_name

        def relationship_class_type
          relationship_class.type.to_sym
        end

        def relationship_class
          @relationship_class ||= @relationship_class_name && @relationship_class_name.constantize
        end

        def unique?
          return relationship_class.unique? if rel_class?
          @origin ? origin_association.unique? : !!@unique
        end

        def creates_unique_option
          @unique || :none
        end

        def create_method
          unique? ? :create_unique : :create
        end

        def _create_relationship(start_object, node_or_nodes, properties)
          RelFactory.create(start_object, node_or_nodes, properties, self)
        end

        def relationship_class?
          !!relationship_class
        end
        alias rel_class? relationship_class?

        private

        def association_model_namespace
          ActiveGraph::Config.association_model_namespace_string
        end

        def get_direction(create, reverse = false)
          dir = (create && @direction == :both) ? :out : @direction
          if reverse
            case dir
            when :in then :out
            when :out then :in
            else :both
            end
          else
            dir
          end
        end

        def origin_association
          target_class && target_class.associations[@origin]
        end

        def origin_type
          origin_association.relationship_type
        end

        private

        def apply_vars_from_options(options)
          @relationship_class_name = options[:rel_class] && options[:rel_class].to_s
          @relationship_type = options[:type] && options[:type].to_sym

          @model_class = options[:model_class]
          @origin = options[:origin] && options[:origin].to_sym
          @dependent = options[:dependent].try(:to_sym)
          @unique = options[:unique]
        end

        # Return basic details about association as declared in the model
        # @example
        #   has_many :in, :bands, type: :has_band
        def base_declaration
          "#{type} #{direction.inspect}, #{name.inspect}"
        end

        def validate_init_arguments(type, direction, name, options)
          validate_association_options!(name, options)
          validate_option_combinations(options)
          validate_dependent(options[:dependent].try(:to_sym))
          check_valid_type_and_dir(type, direction)
        end

        # the ":labels" option is not used by the association per-say.
        # Instead, if provided,it is used by the association getter as a default getter options argument
        VALID_ASSOCIATION_OPTION_KEYS = [:type, :origin, :model_class, :rel_class, :dependent, :before, :after, :unique, :labels]

        def validate_association_options!(_association_name, options)
          ClassArguments.validate_argument!(options[:model_class], 'model_class')
          ClassArguments.validate_argument!(options[:rel_class], 'rel_class')

          message = case
                    when (message = type_keys_error_message(options.keys))
                      message
                    when !(unknown_keys = options.keys - VALID_ASSOCIATION_OPTION_KEYS).empty?
                      "Unknown option(s) specified: #{unknown_keys.join(', ')}"
                    end

          fail ArgumentError, message if message
        end

        def type_keys_error_message(keys)
          type_keys = (keys & [:type, :origin, :rel_class])
          if type_keys.size > 1
            "Only one of 'type', 'origin', or 'rel_class' options are allowed for associations"
          elsif type_keys.empty?
            "The 'type' option must be specified( even if it is `nil`) or `origin`/`rel_class` must be specified"
          end
        end

        def check_valid_type_and_dir(type, direction)
          fail ArgumentError, "Invalid association type: #{type.inspect} (valid value: :has_many and :has_one)" if ![:has_many, :has_one].include?(type.to_sym)
          fail ArgumentError, "Invalid direction: #{direction.inspect} (valid value: :out, :in, and :both)" if ![:out, :in, :both].include?(direction.to_sym)
        end

        def validate_option_combinations(options)
          [[:type, :origin],
           [:type, :rel_class],
           [:origin, :rel_class]].each do |key1, key2|
            if options[key1] && options[key2]
              fail ArgumentError, "Cannot specify both :#{key1} and :#{key2} (#{base_declaration})"
            end
          end
        end

        # Determine if model class as derived from the association name would be different than the one specified via the model_class key
        # @example
        #   has_many :friends                 # Would return false
        #   has_many :friends, model_class: Friend  # Would return false
        #   has_many :friends, model_class: Person  # Would return true
        def exceptional_target_class?
          # TODO: Exceptional if target_class.nil?? (when model_class false)

          target_class && target_class.name != @target_class_name_from_name
        end

        def validate_origin!
          return if not @origin

          association = origin_association

          message = case
                    when !target_class
                      'Cannot use :origin without a model_class (implied or explicit)'
                    when !association
                      "Origin `#{@origin.inspect}` association not found for #{target_class} (specified in #{base_declaration})"
                    when @direction == association.direction
                      "Origin `#{@origin.inspect}` (specified in #{base_declaration}) has same direction `#{@direction}`)"
                    end

          fail ArgumentError, message if message
        end
      end
    end
  end
end