rails-erd 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -19,13 +19,10 @@ end
19
19
  module RailsERD
20
20
  class Diagram
21
21
  # Create Graphviz-based diagrams based on the domain model. For easy
22
- # command line graph generation, you can use rake:
22
+ # command line graph generation, you can use:
23
23
  #
24
24
  # % rake erd
25
25
  #
26
- # Please see the README.rdoc file for more details on how to use Rails ERD
27
- # from the command line.
28
- #
29
26
  # === Options
30
27
  #
31
28
  # The following options are supported:
@@ -37,7 +34,7 @@ module RailsERD
37
34
  # worse than a PDF file. The available formats depend on your installation
38
35
  # of Graphviz.
39
36
  # notation:: The cardinality notation to be used. Can be +:simple+ or
40
- # +:advanced+. Refer to README.rdoc or to the examples on the project
37
+ # +:bachman+. Refer to README.rdoc or to the examples on the project
41
38
  # homepage for more information and examples.
42
39
  # orientation:: The direction of the hierarchy of entities. Either +:horizontal+
43
40
  # or +:vertical+. Defaults to +horizontal+. The orientation of the
@@ -60,9 +57,7 @@ module RailsERD
60
57
  :concentrate => true,
61
58
  :labelloc => :t,
62
59
  :fontsize => 13,
63
- :fontname => "Arial Bold",
64
- :remincross => true,
65
- :outputorder => :edgesfirst
60
+ :fontname => "Arial Bold"
66
61
  }
67
62
 
68
63
  # Default node attributes.
@@ -86,94 +81,139 @@ module RailsERD
86
81
  :fontsize => 7
87
82
  }
88
83
 
89
- # Define different styles to draw the cardinality of relationships.
90
- CARDINALITY_STYLES = {
91
- # Closed arrows for to/from many.
92
- :simple => lambda { |relationship, options|
93
- options[:arrowhead] = relationship.to_many? ? :normal : :none
94
- options[:arrowtail] = relationship.many_to? ? :normal : :none
95
- },
96
-
97
- # Closed arrow for to/from many, UML ranges at each end.
98
- :uml => lambda { |relationship, options|
99
- options[:arrowsize] = 0.7
100
- options[:arrowhead] = relationship.to_many? ? :vee : :none
101
- options[:arrowtail] = relationship.many_to? ? :vee : :none
102
- ranges = [relationship.cardinality.destination_range, relationship.cardinality.source_range].map do |range|
103
- if range.min == range.max
104
- "#{range.min}"
105
- else
106
- "#{range.min}..#{range.max == Relationship::Cardinality::Infinity ? "∗" : range.max}"
107
- end
84
+ module Simple
85
+ def entity_style(entity, attributes)
86
+ {}.tap do |options|
87
+ options[:fontcolor] = options[:color] = :grey60 if entity.abstract?
108
88
  end
109
- options[:headlabel], options[:taillabel] = *ranges
110
- },
89
+ end
111
90
 
112
- # Arrow for to/from many, open or closed dots for optional/mandatory.
113
- :advanced => lambda { |relationship, options|
114
- dst = relationship.destination_optional? ? "odot" : "dot"
115
- src = relationship.source_optional? ? "odot" : "dot"
116
- dst << "normal" if relationship.to_many?
117
- src << "normal" if relationship.many_to?
118
- options[:arrowsize] = 0.6
119
- options[:arrowhead], options[:arrowtail] = dst, src
120
- }
121
- }
91
+ def relationship_style(relationship)
92
+ {}.tap do |options|
93
+ options[:style] = :dotted if relationship.indirect?
122
94
 
123
- def graph
124
- @graph ||= GraphViz.digraph(@domain.name) do |graph|
125
- # Set all default attributes.
126
- GRAPH_ATTRIBUTES.each { |attribute, value| graph[attribute] = value }
127
- NODE_ATTRIBUTES.each { |attribute, value| graph.node[attribute] = value }
128
- EDGE_ATTRIBUTES.each { |attribute, value| graph.edge[attribute] = value }
95
+ # Closed arrows for to/from many.
96
+ options[:arrowhead] = relationship.to_many? ? "normal" : "none"
97
+ options[:arrowtail] = relationship.many_to? ? "normal" : "none"
98
+ end
99
+ end
129
100
 
