rails-erd 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -2,6 +2,12 @@ module RailsERD
2
2
  # Entities represent your Active Record models. Entities may be connected
3
3
  # to other entities.
4
4
  class Entity
5
+ class << self
6
+ def from_models(domain, models) # @private :nodoc:
7
+ models.collect { |model| new domain, model }.sort
8
+ end
9
+ end
10
+
5
11
  # The domain in which this entity resides.
6
12
  attr_reader :domain
7
13
 
@@ -23,11 +29,28 @@ module RailsERD
23
29
  @domain.relationships_for(@model)
24
30
  end
25
31
 
32
+ # Returns the parent entity, if this entity is a descendant.
33
+ def parent
34
+ @domain.entity_for(@model.superclass) if descendant?
35
+ end
36
+
26
37
  # Returns +true+ if this entity has any relationships with other models,
27
38
  # +false+ otherwise.
28
39
  def connected?
29
40
  relationships.any?
30
41
  end
42
+
43
+ # Returns +true+ if this entity has no relationships with any other models,
44
+ # +false+ otherwise. Opposite of +connected?+.
45
+ def disconnected?
46
+ relationships.none?
47
+ end
48
+
49
+ # Returns +true+ if this entity descends from another entity, and is
50
+ # represented in the same table as its parent.
51
+ def descendant?
52
+ !@model.descends_from_active_record?
53
+ end
31
54
 
32
55
  # Returns the name of this entity, which is the class name of the
33
56
  # corresponding model.
@@ -1,9 +1,14 @@
1
+ require "rails_erd/relationship/cardinality"
2
+
1
3
  module RailsERD
2
4
  # Describes a relationship between two entities. A relationship is detected
3
5
  # based on Active Record associations. One relationship may represent more
4
- # than one association, however. Associations that share the same foreign
5
- # key are grouped together.
6
+ # than one association, however. Related associations are grouped together.
7
+ # Associations are related if they share the same foreign key, or the same
8
+ # join table in the case of many-to-many associations.
6
9
  class Relationship
10
+ N = Cardinality::N
11
+
7
12
  class << self
8
13
  def from_associations(domain, associations) # @private :nodoc:
9
14
  assoc_groups = associations.group_by { |assoc| association_identity(assoc) }
@@ -28,11 +33,21 @@ module RailsERD
28
33
  # The destination entity. It corresponds to the model that has defined
29
34
  # a +belongs_to+ association with the other model.
30
35
  attr_reader :destination
36
+
37
+ delegate :one_to_one?, :one_to_many?, :many_to_many?, :source_optional?,
38
+ :destination_optional?, :to => :cardinality
31
39
 
32
40
  def initialize(domain, associations) # @private :nodoc:
33
41
  @domain = domain
34
- @reverse_associations, @forward_associations = *associations.partition(&:belongs_to?)
35
-
42
+ @reverse_associations, @forward_associations = *unless any_habtm?(associations)
43
+ associations.partition(&:belongs_to?)
44
+ else
45
+ # Many-to-many associations don't have a clearly defined direction.
46
+ # We sort by name and use the first model as the source.
47
+ source = associations.first.active_record
48
+ associations.partition { |association| association.active_record == source }
49
+ end
50
+
36
51
  assoc = @forward_associations.first || @reverse_associations.first
37
52
  @source, @destination = @domain.entity_for(assoc.active_record), @domain.entity_for(assoc.klass)
38
53
  @source, @destination = @destination, @source if assoc.belongs_to?
@@ -44,11 +59,14 @@ module RailsERD
44
59
  @forward_associations + @reverse_associations
45
60
  end
46
61
 
47
- # Returns the cardinality of this relationship. The cardinality may be
48
- # one of Cardinality::OneToOne, Cardinality::OneToMany, or
49
- # Cardinality::ManyToMany.
62
+ # Returns the cardinality of this relationship.
50
63
  def cardinality
51
- @forward_associations.collect { |assoc| Cardinality.from_macro(assoc.macro) }.max or Cardinality::OneToMany
64
+ @cardinality ||= begin
65
+ reverse_max = any_habtm?(associations) ? N : 1
66
+ forward_range = associations_range(@source.model, @forward_associations, N)
67
+ reverse_range = associations_range(@destination.model, @reverse_associations, reverse_max)
68
+ Cardinality.new(reverse_range, forward_range)
69
+ end
52
70
  end
53
71
 
54
72
  # Indicates if a relationship is indirect, that is, if it is defined
@@ -71,6 +89,32 @@ module RailsERD
71
89
  @source == @destination
72
90
  end
73
91
 
