rails-erd 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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