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.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.idea/misc.xml +4 -0
- data/.idea/workspace.xml +65 -0
- data/.rspec +3 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +59 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/db_diagram.gemspec +34 -0
- data/lib/db_diagram.rb +48 -0
- data/lib/db_diagram/diagram.rb +184 -0
- data/lib/db_diagram/diagram/graphviz.rb +315 -0
- data/lib/db_diagram/diagram/templates/node.html.erb +14 -0
- data/lib/db_diagram/diagram/templates/node.record.erb +4 -0
- data/lib/db_diagram/domain.rb +173 -0
- data/lib/db_diagram/domain/attribute.rb +162 -0
- data/lib/db_diagram/domain/entity.rb +81 -0
- data/lib/db_diagram/domain/relationship.rb +194 -0
- data/lib/db_diagram/domain/relationship/cardinality.rb +118 -0
- data/lib/db_diagram/version.rb +3 -0
- metadata +152 -0
@@ -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
|