92
+ # Indicates whether the destination cardinality class of this relationship
93
+ # is equal to one. This is +true+ for one-to-one relationships only.
94
+ def to_one?
95
+ cardinality.cardinality_class[1] == 1
96
+ end
97
+
98
+ # Indicates whether the destination cardinality class of this relationship
99
+ # is equal to infinity. This is +true+ for one-to-many or
100
+ # many-to-many relationships only.
101
+ def to_many?
102
+ cardinality.cardinality_class[1] != 1
103
+ end
104
+
105
+ # Indicates whether the source cardinality class of this relationship
106
+ # is equal to one. This is +true+ for one-to-one or
107
+ # one-to-many relationships only.
108
+ def one_to?
109
+ cardinality.cardinality_class[0] == 1
110
+ end
111
+
112
+ # Indicates whether the source cardinality class of this relationship
113
+ # is equal to infinity. This is +true+ for many-to-many relationships only.
114
+ def many_to?
115
+ cardinality.cardinality_class[0] != 1
116
+ end
117
+
74
118
  # The strength of a relationship is equal to the number of associations
75
119
  # that describe it.
76
120
  def strength
@@ -84,5 +128,50 @@ module RailsERD
84
128
  def <=>(other) # @private :nodoc:
85
129
  (source.name <=> other.source.name).nonzero? or (destination.name <=> other.destination.name)
86
130
  end
131
+
132
+ private
133
+
134
+ def associations_range(model, associations, absolute_max)
135
+ # The minimum of the range is the maximum value of each association
136
+ # minimum. If there is none, it is zero by definition. The reasoning is
137
+ # that from all associations, if only one has a required minimum, then
138
+ # this side of the relationship has a cardinality of at least one.
139
+ min = associations.map { |assoc| association_minimum(model, assoc) }.max || 0
140
+
141
+ # The maximum of the range is the maximum value of each association
142
+ # maximum. If there is none, it is equal to the absolute maximum. If
143
+ # only one association has a high cardinality on this side, the
144
+ # relationship itself has the same maximum cardinality.
145
+ max = associations.map { |assoc| association_maximum(model, assoc) }.max || absolute_max
146
+
147
+ min..max
148
+ end
149
+
150
+ def association_minimum(model, association)
151
+ minimum = association_validators(:presence, model, association).any? ||
152
+ foreign_key_required?(model, association) ? 1 : 0
153
+ length_validators = association_validators(:length, model, association)
154
+ length_validators.map { |v| v.options[:minimum] }.compact.max or minimum
155
+ end
156
+
157
+ def association_maximum(model, association)
158
+ maximum = association.collection? ? N : 1
159
+ length_validators = association_validators(:length, model, association)
160
+ length_validators.map { |v| v.options[:maximum] }.compact.min or maximum
161
+ end
162
+
163
+ def association_validators(kind, model, association)
164
+ model.validators_on(association.name).select { |v| v.kind == kind }
165
+ end
166
+
167
+ def any_habtm?(associations)
168
+ associations.any? { |association| association.macro == :has_and_belongs_to_many }
169
+ end
170
+
171
+ def foreign_key_required?(model, association)
172
+ if association.belongs_to?
173
+ key = model.arel_table.columns.find { |column| column.name == association.primary_key_name } and !key.null
174
+ end
175
+ end
87
176
  end
88
177
  end
@@ -1,35 +1,117 @@
1
1
  module RailsERD
2
2
  class Relationship
3
3
  class Cardinality
4
- CARDINALITY_NAMES = %w{one_to_one one_to_many many_to_many} # @private :nodoc:
5
- ORDER = {} # @private :nodoc:
4
+ N = Infinity = 1.0/0 # And beyond.
5
+
6
+ CLASSES = {
7
+ [1, 1] => :one_to_one,
8
+ [1, N] => :one_to_many,
9
+ [N, 1] => :many_to_one,
10
+ [N, N] => :many_to_many
11
+ } # @private :nodoc:
6
12
 
