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