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.
@@ -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