7
- class << self
8
- # Returns the cardinality as a symbol.
9
- attr_reader :type
10
-
11
- def from_macro(macro) # @private :nodoc:
12
- case macro
13
- when :has_and_belongs_to_many then ManyToMany
14
- when :has_many then OneToMany
15
- when :has_one then OneToOne
16
- end
17
- end
18
-
19
- def <=>(other) # @private :nodoc:
20
- ORDER[self] <=> ORDER[other]
21
- end
22
-
23
- CARDINALITY_NAMES.each do |cardinality|
24
- define_method :"#{cardinality}?" do
25
- type == cardinality
13
+ # Returns a range that indicates the source (left) cardinality.
14
+ attr_reader :source_range
15
+
16
+ # Returns a range that indicates the destination (right) cardinality.
17
+ attr_reader :destination_range
18
+
19
+ # Create a new cardinality based on a source range and a destination
20
+ # range. These ranges describe which number of values are valid.
21
+ def initialize(source_range, destination_range) # @private :nodoc:
22
+ @source_range = compose_range(source_range)
23
+ @destination_range = compose_range(destination_range)
24
+ end
25
+
26
+ # Returns the name of this cardinality, based on its two cardinal
27
+ # numbers (for source and destination). Can be any of
28
+ # +:one_to_one:+, +:one_to_many+, or +:many_to_many+. The name
29
+ # +:many_to_one+ also exists, but Rails ERD always normalises these
30
+ # kinds of relationships by inversing them, so they become
31
+ # +:one_to_many+ associations.
32
+ #
33
+ # You can also call the equivalent method with a question mark, which
34
+ # will return true if the name corresponds to that method. For example:
35
+ #
36
+ # cardinality.one_to_one?
37
+ # #=> true
38
+ # cardinality.one_to_many?
39
+ # #=> false
40
+ def name
41
+ CLASSES[cardinality_class]
42
+ end
43
+
44
+ # Returns +true+ if the source (left side) is not mandatory.
45
+ def source_optional?
46
+ source_range.first < 1
47
+ end
48
+
49
+ # Returns +true+ if the destination (right side) is not mandatory.
50
+ def destination_optional?
51
+ destination_range.first < 1
52
+ end
53
+
54
+ # Returns the inverse cardinality. Destination becomes source, source
55
+ # becomes destination.
56
+ def inverse
57
+ self.class.new destination_range, source_range
58
+ end
59
+
60
+ CLASSES.each do |cardinality_class, name|
61
+ class_eval <<-RUBY
62
+ def #{name}?
63
+ cardinality_class == #{cardinality_class.inspect}
26
64
  end
27
- end
65
+ RUBY
66
+ end
67
+
68
+ def ==(other) # @private :nodoc:
69
+ source_range == other.source_range and destination_range == other.destination_range
70
+ end
71
+
72
+ def <=>(other) # @private :nodoc:
73
+ (cardinality_class <=> other.cardinality_class).nonzero? or
74
+ compare_with(other) { |x| x.source_range.first + x.destination_range.first }.nonzero? or
75
+ compare_with(other) { |x| x.source_range.last + x.destination_range.last }.nonzero? or
76
+ compare_with(other) { |x| x.source_range.last }.nonzero? or
77
+ compare_with(other) { |x| x.destination_range.last }
78
+ end
79
+
80
+ def inspect # @private :nodoc:
81
+ "#<#{self.class}:0x%.14x (%s,%s) => (%s,%s)>" %
82
+ [object_id << 1, source_range.first, source_range.last, destination_range.first, destination_range.last]
83
+ end
84
+
85
+ # Returns an array with the cardinality classes for the source and
86
+ # destination of this cardinality. Possible return values are:
87
+ # <tt>[1, 1]</tt>, <tt>[1, N]</tt>, <tt>[N, N]</tt>, and (in theory)
88
+ # <tt>[N, 1]</tt>.
89
+ def cardinality_class
90
+ [source_cardinality_class, destination_cardinality_class]
28
91
  end
29
92
 
30
- CARDINALITY_NAMES.each_with_index do |cardinality, i|
31
- klass = Cardinality.const_set cardinality.camelize.to_sym, Class.new(Cardinality) { @type = cardinality }
32
- ORDER[klass] = i
93
+ protected
94
+
95
+ # The cardinality class of the source (left side). Either +1+ or +Infinity+.
96
+ def source_cardinality_class
97
+ source_range.last == 1 ? 1 : N
98
+ end
99
+
100
+ # The cardinality class of the destination (right side). Either +1+ or +Infinity+.
101
+ def destination_cardinality_class
102
+ destination_range.last == 1 ? 1 : N
103
+ end
104
+
105
+ private
106
+
107
+ def compose_range(r)
108
+ return r..r if r.kind_of?(Integer) && r > 0
109
+ return (r.begin)..(r.end - 1) if r.exclude_end?
110
+ r
111
+ end
112
+
113
+ def compare_with(other, &block)
114
+ yield(self) <=> yield(other)
33
115
  end
34
116
  end
35
117
  end
@@ -6,8 +6,9 @@ namespace :erd do
6
6
  task :options do
7
7
  (RailsERD.options.keys.map(&:to_s) & ENV.keys).each do |option|
8
8
  RailsERD.options[option.to_sym] = case ENV[option]