130
- # Switch rank direction if we're creating a vertically oriented graph.
131
- graph[:rankdir] = :TB if vertical?
132
-
133
- # Title of the graph itself.
134
- graph[:label] = "#{title}\\n\\n" if title
101
+ def specialization_style(specialization)
102
+ { :color => :grey60, :arrowtail => :onormal, :arrowhead => :none, :arrowsize => 1.2 }
135
103
  end
136
104
  end
137
-
138
- # Save the diagram and return the file name that was written to.
139
- def save
140
- graph.output(filetype => filename)
141
- filename
142
- rescue StandardError => e
143
- raise "Saving diagram failed. Verify that Graphviz is installed or select filetype=dot."
144
- end
105
+
106
+ module Bachman
107
+ include Simple
108
+ def relationship_style(relationship)
109
+ {}.tap do |options|
110
+ options[:style] = :dotted if relationship.indirect?
145
111
 
146
- protected
112
+ # Participation is "look-here".
113
+ dst = relationship.source_optional? ? "odot" : "dot"
114
+ src = relationship.destination_optional? ? "odot" : "dot"
147
115
 
148
- def process_entity(entity, attributes)
149
- graph.add_node entity.name, entity_options(entity, attributes)
116
+ # Cardinality is "look-across".
117
+ dst << "normal" if relationship.to_many?
118
+ src << "normal" if relationship.many_to?
119
+ options[:arrowsize] = 0.6
120
+ options[:arrowhead], options[:arrowtail] = dst, src
121
+ end
122
+ end
150
123
  end
124
+
125
+ module Uml
126
+ include Simple
127
+ def relationship_style(relationship)
128
+ {}.tap do |options|
129
+ options[:style] = :dotted if relationship.indirect?
151
130
 
152
- def process_relationship(relationship)
153
- graph.add_edge graph.get_node(relationship.source.name), graph.get_node(relationship.destination.name),
154
- relationship_options(relationship)
155
- end
131
+ options[:arrowsize] = 0.7
132
+ options[:arrowhead] = relationship.to_many? ? "vee" : "none"
133
+ options[:arrowtail] = relationship.many_to? ? "vee" : "none"
156
134
 
157
- # Returns +true+ if the layout or hierarchy of the diagram should be
158
- # horizontally oriented.
159
- def horizontal?
160
- options.orientation == :horizontal
135
+ ranges = [relationship.cardinality.destination_range, relationship.cardinality.source_range].map do |range|
136
+ if range.min == range.max
137
+ "#{range.min}"
138
+ else
139
+ "#{range.min}..#{range.max == Domain::Relationship::N ? "∗" : range.max}"
140
+ end
141
+ end
142
+ options[:headlabel], options[:taillabel] = *ranges
143
+ end
144
+ end
161
145
  end
146
+
147
+ attr_accessor :graph
162
148
 
163
- # Returns +true+ if the layout or hierarchy of the diagram should be
164
- # vertically oriented.
165
- def vertical?
166
- !horizontal?
149
+ setup do
150
+ self.graph = GraphViz.digraph(domain.name)
151
+
152
+ # Set all default attributes.
153
+ GRAPH_ATTRIBUTES.each { |attribute, value| graph[attribute] = value }
154
+ NODE_ATTRIBUTES.each { |attribute, value| graph.node[attribute] = value }
155
+ EDGE_ATTRIBUTES.each { |attribute, value| graph.edge[attribute] = value }
156
+
157
+ # Switch rank direction if we're creating a vertically oriented graph.
158
+ graph[:rankdir] = :TB if options.orientation == :vertical
159
+
160
+ # Title of the graph itself.
161
+ graph[:label] = "#{title}\\n\\n" if title
162
+
163
+ # Setup notation options.
164
+ extend self.class.const_get(options.notation.to_s.capitalize.to_sym)
165
+ end
166
+
167
+ save do
168
+ raise "Saving diagram failed. Output directory '#{File.dirname(filename)}' does not exist." unless File.directory?(File.dirname(filename))
169
+ begin
170
+ graph.output(filetype => filename)
171
+ filename
172
+ rescue StandardError => e
173
+ raise "Saving diagram failed. Verify that Graphviz is installed or select filetype=dot."
174
+ end
167
175
  end
168
176
 
