rails-erd 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ module RailsERD
2
+ class Railtie < Rails::Railtie #:nodoc:
3
+ rake_tasks do
4
+ load "rails_erd/tasks.rake"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,85 @@
1
+ module RailsERD
2
+ class Relationship
3
+ class << self
4
+ def from_associations(domain, associations) #:nodoc:
5
+ assoc_groups = associations.group_by { |assoc| association_identity(assoc) }
6
+ assoc_groups.collect { |_, assoc_group| Relationship.new(domain, assoc_group.to_a) }
7
+ end
8
+
9
+ private
10
+
11
+ def association_identity(assoc)
12
+ identifier = assoc.options[:join_table] || assoc.primary_key_name.to_s
13
+ Set[identifier, assoc.active_record, assoc.klass]
14
+ end
15
+ end
16
+
17
+ # Returns the domain in which this relationship is defined.
18
+ attr_reader :domain
19
+
20
+ # Returns the source entity. The source entity corresponds to the model
21
+ # that has defined a +has_one+ or +has_many+ association with the other
22
+ # model.
23
+ attr_reader :source
24
+
25
+ # Returns the destination entity. The destination entity corresponds to the
26
+ # model that has defined a +belongs_to+ association with the other model.
27
+ attr_reader :destination
28
+
29
+ def initialize(domain, associations) #:nodoc:
30
+ @domain = domain
31
+ @reverse_associations, @forward_associations = *associations.partition(&:belongs_to?)
32
+
33
+ assoc = @forward_associations.first || @reverse_associations.first
34
+ @source, @destination = @domain.entity_for(assoc.active_record), @domain.entity_for(assoc.klass)
35
+ @source, @destination = @destination, @source if assoc.belongs_to?
36
+ end
37
+
38
+ # Returns all +ActiveRecord+ association objects that describe this
39
+ # relationship.
40
+ def associations
41
+ @forward_associations + @reverse_associations
42
+ end
43
+
44
+ # Returns the cardinality of this relationship. The cardinality may be
45
+ # one of Cardinality::OneToOne, Cardinality::OneToMany, or
46
+ # Cardinality::ManyToMany.
47
+ def cardinality
48
+ @forward_associations.collect { |assoc| Cardinality.from_macro(assoc.macro) }.max or Cardinality::OneToMany
49
+ end
50
+
51
+ # Indicates if a relationship is indirect, that is, if it is defined
52
+ # through other relationships. Indirect relationships are created in
53
+ # Rails with <tt>has_many :through</tt> or <tt>has_one :through</tt>
54
+ # association macros.
55
+ def indirect?
56
+ @forward_associations.all?(&:through_reflection)
57
+ end
58
+
59
+ # Indicates whether or not the relationship is defined by two inverse
60
+ # associations (e.g. a +has_many+ and a corresponding +belongs_to+
61
+ # association).
62
+ def mutual?
63
+ @forward_associations.any? and @reverse_associations.any?
64
+ end
65
+
66
+ # Indicates whether or not this relationship connects an entity with itself.
67
+ def recursive?
68
+ @source == @destination
69
+ end
70
+
71
+ # The strength of a relationship is equal to the number of associations
72
+ # that describe it.
73
+ def strength
74
+ associations.size
75
+ end
76
+
77
+ def inspect #:nodoc:
78
+ "#<#{self.class}:0x%.14x @source=#{source} @destination=#{destination}>" % (object_id << 1)
79
+ end
80
+
81
+ def <=>(other) #:nodoc:
82
+ (source.name <=> other.source.name).nonzero? or (destination.name <=> other.destination.name)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,35 @@
1
+ module RailsERD
2
+ class Relationship
3
+ class Cardinality
4
+ CARDINALITY_NAMES = %w{one_to_one one_to_many many_to_many} #:nodoc:
5
+ ORDER = {} #:nodoc:
6
+
7
+ class << self
8
+ attr_reader :type
9
+
10
+ def from_macro(macro) #:nodoc:
11
+ case macro
12
+ when :has_and_belongs_to_many then ManyToMany
13
+ when :has_many then OneToMany
14
+ when :has_one then OneToOne
15
+ end
16
+ end
17
+
18
+ def <=>(other) #:nodoc:
19
+ ORDER[self] <=> ORDER[other]
20
+ end
21
+
22
+ CARDINALITY_NAMES.each do |cardinality|
23
+ define_method :"#{cardinality}?" do
24
+ type == cardinality
25
+ end
26
+ end
27
+ end
28
+
29
+ CARDINALITY_NAMES.each_with_index do |cardinality, i|
30
+ klass = Cardinality.const_set cardinality.camelize.to_sym, Class.new(Cardinality) { @@type = cardinality }
31
+ ORDER[klass] = i
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ def say(message)
2
+ puts message unless Rake.application.options.silent
3
+ end
4
+
5
+ namespace :erd do
6
+ task :options do
7
+ (RailsERD.options.keys.map(&:to_s) & ENV.keys).each do |option|
8
+ RailsERD.options[option.to_sym] = case ENV[option]
9
+ when "true" then true
10
+ when "false" then false
11
+ else ENV[option].to_sym
12
+ end
13
+ end
14
+ end
15
+
16
+ task :load_models do
17
+ say "Loading ActiveRecord models..."
18
+
19
+ Rake::Task[:environment].invoke
20
+ Rails.application.config.paths.app.models.paths.each do |model_path|
21
+ Dir["#{model_path}/**/*.rb"].sort.each do |file|
22
+ require_dependency file
23
+ end
24
+ end
25
+ end
26
+
27
+ task :generate => [:options, :load_models] do
28
+ say "Generating ERD diagram..."
29
+
30
+ require "rails_erd/diagram"
31
+ diagram = RailsERD::Diagram.generate
32
+
33
+ say "Done! Saved diagram to #{diagram.file_name}."
34
+ end
35
+ end
36
+
37
+ desc "Generate an Entity-Relationship Diagram based on your models"
38
+ task :erd => "erd:generate"
@@ -0,0 +1,13 @@
1
+ <% if vertical? %>{<% 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="Arial Bold" point-size="11"><%= entity.name %></font></td></tr>
4
+ </table>|<% if attributes.any? %>
5
+ <table border="0" align="left" cellspacing="2" cellpadding="0" width="<%= NODE_WIDTH + 4 %>">
6
+ <% attributes.each do |attribute| %>
7
+ <tr>
8
+ <td align="left" width="<%= NODE_WIDTH %>" port="<%= attribute %>"><%= attribute %> <font face="Arial Italic" color="grey60"><%= attribute.type_description %></font></td>
9
+ </tr>
10
+ <% end %>
11
+ </table>
12
+ <% end %>
13
+ <% if vertical? %>}<% end %>
@@ -0,0 +1,72 @@
1
+ require "test/unit"
2
+ require "active_support/test_case"
3
+
4
+ require "rails_erd/domain"
5
+
6
+ require "rails"
7
+ require "active_record"
8
+
9
+ ActiveRecord::Base.establish_connection :adapter => "sqlite3", :database => ":memory:"
10
+
11
+ include RailsERD
12
+
13
+ class ActiveSupport::TestCase
14
+ teardown :reset_domain
15
+
16
+ def create_table(table, columns = {}, pk = nil)
17
+ opts = if pk then { :primary_key => pk } else { :id => false } end
18
+ ActiveRecord::Schema.define do
19
+ suppress_messages do
20
+ create_table table, opts do |t|
21
+ columns.each do |column, type|
22
+ t.send type, column
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def add_column(*args)
30
+ ActiveRecord::Schema.define do
31
+ suppress_messages do
32
+ add_column *args
33
+ end
34
+ end
35
+ end
36
+
37
+ def create_model(name, columns = {}, &block)
38
+ klass = Object.const_set name.to_sym, Class.new(ActiveRecord::Base)
39
+ klass.class_eval(&block) if block_given?
40
+ create_table Object.const_get(name.to_sym).table_name, columns, Object.const_get(name.to_sym).primary_key rescue nil
41
+ end
42
+
43
+ def create_models(*names)
44
+ names.each do |name|
45
+ create_model name
46
+ end
47
+ end
48
+
49
+ def collect_stdout
50
+ stdout = $stdout
51
+ $stdout = StringIO.new
52
+ yield
53
+ $stdout.rewind
54
+ $stdout.read
55
+ ensure
56
+ $stdout = stdout
57
+ end
58
+
59
+ private
60
+
61
+ def reset_domain
62
+ ActiveRecord::Base.descendants.each do |model|
63
+ Object.send :remove_const, model.name.to_sym
64
+ end
65
+ ActiveRecord::Base.connection.tables.each do |table|
66
+ ActiveRecord::Base.connection.drop_table table
67
+ end
68
+ ActiveRecord::Base.direct_descendants.clear
69
+ Arel::Relation.class_variable_set :@@connection_tables_primary_keys, {}
70
+ ActiveSupport::Dependencies::Reference.clear!
71
+ end
72
+ end
@@ -0,0 +1,144 @@
1
+ # encoding: utf-8
2
+ require File.expand_path("../test_helper", File.dirname(__FILE__))
3
+
4
+ class AttributeTest < ActiveSupport::TestCase
5
+ def with_native_limit(type, new_limit)
6
+ ActiveRecord::Base.connection.class_eval do
7
+ define_method :native_database_types do
8
+ super().tap do |types|
9
+ types[type][:limit] = new_limit
10
+ end
11
+ end
12
+ end
13
+ yield
14
+ ensure
15
+ ActiveRecord::Base.connection.class_eval do
16
+ define_method :native_database_types do
17
+ super()
18
+ end
19
+ end
20
+ end
21
+
22
+ def create_attribute(model, name)
23
+ Attribute.new(Domain.generate, model, model.arel_table.attributes[name].column)
24
+ end
25
+
26
+ # Attribute ================================================================
27
+ test "column should return database column" do
28
+ create_model "Foo", :my_column => :string
29
+ assert_equal Foo.arel_table.attributes["my_column"].column,
30
+ Attribute.from_model(Domain.new, Foo).reject(&:primary_key?).first.column
31
+ end
32
+
33
+ test "spaceship should sort attributes by name" do
34
+ create_model "Foo", :a => :string, :b => :string, :c => :string
35
+ a = Attribute.new(Domain.new, Foo, Foo.arel_table.attributes["a"].column)
36
+ b = Attribute.new(Domain.new, Foo, Foo.arel_table.attributes["b"].column)
37
+ c = Attribute.new(Domain.new, Foo, Foo.arel_table.attributes["c"].column)
38
+ assert_equal [a, b, c], [c, a, b].sort
39
+ end
40
+
41
+ test "inspect should show column" do
42
+ create_model "Foo", :my_column => :string
43
+ assert_match %r{#<RailsERD::Attribute:.* @column="my_column" @type=:string>},
44
+ Attribute.new(Domain.new, Foo, Foo.arel_table.attributes["my_column"].column).inspect
45
+ end
46
+
47
+ test "type should return attribute type" do
48
+ create_model "Foo", :a => :binary
49
+ assert_equal :binary, create_attribute(Foo, "a").type
50
+ end
51
+
52
+ # Attribute properties =====================================================
53
+ test "mandatory should return false by default" do
54
+ create_model "Foo", :column => :string
55
+ assert_equal false, create_attribute(Foo, "column").mandatory?
56
+ end
57
+
58
+ test "mandatory should return true if attribute has a presence validator" do
59
+ create_model "Foo", :column => :string do
60
+ validates :column, :presence => true
61
+ end
62
+ assert_equal true, create_attribute(Foo, "column").mandatory?
63
+ end
64
+
65
+ test "mandatory should return true if attribute has a not null constraint" do
66
+ create_model "Foo"
67
+ add_column :foos, :column, :string, :null => false, :default => ""
68
+ assert_equal true, create_attribute(Foo, "column").mandatory?
69
+ end
70
+
71
+ test "primary_key should return false by default" do
72
+ create_model "Bar", :my_key => :integer
73
+ assert_equal false, create_attribute(Bar, "my_key").primary_key?
74
+ end
75
+
76
+ test "primary_key should return true if column is used as primary key" do
77
+ create_model "Bar", :my_key => :integer do
78
+ set_primary_key :my_key
79
+ end
80
+ assert_equal true, create_attribute(Bar, "my_key").primary_key?
81
+ end
82
+
83
+ test "foreign_key should return false by default" do
84
+ create_model "Foo", :bar => :references
85
+ assert_equal false, create_attribute(Foo, "bar_id").foreign_key?
86
+ end
87
+
88
+ test "foreign_key should return true if it is used in an association" do
89
+ create_model "Foo", :bar => :references do
90
+ belongs_to :bar
91
+ end
92
+ create_model "Bar"
93
+ assert_equal true, create_attribute(Foo, "bar_id").foreign_key?
94
+ end
95
+
96
+ test "foreign_key should return true if it is used in a remote association" do
97
+ create_model "Foo", :bar => :references
98
+ create_model "Bar" do
99
+ has_many :foos
100
+ end
101
+ assert_equal true, create_attribute(Foo, "bar_id").foreign_key?
102
+ end
103
+
104
+ test "timestamp should return false by default" do
105
+ create_model "Foo", :created => :datetime
106
+ assert_equal false, create_attribute(Foo, "created").timestamp?
107
+ end
108
+
109
+ test "timestamp should return true if it is named created_at/on or updated_at/on" do
110
+ create_model "Foo", :created_at => :string, :updated_at => :string, :created_on => :string, :updated_on => :string
111
+ assert_equal [true] * 4, [create_attribute(Foo, "created_at"), create_attribute(Foo, "updated_at"),
112
+ create_attribute(Foo, "created_on"), create_attribute(Foo, "updated_on")].collect(&:timestamp?)
113
+ end
114
+
115
+ # Type descriptions ========================================================
116
+ test "type_description should return short type description" do
117
+ create_model "Foo", :a => :binary
118
+ assert_equal "blob", create_attribute(Foo, "a").type_description
119
+ end
120
+
121
+ test "type_description should return short type description without limit if standard" do
122
+ with_native_limit :string, 456 do
123
+ create_model "Foo"
124
+ add_column :foos, :my_str, :string, :limit => 255
125
+ ActiveRecord::Base.connection.native_database_types[:string]
126
+ assert_equal "str (255)", create_attribute(Foo, "my_str").type_description
127
+ end
128
+ end
129
+
130
+ test "type_description should return short type description with limit if nonstandard" do
131
+ with_native_limit :string, 456 do
132
+ create_model "Foo"
133
+ add_column :foos, :my_str, :string, :limit => 456
134
+ assert_equal "str", create_attribute(Foo, "my_str").type_description
135
+ end
136
+ end
137
+
138
+ test "type_description should append hair space and low asterisk if field is mandatory" do
139
+ create_model "Foo", :a => :integer do
140
+ validates_presence_of :a
141
+ end
142
+ assert_equal "int ∗", create_attribute(Foo, "a").type_description
143
+ end
144
+ end
@@ -0,0 +1,8 @@
1
+ require File.expand_path("../test_helper", File.dirname(__FILE__))
2
+
3
+ class CardinalityTest < ActiveSupport::TestCase
4
+ test "cardinalities should be sorted in order of maniness" do
5
+ assert_equal [Relationship::Cardinality::OneToOne, Relationship::Cardinality::OneToMany, Relationship::Cardinality::ManyToMany],
6
+ [Relationship::Cardinality::OneToMany, Relationship::Cardinality::ManyToMany, Relationship::Cardinality::OneToOne].sort
7
+ end
8
+ end
File without changes
@@ -0,0 +1,125 @@
1
+ require File.expand_path("../test_helper", File.dirname(__FILE__))
2
+
3
+ class DomainTest < ActiveSupport::TestCase
4
+ # Domain ===================================================================
5
+ test "generate should return domain" do
6
+ assert_kind_of Domain, Domain.generate
7
+ end
8
+
9
+ test "name should return rails application name" do
10
+ Object::Quux = Module.new
11
+ Object::Quux::Application = Class.new Rails::Application
12
+ assert_equal "Quux", Domain.generate.name
13
+ end
14
+
15
+ test "name should return nil outside rails" do
16
+ assert_nil Domain.generate.name
17
+ end
18
+
19
+ # Entity processing ========================================================
20
+ test "entity_for should return associated entity for given model" do
21
+ create_model "Foo"
22
+ assert_equal Foo, Domain.generate.entity_for(Foo).model
23
+ end
24
+
25
+ test "entities should return domain entities" do
26
+ create_models "Foo", "Bar"
27
+ assert_equal [Entity] * 2, Domain.generate.entities.collect(&:class)
28
+ end
29
+
30
+ test "entities should return all domain entities sorted by name" do
31
+ create_models "Foo", "Bar", "Baz", "Qux"
32
+ assert_equal [Bar, Baz, Foo, Qux], Domain.generate.entities.collect(&:model)
33
+ end
34
+
35
+ # Relationship processing ==================================================
36
+ test "relationships should return empty array for empty domain" do
37
+ assert_equal [], Domain.generate.relationships
38
+ end
39
+
40
+ test "relationships should return relationships in domain model" do
41
+ create_models "Baz", "Qux"
42
+ create_model "Foo", :bar => :references, :qux => :references do
43
+ belongs_to :bar
44
+ belongs_to :qux
45
+ end
46
+ create_model "Bar", :baz => :references do
47
+ belongs_to :baz
48
+ end
49
+ assert_equal [Relationship] * 3, Domain.generate.relationships.collect(&:class)
50
+ end
51
+
52
+ test "relationships should count mutual relationship as one" do
53
+ create_model "Foo", :bar => :references do
54
+ belongs_to :bar
55
+ end
56
+ create_model "Bar" do
57
+ has_many :foos
58
+ end
59
+ assert_equal [Relationship], Domain.generate.relationships.collect(&:class)
60
+ end
61
+
62
+ test "relationships should count relationship between same models with distinct foreign key seperately" do
63
+ create_model "Foo", :bar => :references, :special_bar => :references do
64
+ belongs_to :bar
65
+ end
66
+ create_model "Bar" do
67
+ has_many :foos, :foreign_key => :special_bar_id
68
+ end
69
+ assert_equal [Relationship] * 2, Domain.generate.relationships.collect(&:class)
70
+ end
71
+
72
+ # Erroneous associations ===================================================
73
+ test "relationships should omit bad has_many associations" do
74
+ create_model "Foo" do
75
+ has_many :flabs
76
+ end
77
+ assert_equal [], Domain.generate(:suppress_warnings => true).relationships
78
+ end
79
+
80
+ test "relationships should omit bad has_many through association" do
81
+ create_model "Foo" do
82
+ has_many :flabs, :through => :bars
83
+ end
84
+ assert_equal [], Domain.generate(:suppress_warnings => true).relationships
85
+ end
86
+
87
+ test "relationships should omit association to model outside domain" do
88
+ create_model "Foo" do
89
+ has_many :bars
90
+ end
91
+ create_model "Bar", :foo => :references
92
+ assert_equal [], Domain.new([Foo], :suppress_warnings => true).relationships
93
+ end
94
+
95
+ test "relationships should output a warning when a bad association is encountered" do
96
+ create_model "Foo" do
97
+ has_many :flabs
98
+ end
99
+ output = collect_stdout do
100
+ Domain.generate.relationships
101
+ end
102
+ assert_match /Invalid association :flabs on Foo/, output
103
+ end
104
+
105
+ test "relationships should output a warning when an association to model outside domain is encountered" do
106
+ create_model "Foo" do
107
+ has_many :bars
108
+ end
109
+ create_model "Bar", :foo => :references
110
+ output = collect_stdout do
111
+ Domain.new([Foo]).relationships
112
+ end
113
+ assert_match /model Bar exists, but is not included in the domain/, output
114
+ end
115
+
116
+ test "relationships should suppress warnings when a bad association is encountered if warning suppression is enabled" do
117
+ create_model "Foo" do
118
+ has_many :flabs
119
+ end
120
+ output = collect_stdout do
121
+ Domain.generate(:suppress_warnings => true).relationships
122
+ end
123
+ assert_equal "", output
124
+ end
125
+ end