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,162 @@
1
+ # encoding: utf-8
2
+
3
+ #--
4
+ module DBDiagram
5
+ class Domain
6
+ # Describes an entity's attribute. Attributes correspond directly to
7
+ # database columns.
8
+ class Attribute
9
+ TIMESTAMP_NAMES = %w{created_at created_on updated_at updated_on} # @private :nodoc:
10
+
11
+ class << self
12
+ def from_model(domain, model) # @private :nodoc:
13
+ attributes = model.columns.collect { |column| new(domain, model, column) }
14
+ attributes.sort! if DBDiagram.options[:sort]
15
+
16
+ if DBDiagram.options[:prepend_primary]
17
+ attributes = prepend_primary(model, attributes)
18
+ end
19
+
20
+ attributes
21
+ end
22
+
23
+ def prepend_primary(model, attributes)
24
+ primary_key = ActiveRecord::Base.get_primary_key(model)
25
+ primary = attributes.index { |column| column.name == primary_key }
26
+
27
+ return attributes if primary || primary == 0
28
+
29
+ [primary_key, *attributes[0...primary], *attributes[(primary+1)..-1]]
30
+ end
31
+ end
32
+
33
+ extend Inspectable
34
+ inspection_attributes :name, :type
35
+
36
+ attr_reader :column # @private :nodoc:
37
+
38
+ def initialize(domain, model, column) # @private :nodoc:
39
+ @domain, @model, @column = domain, model, column
40
+ end
41
+
42
+ # The name of the attribute, equal to the column name.
43
+ def name
44
+ column.name
45
+ end
46
+
47
+ # The type of the attribute, equal to the Rails migration type. Can be any
48
+ # of +:string+, +:integer+, +:boolean+, +:text+, etc.
49
+ def type
50
+ column.sql_type.downcase.to_sym or column.type
51
+ end
52
+
53
+ # Returns +true+ if this attribute is a content column, that is, if it
54
+ # is not a primary key, foreign key, timestamp, or inheritance column.
55
+ def content?
56
+ !primary_key? and !foreign_key? and !timestamp? and !inheritance?
57
+ end
58
+
59
+ # Returns +true+ if this attribute is mandatory. Mandatory attributes
60
+ # either have a presence validation (+validates_presence_of+), or have a
61
+ # <tt>NOT NULL</tt> database constraint.
62
+ def mandatory?
63
+ !column.null or @model.validators_on(name).map(&:kind).include?(:presence)
64
+ end
65
+
66
+ def unique?
67
+ @model.validators_on(name).map(&:kind).include?(:uniqueness)
68
+ end
69
+
70
+ # Returns +true+ if this attribute is the primary key of the entity.
71
+ def primary_key?
72
+ @model.primary_key.to_s == name.to_s
73
+ end
74
+
75
+ # Returns +true+ if this attribute is used as a foreign key for any
76
+ # relationship.
77
+ def foreign_key?
78
+ @domain.relationships_by_entity_name(@model.name).map(&:associations).flatten.map { |associaton|
79
+ associaton.send(Domain.foreign_key_method_name)
80
+ }.include?(name)
81
+ end
82
+
83
+ # Returns +true+ if this attribute is used for single table inheritance.
84
+ # These attributes are typically named +type+.
85
+ def inheritance?
86
+ @model.inheritance_column == name
87
+ end
88
+
89
+ # Method allows false to be set as an attributes option when making custom graphs.
90
+ # It rejects all attributes when called from Diagram#filtered_attributes method
91
+ def false?
92
+ false
93
+ end
94
+
95
+ def true?
96
+ true
97
+ end
98
+
99
+ # Returns +true+ if this attribute is one of the standard 'magic' Rails
100
+ # timestamp columns, being +created_at+, +updated_at+, +created_on+ or
101
+ # +updated_on+.
102
+ def timestamp?
103
+ TIMESTAMP_NAMES.include? name
104
+ end
105
+
106
+ def <=>(other) # @private :nodoc:
107
+ name <=> other.name
108
+ end
109
+
110
+ def to_s # @private :nodoc:
111
+ name
112
+ end
113
+
114
+ # Returns a description of the attribute type. If the attribute has
115
+ # a non-standard limit or if it is mandatory, this information is included.
116
+ #
117
+ # Example output:
118
+ # <tt>:integer</tt>:: integer
119
+ # <tt>:string, :limit => 255</tt>:: string
120
+ # <tt>:string, :limit => 128</tt>:: string (128)
121
+ # <tt>:decimal, :precision => 5, :scale => 2/tt>:: decimal (5,2)
122
+ # <tt>:boolean, :null => false</tt>:: boolean *
123
+ def type_description
124
+ type.to_s.tap do |desc|
125
+ # desc << " #{limit_description}" if limit_description
126
+ desc << " ∗" if mandatory? && !primary_key? # Add a hair space + low asterisk (Unicode characters)
127
+ desc << " U" if unique? && !primary_key? && !foreign_key? # Add U if unique but non-key
128
+ desc << " PK" if primary_key?
129
+ desc << " FK" if foreign_key?
130
+ end
131
+ end
132
+
133
+ # Returns any non-standard limit for this attribute. If a column has no
134
+ # limit or uses a default database limit, this method returns +nil+.
135
+ def limit
136
+ return if native_type == 'geometry' || native_type == 'geography'
137
+ return column.limit.to_i if column.limit != native_type[:limit] and column.limit.respond_to?(:to_i)
138
+ column.precision.to_i if column.precision != native_type[:precision] and column.precision.respond_to?(:to_i)
139
+ end
140
+
141
+ # Returns any non-standard scale for this attribute (decimal types only).
142
+ def scale
143
+ return column.scale.to_i if column.scale != native_type[:scale] and column.scale.respond_to?(:to_i)
144
+ 0 if column.precision
145
+ end
146
+
147
+ # Returns a string that describes the limit for this attribute, such as
148
+ # +(128)+, or +(5,2)+ for decimal types. Returns nil if no non-standard
149
+ # limit was set.
150
+ def limit_description # @private :nodoc:
151
+ return "(#{limit},#{scale})" if limit and scale
152
+ return "(#{limit})" if limit
153
+ end
154
+
155
+ private
156
+
157
+ def native_type
158
+ @model.connection.native_database_types[type] or {}
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,81 @@
1
+ module DBDiagram
2
+ class Domain
3
+ # Entities represent your Active Record models. Entities may be connected
4
+ # to other entities.
5
+ class Entity
6
+ class << self
7
+ def from_models(domain, models) # @private :nodoc:
8
+ concrete_from_models(domain, models).sort
9
+ end
10
+
11
+ private
12
+
13
+ def concrete_from_models(domain, models)
14
+ models.collect { |model| new(domain, model) }
15
+ end
16
+ end
17
+
18
+ extend Inspectable
19
+ inspection_attributes :model
20
+
21
+ # The domain in which this entity resides.
22
+ attr_reader :domain
23
+
24
+ # The Active Record model that this entity corresponds to.
25
+ attr_reader :model
26
+
27
+ # The name of this entity. Equal to the class name of the corresponding
28
+ # model (for concrete entities) or given name (for abstract entities).
29
+ attr_reader :name
30
+
31
+ def initialize(domain, model) # @private :nodoc:
32
+ @domain, @model = domain, model
33
+ @name = !!model.abstract_class? ? model.name : model.table_name
34
+ end
35
+
36
+ # Returns an array of attributes for this entity.
37
+ def attributes
38
+ @attributes ||= generalized? ? [] : Attribute.from_model(domain, model)
39
+ end
40
+
41
+ # Returns an array of all relationships that this entity has with other
42
+ # entities in the domain model.
43
+ def relationships
44
+ domain.relationships_by_entity_name(name)
45
+ end
46
+
47
+ # Returns +true+ if this entity is a generalization, which does not
48
+ # correspond with a database table. Generalized entities are either
49
+ # models that are defined as +abstract_class+ or they are constructed
50
+ # from polymorphic interfaces. Any +has_one+ or +has_many+ association
51
+ # that defines a polymorphic interface with <tt>:as => :name</tt> will
52
+ # lead to a generalized entity to be created.
53
+ def generalized?
54
+ !!model.abstract_class?
55
+ end
56
+
57
+ # Returns +true+ if this entity does not correspond directly with a
58
+ # database table (if and only if the entity is specialized or
59
+ # generalized).
60
+ def abstract?
61
+ generalized?
62
+ end
63
+
64
+ def namespace
65
+ $1 if name.match(/(.*)::.*/)
66
+ end
67
+
68
+ def model_name
69
+ model.name
70
+ end
71
+
72
+ def to_s # @private :nodoc:
73
+ name
74
+ end
75
+
76
+ def <=>(other) # @private :nodoc:
77
+ self.name <=> other.name
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,194 @@
1
+ require "set"
2
+ require "active_support/core_ext/module/delegation"
3
+ require "db_diagram/domain/relationship/cardinality"
4
+
5
+ module DBDiagram
6
+ class Domain
7
+ # Describes a relationship between two entities. A relationship is detected
8
+ # based on Active Record associations. One relationship may represent more
9
+ # than one association, however. Related associations are grouped together.
10
+ # Associations are related if they share the same foreign key, or the same
11
+ # join table in the case of many-to-many associations.
12
+ class Relationship
13
+ N = Cardinality::N
14
+
15
+ class << self
16
+ def from_associations(domain, associations) # @private :nodoc:
17
+ assoc_groups = associations.group_by { |assoc| association_identity(assoc) }
18
+ assoc_groups.collect { |_, assoc_group| new(domain, assoc_group.to_a) }
19
+ end
20
+
21
+ private
22
+
23
+ def association_identity(association)
24
+ Set[association_owner(association), association_target(association)]
25
+ end
26
+
27
+ def association_identifier(association)
28
+ if association.macro == :has_and_belongs_to_many
29
+ # Rails 4+ supports the join_table method, and doesn't expose it
30
+ # as an option if it's an implicit default.
31
+ (association.respond_to?(:join_table) && association.join_table) || association.options[:join_table]
32
+ else
33
+ association.options[:through] || association.send(Domain.foreign_key_method_name).to_s
34
+ end
35
+ end
36
+
37
+ def association_owner(association)
38
+ association.options[:as] ? association.options[:as].to_s.classify : association.active_record.name
39
+ end
40
+
41
+ def association_target(association)
42
+ association.options[:polymorphic] ? association.class_name : association.klass.name
43
+ end
44
+ end
45
+
46
+ extend Inspectable
47
+ inspection_attributes :source, :destination
48
+
49
+ # The domain in which this relationship is defined.
50
+ attr_reader :domain
51
+
52
+ # The source entity. It corresponds to the model that has defined a
53
+ # +has_one+ or +has_many+ association with the other model.
54
+ attr_reader :source
55
+
56
+ # The destination entity. It corresponds to the model that has defined
57
+ # a +belongs_to+ association with the other model.
58
+ attr_reader :destination
59
+
60
+ delegate :one_to_one?, :one_to_many?, :many_to_many?, :source_optional?,
61
+ :destination_optional?, :to => :cardinality
62
+
63
+ def initialize(domain, associations) # @private :nodoc:
64
+ @domain = domain
65
+ @reverse_associations, @forward_associations = partition_associations(associations)
66
+
67
+ assoc = @forward_associations.first || @reverse_associations.first
68
+ @source = @domain.entity_by_name(self.class.send(:association_owner, assoc))
69
+ @destination = @domain.entity_by_name(self.class.send(:association_target, assoc))
70
+ @source, @destination = @destination, @source if assoc.belongs_to?
71
+ end
72
+
73
+ # Returns all Active Record association objects that describe this
74
+ # relationship.
75
+ def associations
76
+ @forward_associations + @reverse_associations
77
+ end
78
+
79
+ # Returns the cardinality of this relationship.
80
+ def cardinality
81
+ @cardinality ||= begin
82
+ reverse_max = any_habtm?(associations) ? N : 1
83
+ forward_range = associations_range(@forward_associations, N)
84
+ reverse_range = associations_range(@reverse_associations, reverse_max)
85
+ Cardinality.new(reverse_range, forward_range)
86
+ end
87
+ end
88
+
89
+ # Indicates whether or not the relationship is defined by two inverse
90
+ # associations (e.g. a +has_many+ and a corresponding +belongs_to+
91
+ # association).
92
+ def mutual?
93
+ @forward_associations.any? and @reverse_associations.any?
94
+ end
95
+
96
+ # Indicates whether or not this relationship connects an entity with itself.
97
+ def recursive?
98
+ @source == @destination
99
+ end
100
+
101
+ # Indicates whether the destination cardinality class of this relationship
102
+ # is equal to one. This is +true+ for one-to-one relationships only.
103
+ def to_one?
104
+ cardinality.cardinality_class[1] == 1
105
+ end
106
+
107
+ # Indicates whether the destination cardinality class of this relationship
108
+ # is equal to infinity. This is +true+ for one-to-many or
109
+ # many-to-many relationships only.
110
+ def to_many?
111
+ cardinality.cardinality_class[1] != 1
112
+ end
113
+
114
+ # Indicates whether the source cardinality class of this relationship
115
+ # is equal to one. This is +true+ for one-to-one or
116
+ # one-to-many relationships only.
117
+ def one_to?
118
+ cardinality.cardinality_class[0] == 1
119
+ end
120
+
121
+ # Indicates whether the source cardinality class of this relationship
122
+ # is equal to infinity. This is +true+ for many-to-many relationships only.
123
+ def many_to?
124
+ cardinality.cardinality_class[0] != 1
125
+ end
126
+
127
+ # The strength of a relationship is equal to the number of associations
128
+ # that describe it.
129
+ def strength
130
+ if source.generalized? then 1 else associations.size end
131
+ end
132
+
133
+ def <=>(other) # @private :nodoc:
134
+ (source.name <=> other.source.name).nonzero? or (destination.name <=> other.destination.name)
135
+ end
136
+
137
+ private
138
+
139
+ def partition_associations(associations)
140
+ if any_habtm?(associations)
141
+ # Many-to-many associations don't have a clearly defined direction.
142
+ # We sort by name and use the first model as the source.
143
+ source = associations.map(&:active_record).sort_by(&:name).first
144
+ associations.partition { |association| association.active_record != source }
145
+ else
146
+ associations.partition(&:belongs_to?)
147
+ end
148
+ end
149
+
150
+ def associations_range(associations, absolute_max)
151
+ # The minimum of the range is the maximum value of each association
152
+ # minimum. If there is none, it is zero by definition. The reasoning is
153
+ # that from all associations, if only one has a required minimum, then
154
+ # this side of the relationship has a cardinality of at least one.
155
+ min = associations.map { |assoc| association_minimum(assoc) }.max || 0
156
+
157
+ # The maximum of the range is the maximum value of each association
158
+ # maximum. If there is none, it is equal to the absolute maximum. If
159
+ # only one association has a high cardinality on this side, the
160
+ # relationship itself has the same maximum cardinality.
161
+ max = associations.map { |assoc| association_maximum(assoc) }.max || absolute_max
162
+
163
+ min..max
164
+ end
165
+
166
+ def association_minimum(association)
167
+ minimum = association_validators(:presence, association).any? ||
168
+ foreign_key_required?(association) ? 1 : 0
169
+ length_validators = association_validators(:length, association)
170
+ length_validators.map { |v| v.options[:minimum] }.compact.max or minimum
171
+ end
172
+
173
+ def association_maximum(association)
174
+ maximum = association.collection? ? N : 1
175
+ length_validators = association_validators(:length, association)
176
+ length_validators.map { |v| v.options[:maximum] }.compact.min or maximum
177
+ end
178
+
179
+ def association_validators(kind, association)
180
+ association.active_record.validators_on(association.name).select { |v| v.kind == kind }
181
+ end
182
+
183
+ def any_habtm?(associations)
184
+ associations.any? { |association| association.macro == :has_and_belongs_to_many }
185
+ end
186
+
187
+ def foreign_key_required?(association)
188
+ if !association.active_record.abstract_class? and association.belongs_to?
189
+ column = association.active_record.columns_hash[association.send(Domain.foreign_key_method_name)] and !column.null
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,118 @@
1
+ module DBDiagram
2
+ class Domain
3
+ class Relationship
4
+ class Cardinality
5
+ extend Inspectable
6
+ inspection_attributes :source_range, :destination_range
7
+
8
+ N = Infinity = 1.0/0 # And beyond.
9
+
10
+ CLASSES = {
11
+ [1, 1] => :one_to_one,
12
+ [1, N] => :one_to_many,
13
+ [N, 1] => :many_to_one,
14
+ [N, N] => :many_to_many
15
+ } # @private :nodoc:
16
+
17
+ # Returns a range that indicates the source (left) cardinality.
18
+ attr_reader :source_range
19
+
20
+ # Returns a range that indicates the destination (right) cardinality.
21
+ attr_reader :destination_range
22
+
23
+ # Create a new cardinality based on a source range and a destination
24
+ # range. These ranges describe which number of values are valid.
25
+ def initialize(source_range, destination_range) # @private :nodoc:
26
+ @source_range = compose_range(source_range)
27
+ @destination_range = compose_range(destination_range)
28
+ end
29
+
30
+ # Returns the name of this cardinality, based on its two cardinal
31
+ # numbers (for source and destination). Can be any of
32
+ # +:one_to_one:+, +:one_to_many+, or +:many_to_many+. The name
33
+ # +:many_to_one+ also exists, but Rails ERD always normalises these
34
+ # kinds of relationships by inverting them, so they become
35
+ # +:one_to_many+ associations.
36
+ #
37
+ # You can also call the equivalent method with a question mark, which
38
+ # will return true if the name corresponds to that method. For example:
39
+ #
40
+ # cardinality.one_to_one?
41
+ # #=> true
42
+ # cardinality.one_to_many?
43
+ # #=> false
44
+ def name
45
+ CLASSES[cardinality_class]
46
+ end
47
+
48
+ # Returns +true+ if the source (left side) is not mandatory.
49
+ def source_optional?
50
+ source_range.first < 1
51
+ end
52
+
53
+ # Returns +true+ if the destination (right side) is not mandatory.
54
+ def destination_optional?
55
+ destination_range.first < 1
56
+ end
57
+
58
+ # Returns the inverse cardinality. Destination becomes source, source
59
+ # becomes destination.
60
+ def inverse
61
+ self.class.new destination_range, source_range
62
+ end
63
+
64
+ CLASSES.each do |cardinality_class, name|
65
+ class_eval <<-RUBY
66
+ def #{name}?
67
+ cardinality_class == #{cardinality_class.inspect}
68
+ end
69
+ RUBY
70
+ end
71
+
72
+ def ==(other) # @private :nodoc:
73
+ source_range == other.source_range and destination_range == other.destination_range
74
+ end
75
+
76
+ def <=>(other) # @private :nodoc:
77
+ (cardinality_class <=> other.cardinality_class).nonzero? or
78
+ compare_with(other) { |x| x.source_range.first + x.destination_range.first }.nonzero? or
79
+ compare_with(other) { |x| x.source_range.last + x.destination_range.last }.nonzero? or
80
+ compare_with(other) { |x| x.source_range.last }.nonzero? or
81
+ compare_with(other) { |x| x.destination_range.last }
82
+ end
83
+
84
+ # Returns an array with the cardinality classes for the source and
85
+ # destination of this cardinality. Possible return values are:
86
+ # <tt>[1, 1]</tt>, <tt>[1, N]</tt>, <tt>[N, N]</tt>, and (in theory)
87
+ # <tt>[N, 1]</tt>.
88
+ def cardinality_class
89
+ [source_cardinality_class, destination_cardinality_class]
90
+ end
91
+
92
+ protected
93
+
94
+ # The cardinality class of the source (left side). Either +1+ or +Infinity+.
95
+ def source_cardinality_class
96
+ source_range.last == 1 ? 1 : N
97
+ end
98
+
99
+ # The cardinality class of the destination (right side). Either +1+ or +Infinity+.
100
+ def destination_cardinality_class
101
+ destination_range.last == 1 ? 1 : N
102
+ end
103
+
104
+ private
105
+
106
+ def compose_range(r)
107
+ return r..r if r.kind_of?(Integer) && r > 0
108
+ return (r.begin)..(r.end - 1) if r.exclude_end?
109
+ r
110
+ end
111
+
112
+ def compare_with(other, &block)
113
+ yield(self) <=> yield(other)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end