177
+ each_entity do |entity, attributes|
178
+ draw_node entity.name, entity_options(entity, attributes)
179
+ end
180
+
181
+ each_specialization do |specialization|
182
+ from, to = specialization.generalized, specialization.specialized
183
+ draw_edge from.name, to.name, specialization_options(specialization)
184
+ end
185
+
186
+ each_relationship do |relationship|
187
+ from, to = relationship.source, relationship.destination
188
+ unless draw_edge from.name, to.name, relationship_options(relationship)
189
+ if from.children.any?
190
+ from.children.each do |child|
191
+ draw_edge child.name, to.name, relationship_options(relationship)
192
+ end
193
+ end
194
+ end
195
+ end
196
+
169
197
  private
170
198
 
199
+ def node_exists?(name)
200
+ !!graph.get_node(name)
201
+ end
202
+
203
+ def draw_node(name, options)
204
+ graph.add_node name, options
205
+ end
206
+
207
+ def draw_edge(from, to, options)
208
+ graph.add_edge graph.get_node(from), graph.get_node(to), options if node_exists?(from) and node_exists?(to)
209
+ end
210
+
171
211
  # Returns the title to be used for the graph.
172
212
  def title
173
213
  case options.title
174
214
  when false then nil
175
- when true then
176
- if @domain.name then "#{@domain.name} domain model" else "Domain model" end
215
+ when true
216
+ if domain.name then "#{domain.name} domain model" else "Domain model" end
177
217
  else options.title
178
218
  end
179
219
  end
@@ -188,32 +228,23 @@ module RailsERD
188
228
  if options.filetype.to_sym == :dot then :none else options.filetype.to_sym end
189
229
  end
190
230
 
191
- # Returns an options hash based on the given entity and its attributes.
192
231
  def entity_options(entity, attributes)
193
- { :label => "<#{NODE_LABEL_TEMPLATE.result(binding)}>" }
232
+ entity_style(entity, attributes).merge :label => "<#{NODE_LABEL_TEMPLATE.result(binding)}>"
194
233
  end
195
234
 
196
- # Returns an options hash
197
235
  def relationship_options(relationship)
198
- relationship_style_options(relationship).tap do |opts|
236
+ relationship_style(relationship).tap do |options|
199
237
  # Edges with a higher weight are optimised to be shorter and straighter.
200
- opts[:weight] = relationship.strength
238
+ options[:weight] = relationship.strength
201
239
 
202
240
  # Indirect relationships should not influence node ranks.
203
- opts[:constraint] = false if relationship.indirect?
241
+ options[:constraint] = false if relationship.indirect?
204
242
  end
205
243
  end
206
-
207
- # Returns an options hash that defines the (cardinality) style for the
208
- # relationship.
209
- def relationship_style_options(relationship)
210
- {}.tap do |opts|
211
- opts[:style] = :dotted if relationship.indirect?
212
-
213
- # Let cardinality style callbacks draw arrow heads and tails.
214
- CARDINALITY_STYLES[options.notation][relationship, opts]
215
- end
244
+
245
+ def specialization_options(specialization)
246
+ specialization_style(specialization)
216
247
  end
217
248
  end
218
249
  end
219
- end
250
+ end
@@ -1,4 +1,4 @@
1
- <% if vertical? %>{<% end %>
1
+ <% if options.orientation == :vertical %>{<% end %>
2
2
  <table border="0" align="center" cellspacing="0.5" cellpadding="0" width="<%= NODE_WIDTH + 4 %>">
3
3
  <tr><td align="center" valign="bottom" width="<%= NODE_WIDTH %>"><font face="Arial Bold" point-size="11"><%= entity.name %></font></td></tr>
4
4
  </table>
@@ -11,4 +11,4 @@
11
11
  </table>
12
12
  <% else %>
13
13
  <% end %>
14
- <% if vertical? %>}<% end %>
14
+ <% if options.orientation == :vertical %>}<% end %>
@@ -1,8 +1,8 @@
1
- require "set"
2
1
  require "rails_erd"
3
- require "rails_erd/entity"
4
- require "rails_erd/relationship"
5
- require "rails_erd/attribute"
2
+ require "rails_erd/domain/attribute"
3
+ require "rails_erd/domain/entity"
4
+ require "rails_erd/domain/relationship"
5
+ require "rails_erd/domain/specialization"
6
6
 
7
7
  module RailsERD
8
8
  # The domain describes your Rails domain model. This class is the starting
@@ -28,13 +28,16 @@ module RailsERD
28
28
  end
29
29
  end
30
30
 
31
+ extend Inspectable
32
+ inspection_attributes
33
+
31
34
  # The options that are used to generate this domain model.
32
35
  attr_reader :options
33
36
 
