lib/rails_erd/relationship.rb in rails-erd-0.2.0 vs lib/rails_erd/relationship.rb in rails-erd-0.3.0

- old
+ new

@@ -1,11 +1,16 @@ +require "rails_erd/relationship/cardinality" + module RailsERD # Describes a relationship between two entities. A relationship is detected # based on Active Record associations. One relationship may represent more - # than one association, however. Associations that share the same foreign - # key are grouped together. + # 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| Relationship.new(domain, assoc_group.to_a) } end @@ -26,15 +31,25 @@ 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 = *associations.partition(&:belongs_to?) - + @reverse_associations, @forward_associations = *unless any_habtm?(associations) + associations.partition(&:belongs_to?) + else + # 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.first.active_record + associations.partition { |association| association.active_record == source } + end + assoc = @forward_associations.first || @reverse_associations.first @source, @destination = @domain.entity_for(assoc.active_record), @domain.entity_for(assoc.klass) @source, @destination = @destination, @source if assoc.belongs_to? end @@ -42,15 +57,18 @@ # relationship. def associations @forward_associations + @reverse_associations end - # Returns the cardinality of this relationship. The cardinality may be - # one of Cardinality::OneToOne, Cardinality::OneToMany, or - # Cardinality::ManyToMany. + # Returns the cardinality of this relationship. def cardinality - @forward_associations.collect { |assoc| Cardinality.from_macro(assoc.macro) }.max or Cardinality::OneToMany + @cardinality ||= begin + reverse_max = any_habtm?(associations) ? N : 1 + forward_range = associations_range(@source.model, @forward_associations, N) + reverse_range = associations_range(@destination.model, @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> @@ -69,10 +87,36 @@ # 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 associations.size end @@ -81,8 +125,53 @@ "#<#{self.class}:0x%.14x @source=#{source} @destination=#{destination}>" % (object_id << 1) end def <=>(other) # @private :nodoc: (source.name <=> other.source.name).nonzero? or (destination.name <=> other.destination.name) + end + + private + + def associations_range(model, 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(model, 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(model, assoc) }.max || absolute_max + + min..max + end + + def association_minimum(model, association) + minimum = association_validators(:presence, model, association).any? || + foreign_key_required?(model, association) ? 1 : 0 + length_validators = association_validators(:length, model, association) + length_validators.map { |v| v.options[:minimum] }.compact.max or minimum + end + + def association_maximum(model, association) + maximum = association.collection? ? N : 1 + length_validators = association_validators(:length, model, association) + length_validators.map { |v| v.options[:maximum] }.compact.min or maximum + end + + def association_validators(kind, model, association) + model.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?(model, association) + if association.belongs_to? + key = model.arel_table.columns.find { |column| column.name == association.primary_key_name } and !key.null + end end end end