rails-erd 0.3.0 → 0.4.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 +1 -0
- data/CHANGES.rdoc +17 -1
- data/Gemfile +3 -2
- data/Gemfile.lock +8 -4
- data/README.md +60 -0
- data/Rakefile +10 -50
- data/VERSION +1 -1
- data/lib/rails_erd.rb +28 -1
- data/lib/rails_erd/diagram.rb +66 -33
- data/lib/rails_erd/diagram/graphviz.rb +123 -92
- data/lib/rails_erd/diagram/templates/node.erb +2 -2
- data/lib/rails_erd/domain.rb +51 -23
- data/lib/rails_erd/domain/attribute.rb +102 -0
- data/lib/rails_erd/domain/entity.rb +102 -0
- data/lib/rails_erd/domain/relationship.rb +189 -0
- data/lib/rails_erd/domain/relationship/cardinality.rb +118 -0
- data/lib/rails_erd/domain/specialization.rb +58 -0
- data/lib/rails_erd/railtie.rb +1 -1
- data/rails-erd.gemspec +19 -16
- data/test/test_helper.rb +21 -5
- data/test/unit/attribute_test.rb +35 -8
- data/test/unit/cardinality_test.rb +41 -35
- data/test/unit/diagram_test.rb +130 -43
- data/test/unit/domain_test.rb +131 -8
- data/test/unit/entity_test.rb +150 -46
- data/test/unit/graphviz_test.rb +52 -14
- data/test/unit/rake_task_test.rb +2 -2
- data/test/unit/relationship_test.rb +73 -24
- data/test/unit/specialization_test.rb +57 -0
- metadata +15 -13
- data/README.rdoc +0 -51
- data/lib/rails_erd/attribute.rb +0 -95
- data/lib/rails_erd/entity.rb +0 -73
- data/lib/rails_erd/relationship.rb +0 -177
- data/lib/rails_erd/relationship/cardinality.rb +0 -118
@@ -0,0 +1,102 @@
|
|
1
|
+
module RailsERD
|
2
|
+
class Domain
|
3
|
+
# Entities represent your Active Record models. Entities may be connected
|
4
|
+
# to other entities.
|
5
|
+
class Entity
|
6
|
+
class << self
|
7
|
+
def from_models(domain, models) # @private :nodoc:
|
8
|
+
(concrete_from_models(domain, models) + abstract_from_models(domain, models)).sort
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def concrete_from_models(domain, models)
|
14
|
+
models.collect { |model| new(domain, model.name, model) }
|
15
|
+
end
|
16
|
+
|
17
|
+
def abstract_from_models(domain, models)
|
18
|
+
models.collect(&:reflect_on_all_associations).flatten.collect { |association|
|
19
|
+
association.options[:as].to_s.classify if association.options[:as]
|
20
|
+
}.flatten.compact.uniq.collect { |name| new(domain, name) }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
extend Inspectable
|
25
|
+
inspection_attributes :model
|
26
|
+
|
27
|
+
# The domain in which this entity resides.
|
28
|
+
attr_reader :domain
|
29
|
+
|
30
|
+
# The Active Record model that this entity corresponds to.
|
31
|
+
attr_reader :model
|
32
|
+
|
33
|
+
# The name of this entity. Equal to the class name of the corersponding
|
34
|
+
# model (for concrete entities) or given name (for abstract entities).
|
35
|
+
attr_reader :name
|
36
|
+
|
37
|
+
def initialize(domain, name, model = nil) # @private :nodoc:
|
38
|
+
@domain, @name, @model = domain, name, model
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns an array of attributes for this entity.
|
42
|
+
def attributes
|
43
|
+
@attributes ||= if generalized? then [] else Attribute.from_model(domain, model) end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns an array of all relationships that this entity has with other
|
47
|
+
# entities in the domain model.
|
48
|
+
def relationships
|
49
|
+
domain.relationships_by_entity_name(name)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Returns +true+ if this entity has any relationships with other models,
|
53
|
+
# +false+ otherwise.
|
54
|
+
def connected?
|
55
|
+
relationships.any?
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns +true+ if this entity has no relationships with any other models,
|
59
|
+
# +false+ otherwise. Opposite of +connected?+.
|
60
|
+
def disconnected?
|
61
|
+
relationships.none?
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns +true+ if this entity is a generalization, which does not
|
65
|
+
# correspond with a database table. Generalized entities are constructed
|
66
|
+
# from polymorphic interfaces. Any +has_one+ or +has_many+ association
|
67
|
+
# that defines a polymorphic interface with <tt>:as => :name</tt> will
|
68
|
+
# lead to a generalized entity to be created.
|
69
|
+
def generalized?
|
70
|
+
!model
|
71
|
+
end
|
72
|
+
|
73
|
+
# Returns +true+ if this entity descends from another entity, and is
|
74
|
+
# represented in the same table as its parent. In Rails this concept is
|
75
|
+
# referred to as single-table inheritance. In entity-relationship
|
76
|
+
# diagrams it is called specialization.
|
77
|
+
def specialized?
|
78
|
+
!generalized? and !model.descends_from_active_record?
|
79
|
+
end
|
80
|
+
|
81
|
+
# Returns +true+ if this entity does not correspond directly with a
|
82
|
+
# database table (if and only if the entity is specialized or
|
83
|
+
# generalized).
|
84
|
+
def abstract?
|
85
|
+
specialized? or generalized?
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns all child entities, if this is a generalized entity.
|
89
|
+
def children
|
90
|
+
@children ||= domain.specializations_by_entity_name(name).map(&:specialized)
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_s # @private :nodoc:
|
94
|
+
name
|
95
|
+
end
|
96
|
+
|
97
|
+
def <=>(other) # @private :nodoc:
|
98
|
+
self.name <=> other.name
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
require "set"
|
2
|
+
require "active_support/core_ext/module/delegation"
|
3
|
+
require "rails_erd/domain/relationship/cardinality"
|
4
|
+
|
5
|
+
module RailsERD
|
6
|
+
class Domain
|
7
|
+
# Describes a relationship between two entities. A relationship is detected
|
8
|
+
# based on Active Record associations. One relationship may represent more
|
9
|
+
# than one association, however. Related associations are grouped together.
|
10
|
+
# Associations are related if they share the same foreign key, or the same
|
11
|
+
# join table in the case of many-to-many associations.
|
12
|
+
class Relationship
|
13
|
+
N = Cardinality::N
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def from_associations(domain, associations) # @private :nodoc:
|
17
|
+
assoc_groups = associations.group_by { |assoc| association_identity(assoc) }
|
18
|
+
assoc_groups.collect { |_, assoc_group| new(domain, assoc_group.to_a) }
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def association_identity(association)
|
24
|
+
identifier = association.options[:join_table] || association.options[:through] || association.primary_key_name.to_s
|
25
|
+
Set[identifier, association_owner(association), association_target(association)]
|
26
|
+
end
|
27
|
+
|
28
|
+
def association_owner(association)
|
29
|
+
association.options[:as] ? association.options[:as].to_s.classify : association.active_record.name
|
30
|
+
end
|
31
|
+
|
32
|
+
def association_target(association)
|
33
|
+
association.class_name
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
extend Inspectable
|
38
|
+
inspection_attributes :source, :destination
|
39
|
+
|
40
|
+
# The domain in which this relationship is defined.
|
41
|
+
attr_reader :domain
|
42
|
+
|
43
|
+
# The source entity. It corresponds to the model that has defined a
|
44
|
+
# +has_one+ or +has_many+ association with the other model.
|
45
|
+
attr_reader :source
|
46
|
+
|
47
|
+
# The destination entity. It corresponds to the model that has defined
|
48
|
+
# a +belongs_to+ association with the other model.
|
49
|
+
attr_reader :destination
|
50
|
+
|
51
|
+
delegate :one_to_one?, :one_to_many?, :many_to_many?, :source_optional?,
|
52
|
+
:destination_optional?, :to => :cardinality
|
53
|
+
|
54
|
+
def initialize(domain, associations) # @private :nodoc:
|
55
|
+
@domain = domain
|
56
|
+
@reverse_associations, @forward_associations = *unless any_habtm?(associations)
|
57
|
+
associations.partition(&:belongs_to?)
|
58
|
+
else
|
59
|
+
# Many-to-many associations don't have a clearly defined direction.
|
60
|
+
# We sort by name and use the first model as the source.
|
61
|
+
source = associations.first.active_record
|
62
|
+
associations.partition { |association| association.active_record == source }
|
63
|
+
end
|
64
|
+
|
65
|
+
assoc = @forward_associations.first || @reverse_associations.first
|
66
|
+
@source = @domain.entity_by_name(self.class.send(:association_owner, assoc))
|
67
|
+
@destination = @domain.entity_by_name(self.class.send(:association_target, assoc))
|
68
|
+
@source, @destination = @destination, @source if assoc.belongs_to?
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns all Active Record association objects that describe this
|
72
|
+
# relationship.
|
73
|
+
def associations
|
74
|
+
@forward_associations + @reverse_associations
|
75
|
+
end
|
76
|
+
|
77
|
+
# Returns the cardinality of this relationship.
|
78
|
+
def cardinality
|
79
|
+
@cardinality ||= begin
|
80
|
+
reverse_max = any_habtm?(associations) ? N : 1
|
81
|
+
forward_range = associations_range(@forward_associations, N)
|
82
|
+
reverse_range = associations_range(@reverse_associations, reverse_max)
|
83
|
+
Cardinality.new(reverse_range, forward_range)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Indicates if a relationship is indirect, that is, if it is defined
|
88
|
+
# through other relationships. Indirect relationships are created in
|
89
|
+
# Rails with <tt>has_many :through</tt> or <tt>has_one :through</tt>
|
90
|
+
# association macros.
|
91
|
+
def indirect?
|
92
|
+
!@forward_associations.empty? and @forward_associations.all?(&:through_reflection)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Indicates whether or not the relationship is defined by two inverse
|
96
|
+
# associations (e.g. a +has_many+ and a corresponding +belongs_to+
|
97
|
+
# association).
|
98
|
+
def mutual?
|
99
|
+
@forward_associations.any? and @reverse_associations.any?
|
100
|
+
end
|
101
|
+
|
102
|
+
# Indicates whether or not this relationship connects an entity with itself.
|
103
|
+
def recursive?
|
104
|
+
@source == @destination
|
105
|
+
end
|
106
|
+
|
107
|
+
# Indicates whether the destination cardinality class of this relationship
|
108
|
+
# is equal to one. This is +true+ for one-to-one relationships only.
|
109
|
+
def to_one?
|
110
|
+
cardinality.cardinality_class[1] == 1
|
111
|
+
end
|
112
|
+
|
113
|
+
# Indicates whether the destination cardinality class of this relationship
|
114
|
+
# is equal to infinity. This is +true+ for one-to-many or
|
115
|
+
# many-to-many relationships only.
|
116
|
+
def to_many?
|
117
|
+
cardinality.cardinality_class[1] != 1
|
118
|
+
end
|
119
|
+
|
120
|
+
# Indicates whether the source cardinality class of this relationship
|
121
|
+
# is equal to one. This is +true+ for one-to-one or
|
122
|
+
# one-to-many relationships only.
|
123
|
+
def one_to?
|
124
|
+
cardinality.cardinality_class[0] == 1
|
125
|
+
end
|
126
|
+
|
127
|
+
# Indicates whether the source cardinality class of this relationship
|
128
|
+
# is equal to infinity. This is +true+ for many-to-many relationships only.
|
129
|
+
def many_to?
|
130
|
+
cardinality.cardinality_class[0] != 1
|
131
|
+
end
|
132
|
+
|
133
|
+
# The strength of a relationship is equal to the number of associations
|
134
|
+
# that describe it.
|
135
|
+
def strength
|
136
|
+
if source.generalized? then 1 else associations.size end
|
137
|
+
end
|
138
|
+
|
139
|
+
def <=>(other) # @private :nodoc:
|
140
|
+
(source.name <=> other.source.name).nonzero? or (destination.name <=> other.destination.name)
|
141
|
+
end
|
142
|
+
|
143
|
+
private
|
144
|
+
|
145
|
+
def associations_range(associations, absolute_max)
|
146
|
+
# The minimum of the range is the maximum value of each association
|
147
|
+
# minimum. If there is none, it is zero by definition. The reasoning is
|
148
|
+
# that from all associations, if only one has a required minimum, then
|
149
|
+
# this side of the relationship has a cardinality of at least one.
|
150
|
+
min = associations.map { |assoc| association_minimum(assoc) }.max || 0
|
151
|
+
|
152
|
+
# The maximum of the range is the maximum value of each association
|
153
|
+
# maximum. If there is none, it is equal to the absolute maximum. If
|
154
|
+
# only one association has a high cardinality on this side, the
|
155
|
+
# relationship itself has the same maximum cardinality.
|
156
|
+
max = associations.map { |assoc| association_maximum(assoc) }.max || absolute_max
|
157
|
+
|
158
|
+
min..max
|
159
|
+
end
|
160
|
+
|
161
|
+
def association_minimum(association)
|
162
|
+
minimum = association_validators(:presence, association).any? ||
|
163
|
+
foreign_key_required?(association) ? 1 : 0
|
164
|
+
length_validators = association_validators(:length, association)
|
165
|
+
length_validators.map { |v| v.options[:minimum] }.compact.max or minimum
|
166
|
+
end
|
167
|
+
|
168
|
+
def association_maximum(association)
|
169
|
+
maximum = association.collection? ? N : 1
|
170
|
+
length_validators = association_validators(:length, association)
|
171
|
+
length_validators.map { |v| v.options[:maximum] }.compact.min or maximum
|
172
|
+
end
|
173
|
+
|
174
|
+
def association_validators(kind, association)
|
175
|
+
association.active_record.validators_on(association.name).select { |v| v.kind == kind }
|
176
|
+
end
|
177
|
+
|
178
|
+
def any_habtm?(associations)
|
179
|
+
associations.any? { |association| association.macro == :has_and_belongs_to_many }
|
180
|
+
end
|
181
|
+
|
182
|
+
def foreign_key_required?(association)
|
183
|
+
if association.belongs_to?
|
184
|
+
column = association.active_record.columns_hash[association.primary_key_name] and !column.null
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
module RailsERD
|
2
|
+
class Domain
|
3
|
+
class Relationship
|
4
|
+
class Cardinality
|
5
|
+
extend Inspectable
|
6
|
+
inspection_attributes :source_range, :destination_range
|
7
|
+
|
8
|
+
N = Infinity = 1.0/0 # And beyond.
|
9
|
+
|
10
|
+
CLASSES = {
|
11
|
+
[1, 1] => :one_to_one,
|
12
|
+
[1, N] => :one_to_many,
|
13
|
+
[N, 1] => :many_to_one,
|
14
|
+
[N, N] => :many_to_many
|
15
|
+
} # @private :nodoc:
|
16
|
+
|
17
|
+
# Returns a range that indicates the source (left) cardinality.
|
18
|
+
attr_reader :source_range
|
19
|
+
|
20
|
+
# Returns a range that indicates the destination (right) cardinality.
|
21
|
+
attr_reader :destination_range
|
22
|
+
|
23
|
+
# Create a new cardinality based on a source range and a destination
|
24
|
+
# range. These ranges describe which number of values are valid.
|
25
|
+
def initialize(source_range, destination_range) # @private :nodoc:
|
26
|
+
@source_range = compose_range(source_range)
|
27
|
+
@destination_range = compose_range(destination_range)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Returns the name of this cardinality, based on its two cardinal
|
31
|
+
# numbers (for source and destination). Can be any of
|
32
|
+
# +:one_to_one:+, +:one_to_many+, or +:many_to_many+. The name
|
33
|
+
# +:many_to_one+ also exists, but Rails ERD always normalises these
|
34
|
+
# kinds of relationships by inversing them, so they become
|
35
|
+
# +:one_to_many+ associations.
|
36
|
+
#
|
37
|
+
# You can also call the equivalent method with a question mark, which
|
38
|
+
# will return true if the name corresponds to that method. For example:
|
39
|
+
#
|
40
|
+
# cardinality.one_to_one?
|
41
|
+
# #=> true
|
42
|
+
# cardinality.one_to_many?
|
43
|
+
# #=> false
|
44
|
+
def name
|
45
|
+
CLASSES[cardinality_class]
|
46
|
+
end
|
47
|
+
|
48
|
+
# Returns +true+ if the source (left side) is not mandatory.
|
49
|
+
def source_optional?
|
50
|
+
source_range.first < 1
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns +true+ if the destination (right side) is not mandatory.
|
54
|
+
def destination_optional?
|
55
|
+
destination_range.first < 1
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns the inverse cardinality. Destination becomes source, source
|
59
|
+
# becomes destination.
|
60
|
+
def inverse
|
61
|
+
self.class.new destination_range, source_range
|
62
|
+
end
|
63
|
+
|
64
|
+
CLASSES.each do |cardinality_class, name|
|
65
|
+
class_eval <<-RUBY
|
66
|
+
def #{name}?
|
67
|
+
cardinality_class == #{cardinality_class.inspect}
|
68
|
+
end
|
69
|
+
RUBY
|
70
|
+
end
|
71
|
+
|
72
|
+
def ==(other) # @private :nodoc:
|
73
|
+
source_range == other.source_range and destination_range == other.destination_range
|
74
|
+
end
|
75
|
+
|
76
|
+
def <=>(other) # @private :nodoc:
|
77
|
+
(cardinality_class <=> other.cardinality_class).nonzero? or
|
78
|
+
compare_with(other) { |x| x.source_range.first + x.destination_range.first }.nonzero? or
|
79
|
+
compare_with(other) { |x| x.source_range.last + x.destination_range.last }.nonzero? or
|
80
|
+
compare_with(other) { |x| x.source_range.last }.nonzero? or
|
81
|
+
compare_with(other) { |x| x.destination_range.last }
|
82
|
+
end
|
83
|
+
|
84
|
+
# Returns an array with the cardinality classes for the source and
|
85
|
+
# destination of this cardinality. Possible return values are:
|
86
|
+
# <tt>[1, 1]</tt>, <tt>[1, N]</tt>, <tt>[N, N]</tt>, and (in theory)
|
87
|
+
# <tt>[N, 1]</tt>.
|
88
|
+
def cardinality_class
|
89
|
+
[source_cardinality_class, destination_cardinality_class]
|
90
|
+
end
|
91
|
+
|
92
|
+
protected
|
93
|
+
|
94
|
+
# The cardinality class of the source (left side). Either +1+ or +Infinity+.
|
95
|
+
def source_cardinality_class
|
96
|
+
source_range.last == 1 ? 1 : N
|
97
|
+
end
|
98
|
+
|
99
|
+
# The cardinality class of the destination (right side). Either +1+ or +Infinity+.
|
100
|
+
def destination_cardinality_class
|
101
|
+
destination_range.last == 1 ? 1 : N
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
def compose_range(r)
|
107
|
+
return r..r if r.kind_of?(Integer) && r > 0
|
108
|
+
return (r.begin)..(r.end - 1) if r.exclude_end?
|
109
|
+
r
|
110
|
+
end
|
111
|
+
|
112
|
+
def compare_with(other, &block)
|
113
|
+
yield(self) <=> yield(other)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module RailsERD
|
2
|
+
class Domain
|
3
|
+
# Describes the specialization of an entity. Specialized entites correspond
|
4
|
+
# to inheritance. In Rails, specialization is referred to as single table
|
5
|
+
# inheritance.
|
6
|
+
class Specialization
|
7
|
+
class << self
|
8
|
+
def from_models(domain, models) # @private :nodoc:
|
9
|
+
(inheritance_from_models(domain, models) + polymorphic_from_models(domain, models)).sort
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def polymorphic_from_models(domain, models)
|
15
|
+
models.collect(&:reflect_on_all_associations).flatten.collect { |association|
|
16
|
+
[association.options[:as].to_s.classify, association.active_record.name] if association.options[:as]
|
17
|
+
}.compact.uniq.collect { |names|
|
18
|
+
new(domain, domain.entity_by_name(names.first), domain.entity_by_name(names.last))
|
19
|
+
}
|
20
|
+
end
|
21
|
+
|
22
|
+
def inheritance_from_models(domain, models)
|
23
|
+
models.reject(&:descends_from_active_record?).collect { |model|
|
24
|
+
new(domain, domain.entity_by_name(model.base_class.name), domain.entity_by_name(model.name))
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
extend Inspectable
|
30
|
+
inspection_attributes :generalized, :specialized
|
31
|
+
|
32
|
+
# The domain in which this specialization is defined.
|
33
|
+
attr_reader :domain
|
34
|
+
|
35
|
+
# The source entity.
|
36
|
+
attr_reader :generalized
|
37
|
+
|
38
|
+
# The destination entity.
|
39
|
+
attr_reader :specialized
|
40
|
+
|
41
|
+
def initialize(domain, generalized, specialized) # @private :nodoc:
|
42
|
+
@domain, @generalized, @specialized = domain, generalized, specialized
|
43
|
+
end
|
44
|
+
|
45
|
+
def inheritance?
|
46
|
+
!polymorphic?
|
47
|
+
end
|
48
|
+
|
49
|
+
def polymorphic?
|
50
|
+
generalized.generalized?
|
51
|
+
end
|
52
|
+
|
53
|
+
def <=>(other) # @private :nodoc:
|
54
|
+
(generalized.name <=> other.generalized.name).nonzero? or (specialized.name <=> other.specialized.name)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|