34
37
  # Create a new domain model object based on the given array of models.
35
38
  # The given models are assumed to be subclasses of <tt>ActiveRecord::Base</tt>.
36
39
  def initialize(models = [], options = {})
37
- @models, @options = models, RailsERD.options.merge(options)
40
+ @source_models, @options = models, RailsERD.options.merge(options)
38
41
  end
39
42
 
40
43
  # Returns the domain model name, which is the name of your Rails
@@ -45,7 +48,7 @@ module RailsERD
45
48
 
46
49
  # Returns all entities of your domain model.
47
50
  def entities
48
- @entities ||= Entity.from_models(self, @models)
51
+ @entities ||= Entity.from_models(self, models)
49
52
  end
50
53
 
51
54
  # Returns all relationships in your domain model.
@@ -53,19 +56,23 @@ module RailsERD
53
56
  @relationships ||= Relationship.from_associations(self, associations)
54
57
  end
55
58
 
59
+ # Returns all specializations in your domain model.
60
+ def specializations
61
+ @specializations ||= Specialization.from_models(self, models)
62
+ end
63
+
56
64
  # Returns a specific entity object for the given Active Record model.
57
- def entity_for(model) # @private :nodoc:
58
- entity_mapping[model] or raise "model #{model} exists, but is not included in domain"
65
+ def entity_by_name(name) # @private :nodoc:
66
+ entity_mapping[name]
59
67
  end
60
68
 
61
69
  # Returns an array of relationships for the given Active Record model.
62
- def relationships_for(model) # @private :nodoc:
63
- relationships_mapping[model] or []
70
+ def relationships_by_entity_name(name) # @private :nodoc:
71
+ relationships_mapping[name] or []
64
72
  end
65
-
66
- def inspect # @private :nodoc:
67
- "#<#{self.class}:0x%.14x {%s}>" %
68
- [object_id << 1, relationships.map { |rel| "#{rel.source} => #{rel.destination}" } * ", "]
73
+
74
+ def specializations_by_entity_name(name)
75
+ specializations_mapping[name] or []
69
76
  end
70
77
 
71
78
  def warn(message) # @private :nodoc:
@@ -77,7 +84,7 @@ module RailsERD
77
84
  def entity_mapping
78
85
  @entity_mapping ||= {}.tap do |mapping|
79
86
  entities.each do |entity|
80
- mapping[entity.model] = entity
87
+ mapping[entity.name] = entity
81
88
  end
82
89
  end
83
90
  end
@@ -85,25 +92,46 @@ module RailsERD
85
92
  def relationships_mapping
86
93
  @relationships_mapping ||= {}.tap do |mapping|
87
94
  relationships.each do |relationship|
88
- (mapping[relationship.source.model] ||= []) << relationship
89
- (mapping[relationship.destination.model] ||= []) << relationship
95
+ (mapping[relationship.source.name] ||= []) << relationship
96
+ (mapping[relationship.destination.name] ||= []) << relationship
97
+ end
98
+ end
99
+ end
100
+
101
+ def specializations_mapping
102
+ @specializations_mapping ||= {}.tap do |mapping|
103
+ specializations.each do |specialization|
104
+ (mapping[specialization.generalized.name] ||= []) << specialization
105
+ (mapping[specialization.specialized.name] ||= []) << specialization
90
106
  end
91
107
  end
92
108
  end
93
109
 
110
+ def models
111
+ @models ||= @source_models.reject(&:abstract_class?).select { |model| check_model_validity(model) }
112
+ end
113
+
94
114
  def associations
95
- @associations ||= @models.collect(&:reflect_on_all_associations).flatten.select { |assoc| check_association_validity(assoc) }
115
+ @associations ||= models.collect(&:reflect_on_all_associations).flatten.select { |assoc| check_association_validity(assoc) }
116
+ end
117
+
118
+ def check_model_validity(model)
119
+ model.table_exists? or raise "table #{model.table_name} does not exist"
120
+ rescue => e
121
+ warn "Ignoring invalid model #{model.name} (#{e.message})"
96
122
  end
97
123
 
98
124
  def check_association_validity(association)
99
125
  # Raises an ActiveRecord::ActiveRecordError if the association is broken.
100
126
  association.check_validity!
101
127
 
