rails-erd 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,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