db_diagram 0.1.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,315 @@
1
+ # encoding: utf-8
2
+ require "db_diagram/diagram"
3
+ require "graphviz"
4
+ require "erb"
5
+
6
+ # Fix bad RegEx test in Ruby-Graphviz.
7
+ GraphViz::Types::LblString.class_eval do
8
+ def output # @private :nodoc:
9
+ if /^<.*>$/m =~ @data
10
+ @data
11
+ else
12
+ @data.to_s.inspect.gsub("\\\\", "\\")
13
+ end
14
+ end
15
+
16
+ alias_method :to_gv, :output
17
+ alias_method :to_s, :output
18
+ end
19
+
20
+ module DBDiagram
21
+ class Diagram
22
+ # Create Graphviz-based diagrams based on the domain model. For easy
23
+ # command line graph generation, you can use:
24
+ #
25
+ # % rake erd
26
+ #
27
+ # === Options
28
+ #
29
+ # The following options are supported:
30
+ #
31
+ # filename:: The file basename of the generated diagram. Defaults to +ERD+,
32
+ # or any other extension based on the file type.
33
+ # filetype:: The file type of the generated diagram. Defaults to +pdf+, which
34
+ # is the recommended format. Other formats may render significantly
35
+ # worse than a PDF file. The available formats depend on your installation
36
+ # of Graphviz.
37
+ # notation:: The cardinality notation to be used. Can be +:simple+ or
38
+ # +:bachman+. Refer to README.rdoc or to the examples on the project
39
+ # homepage for more information and examples.
40
+ # orientation:: The direction of the hierarchy of entities. Either +:horizontal+
41
+ # or +:vertical+. Defaults to +horizontal+. The orientation of the
42
+ # PDF that is generated depends on the amount of hierarchy
43
+ # in your models.
44
+ # title:: The title to add at the top of the diagram. Defaults to
45
+ # <tt>"YourApplication domain model"</tt>.
46
+ class Graphviz < Diagram
47
+ NODE_LABEL_TEMPLATES = {
48
+ html: "node.html.erb",
49
+ record: "node.record.erb"
50
+ } # @private :nodoc:
51
+
52
+ NODE_WIDTH = 130 # @private :nodoc:
53
+
54
+ FONTS = { normal: "ArialMT",
55
+ bold: "Arial BoldMT",
56
+ italic: "Arial ItalicMT" }
57
+
58
+ # Default graph attributes.
59
+ GRAPH_ATTRIBUTES = {
60
+ rankdir: :LR,
61
+ ranksep: 0.5,
62
+ nodesep: 0.4,
63
+ pad: "0.4,0.4",
64
+ margin: "0,0",
65
+ concentrate: true,
66
+ labelloc: :t,
67
+ fontsize: 13,
68
+ fontname: FONTS[:bold],
69
+ splines: 'spline'
70
+ }
71
+
72
+ # Default node attributes.
73
+ NODE_ATTRIBUTES = {
74
+ shape: "Mrecord",
75
+ fontsize: 10,
76
+ fontname: FONTS[:normal],
77
+ margin: "0.07,0.05",
78
+ penwidth: 1.0
79
+ }
80
+
81
+ # Default edge attributes.
82
+ EDGE_ATTRIBUTES = {
83
+ fontname: FONTS[:normal],
84
+ fontsize: 7,
85
+ dir: :both,
86
+ arrowsize: 0.9,
87
+ penwidth: 1.0,
88
+ labelangle: 32,
89
+ labeldistance: 1.8,
90
+ }
91
+
92
+ # Default cluster attributes.
93
+ CLUSTER_ATTRIBUTES = {
94
+ margin: "10,10"
95
+ }
96
+
97
+ module Simple
98
+ def entity_style(entity, attributes)
99
+ {}.tap do |options|
100
+ options[:fontcolor] = options[:color] = :grey60 if entity.abstract?
101
+ end
102
+ end
103
+
104
+ def relationship_style(relationship)
105
+ {}.tap do |options|
106
+ # options[:style] = :dotted #虚线
107
+
108
+ # Closed arrows for to/from many.
109
+ options[:arrowhead] = relationship.to_many? ? "normal" : "none"
110
+ options[:arrowtail] = relationship.many_to? ? "normal" : "none"
111
+ end
112
+ end
113
+ end
114
+
115
+ module Crowsfoot
116
+ include Simple
117
+
118
+ def relationship_style(relationship)
119
+ {}.tap do |options|
120
+ # options[:style] = :dotted #虚线
121
+
122
+ # Cardinality is "look-across".
123
+ dst = relationship.to_many? ? "crow" : "tee"
124
+ src = relationship.many_to? ? "crow" : "tee"
125
+
126
+ # Participation is "look-across".
127
+ dst << (relationship.destination_optional? ? "odot" : "tee")
128
+ src << (relationship.source_optional? ? "odot" : "tee")
129
+
130
+ options[:arrowsize] = 0.6
131
+ options[:arrowhead], options[:arrowtail] = dst, src
132
+ end
133
+ end
134
+ end
135
+
136
+ module Bachman
137
+ include Simple
138
+
139
+ def relationship_style(relationship)
140
+ {}.tap do |options|
141
+ # options[:style] = :dotted #虚线
142
+
143
+ # Participation is "look-here".
144
+ dst = relationship.source_optional? ? "odot" : "dot"
145
+ src = relationship.destination_optional? ? "odot" : "dot"
146
+
147
+ # Cardinality is "look-across".
148
+ dst << "normal" if relationship.to_many?
149
+ src << "normal" if relationship.many_to?
150
+
151
+ options[:arrowsize] = 0.6
152
+ options[:arrowhead], options[:arrowtail] = dst, src
153
+ end
154
+ end
155
+ end
156
+
157
+ module Uml
158
+ include Simple
159
+
160
+ def relationship_style(relationship)
161
+ {}.tap do |options|
162
+ # options[:style] = :dotted #虚线
163
+
164
+ options[:arrowsize] = 0.7
165
+ options[:arrowhead] = relationship.to_many? ? "vee" : "none"
166
+ options[:arrowtail] = relationship.many_to? ? "vee" : "none"
167
+
168
+ ranges = [relationship.cardinality.destination_range, relationship.cardinality.source_range].map do |range|
169
+ if range.min == range.max
170
+ "#{range.min}"
171
+ else
172
+ "#{range.min}..#{range.max == Domain::Relationship::N ? "∗" : range.max}"
173
+ end
174
+ end
175
+ options[:headlabel], options[:taillabel] = *ranges
176
+ end
177
+ end
178
+ end
179
+
180
+ attr_accessor :graph
181
+
182
+ setup do
183
+ self.graph = GraphViz.digraph(domain.name)
184
+
185
+ # Set all default attributes.
186
+ GRAPH_ATTRIBUTES.each { |attribute, value| graph[attribute] = value }
187
+ NODE_ATTRIBUTES.each { |attribute, value| graph.node[attribute] = value }
188
+ EDGE_ATTRIBUTES.each { |attribute, value| graph.edge[attribute] = value }
189
+
190
+ # Switch rank direction if we're creating a vertically oriented graph.
191
+ graph[:rankdir] = (options.orientation == "vertical") ? :LR : :TB
192
+
193
+ # Title of the graph itself.
194
+ graph[:label] = "#{title}\\n\\n" if title
195
+
196
+ # Style of splines
197
+ graph[:splines] = options.splines unless options.splines.nil?
198
+
199
+ # Setup notation options.
200
+ extend self.class.const_get(options.notation.to_s.capitalize.to_sym)
201
+ end
202
+
203
+ save do
204
+ raise "Saving diagram failed!\nOutput directory '#{File.dirname(filename)}' does not exist." unless File.directory?(File.dirname(filename))
205
+
206
+ begin
207
+ # GraphViz doesn't like spaces in the filename
208
+ graph.output(filetype => filename.gsub(/\s/, "_"))
209
+ filename
210
+ rescue RuntimeError => e
211
+ raise "Saving diagram failed!\nGraphviz produced errors. Verify it " +
212
+ "has support for filetype=#{options.filetype}, or use " +
213
+ "filetype=dot.\nOriginal error: #{e.message.split("\n").last}"
214
+ rescue StandardError => e
215
+ raise "Saving diagram failed!\nVerify that Graphviz is installed " +
216
+ "and in your path, or use filetype=dot."
217
+ end
218
+ end
219
+
220
+ each_entity do |entity, attributes|
221
+ if options[:cluster] && entity.namespace
222
+ cluster_name = "cluster_#{entity.namespace}"
223
+ cluster_options = CLUSTER_ATTRIBUTES.merge(label: entity.namespace)
224
+ cluster = graph.get_graph(cluster_name) ||
225
+ graph.add_graph(cluster_name, cluster_options)
226
+
227
+ draw_cluster_node cluster, entity.name, entity_options(entity, attributes)
228
+ else
229
+ draw_node entity.name, entity_options(entity, attributes)
230
+ end
231
+ end
232
+
233
+ each_relationship do |relationship|
234
+ from, to = relationship.source, relationship.destination
235
+ draw_edge from.name, to.name, relationship_options(relationship)
236
+ end
237
+
238
+ private
239
+
240
+ def node_exists?(name)
241
+ !!graph.search_node(escape_name(name))
242
+ end
243
+
244
+ def draw_node(name, options)
245
+ graph.add_nodes escape_name(name), options
246
+ end
247
+
248
+ def draw_cluster_node(cluster, name, options)
249
+ cluster.add_nodes escape_name(name), options
250
+ end
251
+
252
+ def draw_edge(from, to, options)
253
+ graph.add_edges graph.search_node(escape_name(from)), graph.search_node(escape_name(to)), options if node_exists?(from) and node_exists?(to)
254
+ end
255
+
256
+ def escape_name(name)
257
+ "m_#{name}"
258
+ end
259
+
260
+ # Returns the title to be used for the graph.
261
+ def title
262
+ case options.title
263
+ when false then
264
+ nil
265
+ when true
266
+ if domain.name then
267
+ "#{domain.name} domain model"
268
+ else
269
+ "Domain model"
270
+ end
271
+ else
272
+ options.title
273
+ end
274
+ end
275
+
276
+ # Returns the file name that will be used when saving the diagram.
277
+ def filename
278
+ "#{options.filename.presence || "#{domain.name}_#{domain.current_migration_version}"}.#{options.filetype}"
279
+ end
280
+
281
+ # Returns the default file extension to be used when saving the diagram.
282
+ def filetype
283
+ if options.filetype.to_sym == :dot then
284
+ :none
285
+ else
286
+ options.filetype.to_sym
287
+ end
288
+ end
289
+
290
+ def entity_options(entity, attributes)
291
+ label = options[:markup] ? "<#{read_template(:html).result(binding)}>" : "#{read_template(:record).result(binding)}"
292
+ entity_style(entity, attributes).merge :label => label
293
+ end
294
+
295
+ def relationship_options(relationship)
296
+ relationship_style(relationship).tap do |options|
297
+ # Edges with a higher weight are optimized to be shorter and straighter.
298
+ options[:weight] = relationship.strength
299
+
300
+ # Indirect relationships should not influence node ranks.
301
+ # options[:constraint] = false
302
+ end
303
+ end
304
+
305
+ def read_template(type)
306
+ template_text = File.read(File.expand_path("templates/#{NODE_LABEL_TEMPLATES[type]}", File.dirname(__FILE__)))
307
+ if ERB.instance_method(:initialize).parameters.assoc(:key) # Ruby 2.6+
308
+ ERB.new(template_text, trim_mode: "<>")
309
+ else
310
+ ERB.new(template_text, nil, "<>")
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,14 @@
1
+ <% if options.orientation == :horizontal %>{<% end %>
2
+ <table border="0" align="center" cellspacing="0.5" cellpadding="0" width="<%= NODE_WIDTH + 4 %>">
3
+ <tr><td align="center" valign="bottom" width="<%= NODE_WIDTH %>"><font face="<%= FONTS[:bold] %>" point-size="11"><%= entity.name %></font></td></tr>
4
+ </table>
5
+ <% if attributes.any? %>
6
+ |
7
+ <table border="0" align="left" cellspacing="2" cellpadding="0" width="<%= NODE_WIDTH + 4 %>">
8
+ <% attributes.each do |attribute| %>
9
+ <tr><td align="left" width="<%= NODE_WIDTH %>" port="<%= attribute %>"><%= attribute %> <font face="<%= FONTS[:italic] %>" color="grey60"><%= attribute.type_description %></font></td></tr>
10
+ <% end %>
11
+ </table>
12
+ <% else %>
13
+ <% end %>
14
+ <% if options.orientation == :horizontal %>}<% end %>
@@ -0,0 +1,4 @@
1
+ <% if options.orientation == :horizontal %>{<% end %><%= entity.name %><% if attributes.any? %>
2
+ |<% attributes.each do |attribute| %><%=
3
+ attribute %> (<%= attribute.type_description %>)
4
+ <% end %><% end %><% if options.orientation == :horizontal %>}<% end %>
@@ -0,0 +1,173 @@
1
+ require "db_diagram"
2
+ require "db_diagram/domain/attribute"
3
+ require "db_diagram/domain/entity"
4
+ require "db_diagram/domain/relationship"
5
+
6
+ module DBDiagram
7
+ # The domain describes your Rails domain model. This class is the starting
8
+ # point to get information about your models.
9
+ #
10
+ # === Options
11
+ #
12
+ # The following options are available:
13
+ #
14
+ # warn:: When set to +false+, no warnings are printed to the
15
+ # command line while processing the domain model. Defaults
16
+ # to +true+.
17
+ class Domain
18
+ class << self
19
+ # Generates a domain model object based on all loaded subclasses of
20
+ # <tt>ActiveRecord::Base</tt>. Make sure your models are loaded before calling
21
+ # this method.
22
+ #
23
+ # The +options+ hash allows you to override the default options. For a
24
+ # list of available options, see DBDiagram.
25
+ def generate(options = {})
26
+ base_klass = options.delete(:base_klass) || ActiveRecord::Base
27
+ new base_klass.descendants, options
28
+ end
29
+
30
+ # Returns the method name to retrieve the foreign key from an
31
+ # association reflection object.
32
+ def foreign_key_method_name # @private :nodoc:
33
+ @foreign_key_method_name ||= ActiveRecord::Reflection::AssociationReflection.method_defined?(:foreign_key) ? :foreign_key : :primary_key_name
34
+ end
35
+ end
36
+
37
+ extend Inspectable
38
+ inspection_attributes
39
+
40
+ # The options that are used to generate this domain model.
41
+ attr_reader :options
42
+
43
+ # Create a new domain model object based on the given array of models.
44
+ # The given models are assumed to be subclasses of <tt>ActiveRecord::Base</tt>.
45
+ def initialize(models = [], options = {})
46
+ @source_models, @options = models, DBDiagram.options.merge(options)
47
+ end
48
+
49
+ # Returns the domain model name, which is the name of your Rails
50
+ # application or +nil+ outside of Rails.
51
+ def app_name
52
+ return unless defined?(Rails) && Rails.application
53
+
54
+ if Rails.application.class.respond_to?(:module_parent)
55
+ Rails.application.class.module_parent.name
56
+ else
57
+ Rails.application.class.parent.name
58
+ end
59
+ end
60
+
61
+ def name
62
+ return app_name if @source_models.empty?
63
+ @source_models.first.connection.current_database.presence || app_name
64
+ rescue
65
+ app_name
66
+ end
67
+
68
+ def current_migration_version
69
+ raise '' if @source_models.empty?
70
+ @source_models.first.connection.migration_context.current_version
71
+ rescue
72
+ '0001'
73
+ end
74
+
75
+ # Returns all entities of your domain model.
76
+ def entities
77
+ @entities ||= Entity.from_models(self, models)
78
+ end
79
+
80
+ # Returns all relationships in your domain model.
81
+ def relationships
82
+ @relationships ||= Relationship.from_associations(self, associations)
83
+ end
84
+
85
+ # Returns a specific entity object for the given Active Record model.
86
+ def entity_by_name(name) # @private :nodoc:
87
+ entity_mapping[name]
88
+ end
89
+
90
+ # Returns an array of relationships for the given Active Record model.
91
+ def relationships_by_entity_name(name) # @private :nodoc:
92
+ relationships_mapping[name] or []
93
+ end
94
+
95
+ def warn(message) # @private :nodoc:
96
+ puts "Warning: #{message}" if options.warn
97
+ end
98
+
99
+ private
100
+
101
+ def entity_mapping
102
+ @entity_mapping ||= {}.tap do |mapping|
103
+ entities.each do |entity|
104
+ mapping[entity.model_name] = entity
105
+ end
106
+ end
107
+ end
108
+
109
+ def relationships_mapping
110
+ @relationships_mapping ||= {}.tap do |mapping|
111
+ relationships.each do |relationship|
112
+ (mapping[relationship.source.name] ||= []) << relationship
113
+ (mapping[relationship.destination.name] ||= []) << relationship
114
+ end
115
+ end
116
+ end
117
+
118
+ def models
119
+ @models ||= @source_models.select { |model| check_model_validity(model) }.reject { |model| check_habtm_model(model) }
120
+ end
121
+
122
+ def associations
123
+ @associations ||= models.collect(&:reflect_on_all_associations).flatten.select { |assoc| check_association_validity(assoc) }
124
+ end
125
+
126
+ def check_model_validity(model)
127
+ if model.abstract_class? || model.table_exists?
128
+ if model.name.nil?
129
+ raise "is anonymous class"
130
+ else
131
+ true
132
+ end
133
+ else
134
+ raise "table #{model.table_name} does not exist"
135
+ end
136
+ rescue => e
137
+ warn "Ignoring invalid model #{model.name} (#{e.message})"
138
+ end
139
+
140
+ def check_association_validity(association)
141
+ # Raises an ActiveRecord::ActiveRecordError if the association is broken.
142
+ association.check_validity!
143
+
144
+ if association.options[:polymorphic]
145
+ check_polymorphic_association_validity(association)
146
+ else
147
+ entity_name = association.klass.name # Raises NameError if the associated class cannot be found.
148
+ entity_by_name(entity_name) or raise "model #{entity_name} exists, but is not included in domain"
149
+ end
150
+ rescue => e
151
+ warn "Ignoring invalid association #{association_description(association)} (#{e.message})"
152
+ end
153
+
154
+ def check_polymorphic_association_validity(association)
155
+ entity_name = association.class_name
156
+ entity = entity_by_name(entity_name)
157
+
158
+ if entity || (entity && entity.generalized?)
159
+ return entity
160
+ else
161
+ raise("polymorphic interface #{entity_name} does not exist")
162
+ end
163
+ end
164
+
165
+ def association_description(association)
166
+ "#{association.name.inspect} on #{association.active_record}"
167
+ end
168
+
169
+ def check_habtm_model(model)
170
+ model.name.start_with?("HABTM_")
171
+ end
172
+ end
173
+ end