102
- # Raises NameError if the associated class cannot be found.
103
- model = association.klass
104
-
105
- # Raises error if model is not in the domain.
106
- entity_for model
128
+ if association.options[:polymorphic]
129
+ entity_name = association.class_name
130
+ entity_by_name(entity_name) or raise "polymorphic interface #{entity_name} does not exist"
131
+ else
132
+ entity_name = association.klass.name # Raises NameError if the associated class cannot be found.
133
+ entity_by_name(entity_name) or raise "model #{entity_name} exists, but is not included in domain"
134
+ end
107
135
  rescue => e
108
136
  warn "Ignoring invalid association #{association_description(association)} (#{e.message})"
109
137
  end
@@ -0,0 +1,102 @@
1
+ # encoding: utf-8
2
+ module RailsERD
3
+ class Domain
4
+ # Describes an entity's attribute. Attributes correspond directly to
5
+ # database columns.
6
+ class Attribute
7
+ TIMESTAMP_NAMES = %w{created_at created_on updated_at updated_on} # @private :nodoc:
8
+
9
+ class << self
10
+ def from_model(domain, model) # @private :nodoc:
11
+ model.columns.collect { |column| new(domain, model, column) }.sort
12
+ end
13
+ end
14
+
15
+ extend Inspectable
16
+ inspection_attributes :name, :type
17
+
18
+ attr_reader :column # @private :nodoc:
19
+
20
+ def initialize(domain, model, column) # @private :nodoc:
21
+ @domain, @model, @column = domain, model, column
22
+ end
23
+
24
+ # The name of the attribute, equal to the column name.
25
+ def name
26
+ column.name
27
+ end
28
+
29
+ # The type of the attribute, equal to the Rails migration type. Can be any
30
+ # of +:string+, +:integer+, +:boolean+, +:text+, etc.
31
+ def type
32
+ column.type
33
+ end
34
+
35
+ # Returns +true+ if this attribute is a content column, that is, if it
36
+ # is not a primary key, foreign key, timestamp, or inheritance column.
37
+ def content?
38
+ !primary_key? and !foreign_key? and !timestamp? and !inheritance?
39
+ end
40
+
41
+ # Returns +true+ if this attribute is mandatory. Mandatory attributes
42
+ # either have a presence validation (+validates_presence_of+), or have a
43
+ # <tt>NOT NULL</tt> database constraint.
44
+ def mandatory?
45
+ !column.null or @model.validators_on(name).map(&:kind).include?(:presence)
46
+ end
47
+
48
+ # Returns +true+ if this attribute is the primary key of the entity.
49
+ def primary_key?
50
+ column.primary
51
+ end
52
+
53
+ # Returns +true+ if this attribute is used as a foreign key for any
54
+ # relationship.
55
+ def foreign_key?
56
+ @domain.relationships_by_entity_name(@model.name).map(&:associations).flatten.map(&:primary_key_name).include?(name)
57
+ end
58
+
59
+ # Returns +true+ if this attribute is used for single table inheritance.
60
+ # These attributes are typically named +type+.
61
+ def inheritance?
62
+ @model.inheritance_column == name
63
+ end
64
+
65
+ # Returns +true+ if this attribute is one of the standard 'magic' Rails
66
+ # timestamp columns, being +created_at+, +updated_at+, +created_on+ or
67
+ # +updated_on+.
68
+ def timestamp?
69
+ TIMESTAMP_NAMES.include? name
70
+ end
71
+
72
+ def <=>(other) # @private :nodoc:
73
+ name <=> other.name
74
+ end
75
+
76
+ def to_s # @private :nodoc:
77
+ name
78
+ end
79
+
80
+ # Returns a description of the attribute type. If the attribute has
81
+ # a non-standard limit or if it is mandatory, this information is included.
82
+ #
83
+ # Example output:
84
+ # <tt>:integer</tt>:: integer
85
+ # <tt>:string, :limit => 255</tt>:: string
86
+ # <tt>:string, :limit => 128</tt>:: string (128)
87
+ # <tt>:boolean, :null => false</tt>:: boolean *
88
+ def type_description
89
+ type.to_s.tap do |desc|
90
+ desc << " (#{limit})" if limit
91
+ desc << " ∗" if mandatory? # Add a hair space + low asterisk (Unicode characters).
92
+ end
93
+ end
94
+
95
+ # Returns any non-standard limit for this attribute. If a column has no
96
+ # limit or uses a default database limit, this method returns +nil+.
97
+ def limit
98
+ column.limit if column.limit != @model.connection.native_database_types[type][:limit]
99
+ end
100
+ end
101
+ end
102
+ end