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