9
- when "true" then true
10
- when "false" then false
9
+ when "true", "yes" then true
10
+ when "false", "no" then false
11
+ when /,/ then ENV[option].split(/\s*,\s*/).map(&:to_sym)
11
12
  else ENV[option].to_sym
12
13
  end
13
14
  end
data/rails-erd.gemspec CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{rails-erd}
8
- s.version = "0.2.0"
8
+ s.version = "0.3.0"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Rolf Timmermans"]
12
- s.date = %q{2010-09-21}
12
+ s.date = %q{2010-10-03}
13
13
  s.description = %q{Automatically generate an entity-relationship diagram (ERD) for your Rails models.}
14
14
  s.email = %q{r.timmermans@voormedia.com}
15
15
  s.extra_rdoc_files = [
@@ -19,6 +19,8 @@ Gem::Specification.new do |s|
19
19
  s.files = [
20
20
  ".gitignore",
21
21
  "CHANGES.rdoc",
22
+ "Gemfile",
23
+ "Gemfile.lock",
22
24
  "LICENSE",
23
25
  "README.rdoc",
24
26
  "Rakefile",
@@ -70,18 +72,18 @@ Gem::Specification.new do |s|
70
72
 
71
73
  if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
72
74
  s.add_runtime_dependency(%q<activerecord>, ["~> 3.0.0"])
73
- s.add_runtime_dependency(%q<activesupport>, ["~> 3.0.0"])
75
+ s.add_runtime_dependency(%q<activesupport>, ["~> 3.0"])
74
76
  s.add_runtime_dependency(%q<ruby-graphviz>, ["~> 0.9.17"])
75
77
  s.add_development_dependency(%q<sqlite3-ruby>, [">= 0"])
76
78
  else
77
79
  s.add_dependency(%q<activerecord>, ["~> 3.0.0"])
78
- s.add_dependency(%q<activesupport>, ["~> 3.0.0"])
80
+ s.add_dependency(%q<activesupport>, ["~> 3.0"])
79
81
  s.add_dependency(%q<ruby-graphviz>, ["~> 0.9.17"])
80
82
  s.add_dependency(%q<sqlite3-ruby>, [">= 0"])
81
83
  end
82
84
  else
83
85
  s.add_dependency(%q<activerecord>, ["~> 3.0.0"])
84
- s.add_dependency(%q<activesupport>, ["~> 3.0.0"])
86
+ s.add_dependency(%q<activesupport>, ["~> 3.0"])
85
87
  s.add_dependency(%q<ruby-graphviz>, ["~> 0.9.17"])
86
88
  s.add_dependency(%q<sqlite3-ruby>, [">= 0"])
87
89
  end
data/test/test_helper.rb CHANGED
@@ -1,17 +1,15 @@
1
1
  require "rubygems"
2
- require "test/unit"
3
- require "active_support/test_case"
2
+ require "bundler"
3
+ Bundler.require
4
4
 
5
+ require "test/unit"
5
6
  require "rails_erd/domain"
6
7
 
7
- require "active_record"
8
- require "sqlite3"
9
-
10
8
  ActiveRecord::Base.establish_connection :adapter => "sqlite3", :database => ":memory:"
11
9
 
12
- include RailsERD
13
-
14
10
  class ActiveSupport::TestCase
11
+ include RailsERD
12
+
15
13
  teardown :reset_domain
16
14
 
17
15
  def create_table(table, columns = {}, pk = nil)
@@ -58,12 +56,40 @@ class ActiveSupport::TestCase
58
56
  end
59
57
 
60
58
  def create_simple_domain
61
- create_model "Foo", :bar => :references do
59
+ create_model "Beer", :bar => :references do
62
60
  belongs_to :bar
63
61
  end
64
62
  create_model "Bar"
65
63
  end
66
64
 
65
+ def create_one_to_one_assoc_domain
66
+ create_model "One" do
67
+ has_one :other
68
+ end
69
+ create_model "Other", :one => :references do
70
+ belongs_to :one
71
+ end
72
+ end
73
+
74
+ def create_one_to_many_assoc_domain
75
+ create_model "One" do
76
+ has_many :many
77
+ end
78
+ create_model "Many", :one => :references do
79
+ belongs_to :one
80
+ end
81
+ end
82
+
83
+ def create_many_to_many_assoc_domain
84
+ create_model "Many" do
85
+ has_and_belongs_to_many :more
86
+ end
87
+ create_model "More" do
88
+ has_and_belongs_to_many :many
89
+ end
90
+ create_table "manies_mores", :many_id => :integer, :more_id => :integer
91
+ end
92
+
67
93
  private
68
94
 
69
95
  def reset_domain