voormedia/rails-erd

View on GitHub
lib/rails_erd/domain/relationship.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require "set"
require "active_support/core_ext/module/delegation"
require "rails_erd/domain/relationship/cardinality"

module RailsERD
  class Domain
    # Describes a relationship between two entities. A relationship is detected
    # based on Active Record associations. One relationship may represent more
    # than one association, however. Related associations are grouped together.
    # Associations are related if they share the same foreign key, or the same
    # join table in the case of many-to-many associations.
    class Relationship
      N = Cardinality::N

      class << self
        def from_associations(domain, associations) # @private :nodoc:
          assoc_groups = associations.group_by { |assoc| association_identity(assoc) }
          assoc_groups.collect { |_, assoc_group| new(domain, assoc_group.to_a) }
        end

        private

        def association_identity(association)
          Set[association_owner(association), association_target(association)]
        end

        def association_identifier(association)
          if association.macro == :has_and_belongs_to_many
            # Rails 4+ supports the join_table method, and doesn't expose it
            # as an option if it's an implicit default.
            (association.respond_to?(:join_table) && association.join_table) || association.options[:join_table]
          else
            association.options[:through] || association.send(Domain.foreign_key_method_name).to_s
          end
        end

        def association_owner(association)
          association.options[:as] ? association.options[:as].to_s.classify : association.active_record.name
        end

        def association_target(association)
          association.options[:polymorphic] ? association.class_name : association.klass.name
        end
      end

      extend Inspectable
      inspection_attributes :source, :destination

      # The domain in which this relationship is defined.
      attr_reader :domain

      # The source entity. It corresponds to the model that has defined a
      # +has_one+ or +has_many+ association with the other model.
      attr_reader :source

      # The destination entity. It corresponds to the model that has defined
      # a +belongs_to+ association with the other model.
      attr_reader :destination

      delegate :one_to_one?, :one_to_many?, :many_to_many?, :source_optional?,
        :destination_optional?, :to => :cardinality

      def initialize(domain, associations) # @private :nodoc:
        @domain = domain
        @reverse_associations, @forward_associations = partition_associations(associations)

        assoc = @forward_associations.first || @reverse_associations.first
        @source      = @domain.entity_by_name(self.class.send(:association_owner, assoc))
        @destination = @domain.entity_by_name(self.class.send(:association_target, assoc))
        @source, @destination = @destination, @source if assoc.belongs_to?
      end

      # Returns all Active Record association objects that describe this
      # relationship.
      def associations
        @forward_associations + @reverse_associations
      end

      # Returns the cardinality of this relationship.
      def cardinality
        @cardinality ||= begin
          reverse_max = any_habtm?(associations) ? N : 1
          forward_range = associations_range(@forward_associations, N)
          reverse_range = associations_range(@reverse_associations, reverse_max)
          Cardinality.new(reverse_range, forward_range)
        end
      end

      # Indicates if a relationship is indirect, that is, if it is defined
      # through other relationships. Indirect relationships are created in
      # Rails with <tt>has_many :through</tt> or <tt>has_one :through</tt>
      # association macros.
      def indirect?
        !@forward_associations.empty? and @forward_associations.all?(&:through_reflection)
      end

      # Indicates whether or not the relationship is defined by two inverse
      # associations (e.g. a +has_many+ and a corresponding +belongs_to+
      # association).
      def mutual?
        @forward_associations.any? and @reverse_associations.any?
      end

      # Indicates whether or not this relationship connects an entity with itself.
      def recursive?
        @source == @destination
      end

      # Indicates whether the destination cardinality class of this relationship
      # is equal to one. This is +true+ for one-to-one relationships only.
      def to_one?
        cardinality.cardinality_class[1] == 1
      end

      # Indicates whether the destination cardinality class of this relationship
      # is equal to infinity. This is +true+ for one-to-many or
      # many-to-many relationships only.
      def to_many?
        cardinality.cardinality_class[1] != 1
      end

      # Indicates whether the source cardinality class of this relationship
      # is equal to one. This is +true+ for one-to-one or
      # one-to-many relationships only.
      def one_to?
        cardinality.cardinality_class[0] == 1
      end

      # Indicates whether the source cardinality class of this relationship
      # is equal to infinity. This is +true+ for many-to-many relationships only.
      def many_to?
        cardinality.cardinality_class[0] != 1
      end

      # The strength of a relationship is equal to the number of associations
      # that describe it.
      def strength
        if source.generalized? then 1 else associations.size end
      end

      def <=>(other) # @private :nodoc:
        (source.name <=> other.source.name).nonzero? or (destination.name <=> other.destination.name)
      end

      private

      def partition_associations(associations)
        if any_habtm?(associations)
          # Many-to-many associations don't have a clearly defined direction.
          # We sort by name and use the first model as the source.
          source = associations.map(&:active_record).sort_by(&:name).first
          associations.partition { |association| association.active_record != source }
        else
          associations.partition(&:belongs_to?)
        end
      end

      def associations_range(associations, absolute_max)
        # The minimum of the range is the maximum value of each association
        # minimum. If there is none, it is zero by definition. The reasoning is
        # that from all associations, if only one has a required minimum, then
        # this side of the relationship has a cardinality of at least one.
        min = associations.map { |assoc| association_minimum(assoc) }.max || 0

        # The maximum of the range is the maximum value of each association
        # maximum. If there is none, it is equal to the absolute maximum. If
        # only one association has a high cardinality on this side, the
        # relationship itself has the same maximum cardinality.
        max = associations.map { |assoc| association_maximum(assoc) }.max || absolute_max

        min..max
      end

      def association_minimum(association)
        minimum = association_validators(:presence, association).any? ||
          foreign_key_required?(association) ? 1 : 0
        length_validators = association_validators(:length, association)
        length_validators.map { |v| v.options[:minimum] }.compact.max or minimum
      end

      def association_maximum(association)
        maximum = association.collection? ? N : 1
        length_validators = association_validators(:length, association)
        length_validators.map { |v| v.options[:maximum] }.compact.min or maximum
      end

      def association_validators(kind, association)
        association.active_record.validators_on(association.name).select { |v| v.kind == kind }
      end

      def any_habtm?(associations)
        associations.any? { |association| association.macro == :has_and_belongs_to_many }
      end

      def foreign_key_required?(association)
        if !association.active_record.abstract_class? and association.belongs_to?
          column = association.active_record.columns_hash[association.send(Domain.foreign_key_method_name)] and !column.null
        end
      end
    end
  end
end