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.
- data/.gitignore +6 -2
- data/CHANGES.rdoc +16 -2
- data/Gemfile +16 -0
- data/Gemfile.lock +54 -0
- data/README.rdoc +18 -192
- data/Rakefile +58 -1
- data/VERSION +1 -1
- data/lib/rails_erd.rb +12 -30
- data/lib/rails_erd/attribute.rb +19 -20
- data/lib/rails_erd/diagram.rb +36 -36
- data/lib/rails_erd/diagram/graphviz.rb +121 -22
- data/lib/rails_erd/diagram/templates/node.erb +6 -9
- data/lib/rails_erd/domain.rb +20 -9
- data/lib/rails_erd/entity.rb +23 -0
- data/lib/rails_erd/relationship.rb +97 -8
- data/lib/rails_erd/relationship/cardinality.rb +107 -25
- data/lib/rails_erd/tasks.rake +3 -2
- data/rails-erd.gemspec +7 -5
- data/test/test_helper.rb +34 -8
- data/test/unit/attribute_test.rb +26 -4
- data/test/unit/cardinality_test.rb +105 -13
- data/test/unit/diagram_test.rb +170 -0
- data/test/unit/domain_test.rb +8 -8
- data/test/unit/entity_test.rb +72 -1
- data/test/unit/graphviz_test.rb +188 -30
- data/test/unit/rake_task_test.rb +45 -2
- data/test/unit/relationship_test.rb +273 -172
- metadata +6 -5
data/lib/rails_erd/entity.rb
CHANGED
@@ -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.
|
5
|
-
#
|
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
|
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.
|
48
|
-
# one of Cardinality::OneToOne, Cardinality::OneToMany, or
|
49
|
-
# Cardinality::ManyToMany.
|
62
|
+
# Returns the cardinality of this relationship.
|
50
63
|
def cardinality
|
51
|
-
@
|
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
|
-
|
5
|
-
|
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
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
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
|
data/lib/rails_erd/tasks.rake
CHANGED
@@ -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.
|
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-
|
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
|
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
|
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
|
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 "
|
3
|
-
require
|
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 "
|
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
|