typed_dag 1.0.1 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: e47a0669cc0a0487993711a18e1a37df86b809a5
4
- data.tar.gz: 11f39e3b2f2a189bb28d7f93d6ff4f3b309d6584
3
+ metadata.gz: a7e31c0c6a123f535ad41b59c8aabaac96129a70
4
+ data.tar.gz: 78ee741a3bf16ea7014f2cf69a8a3f5903c9fab1
5
5
  SHA512:
6
- metadata.gz: c11feb64fa4401c79b9285c2629b03d1e7558c6e8ef6f1202b258726beb9b464c9d406ee0bf23a14ed713260f2cbf49e56c630ade313e38fd4f39f2a06e4b57c
7
- data.tar.gz: 5662fb1a45f66349acc32697f56b09b3e292b9096c17b43ecd94c068203d164e62e569298120cad2e320a312de1f6a1b13da45f5fac1f4389f602d11446998d2
6
+ metadata.gz: 7941c61304bd469a8bff392405f09719f7246e6817e991cf835f7435d0570d8b832b37edaf87186ab359b57a20a0f5a5e949ffb65f87149b6745b4ab21be3b3c
7
+ data.tar.gz: 206c14332f5e58353063cf45ac0e8d279f632f017a14bbd1c96a0a2f4ab8e9cf3424f33cc1b2378c13eaf9363259caf2a8b26e920f37ec4a7eea412e24f2e383
data/README.md CHANGED
@@ -35,6 +35,10 @@ Again using the forum application as an example, one would have messages in a hi
35
35
 
36
36
  Please note that the name of the scopes (e.g. `children`, `hierarchy_roots` and `referenced`) and constants (`Message`) have to be configured.
37
37
 
38
+ ## Requirements
39
+
40
+ * Rails >= 5.0
41
+ * MySQL or PostgreSQL (>= 9.5) as `UPSERT` statements are used
38
42
 
39
43
  ## Installation
40
44
 
@@ -62,10 +66,12 @@ To avoid having to configure TypedDag twice, in the node and in the edge AR mode
62
66
 
63
67
  ```
64
68
  # /config/initializers/typed_dag.rb
69
+ # configuration for Relation/Message
65
70
  TypedDag::Configuration.set edge_class_name: 'Relation',
66
71
  node_class_name: 'Message',
67
72
  from_column: 'ancestor_id',
68
73
  to_column: 'descendant_id',
74
+ count_column: 'amount',
69
75
  types: { hierarchy: { from: { name: :parent, limit: 1 },
70
76
  to: :children,
71
77
  all_from: :ancestors,
@@ -75,6 +81,8 @@ To avoid having to configure TypedDag twice, in the node and in the edge AR mode
75
81
  all_from: :all_invalidated_by,
76
82
  all_to: :all_invalidates } }
77
83
 
84
+
85
+ # unrelated configuration for Edge/Node
78
86
  TypedDag::Configuration.set edge_class_name: 'Edge'
79
87
  node_class_name: 'Node',
80
88
  types: { edge: { from: :edges_from,
@@ -93,6 +101,7 @@ The following options exist:
93
101
  * `node_class_name`: The name of the AR model whose instances serve as the nodes of the dag
94
102
  * `from_column` (default `from_id`): The name of the column in the edges AR model that refer to the node the edge starts from
95
103
  * `to_column` (default `to_id`): The name of the column in the edges AR model that refer to the node the edge ends in
104
+ * `count_column` (default `count`): The name of the column in the edges AR model that keeps track of the number of identical edges between from and to
96
105
  * `types`: The hash of type configurations. The key of each configuration will need to be present as a column in the edge's DB table.
97
106
  * `from`: The AR association's name for nodes having a relation which end in the current node, have the type specified by the key and are not transitive (have only one hop). Only for `from` can one specify a limit to the number of relations a node can have. Doing this turns the DAG into a tree which is usefull for hierarchies. If a limit needs to be specified, the configuration has to be provided as `{ name: [association's name], limit: 1 }`. If no limit is given, the association's name can be provided as a symbol.
98
107
  * `to`: The AR association's name for nodes having a relation which start from the current node, have the type specified by the key and are not transitive (have only one hop)
@@ -156,7 +165,8 @@ The edge's table needs to be created containing at least the following columns (
156
165
 
157
166
  * `from_id`: A reference to the node the edge starts from.
158
167
  * `to_id`: A reference to the node the edge ends in.
159
- * `[key]`: A column of type integer for every type the dag is to support
168
+ * `count`: The counter column for the number of similar (transitive) edges between to and from.
169
+ * `[key]`: A column of type integer for every type the dag is to support.
160
170
 
161
171
  A migration to create such a table could look like this:
162
172
 
@@ -166,6 +176,8 @@ A migration to create such a table could look like this:
166
176
  t.references :from, null: false
167
177
  t.references :to, null: false
168
178
 
179
+ t.column :count, :integer, null: false, default: 0
180
+
169
181
  t.column :hierarchy, :integer, null: false, default: 0
170
182
  t.column :reference, :integer, null: false, default: 0
171
183
  end
@@ -173,17 +185,20 @@ A migration to create such a table could look like this:
173
185
  add_foreign_key :edges, :nodes, column: :from_id
174
186
  add_foreign_key :edges, :nodes, column: :to_id
175
187
 
188
+ # give the index a custom name to avoid running into length limitation when having a couple of columns
189
+ # in the index
176
190
  add_index :edges, [:hierarchy, :reference], name: `index_on_type_columns`
191
+ add_index :edges, :count, where: 'count = 0'
177
192
  end
178
193
  ```
179
194
 
180
- the table can also have additional columns. They will not interfere with TypedDag.
195
+ The table can also have additional columns. They will not interfere with TypedDag.
181
196
 
182
- Which indices to use will depend on the data added but having an index over all the type columns is a good start.
197
+ Which indices to use will depend on the data added but having an index over all the type columns is a good start. A partial index on count speeds up deleting edges while also being very lightweight to maintain.
183
198
 
184
- There are no requirements for the node's table.
199
+ There are no requirements on the node's table.
185
200
 
186
- When migrating from an different library, the details of course depend on the library used. If it is one of the many having a `parent_id` column on the node table, one would first have to create the edge table as outlined above and then add a SQL statement like this:
201
+ When migrating from a different library, the details of course depend on the library used. If it is one of the many having a `parent_id` column on the node table, one would first have to create the edge table as outlined above and then add a SQL statement like this:
187
202
 
188
203
  ```
189
204
  ActiveRecord::Base.connection.execute <<-SQL
@@ -56,6 +56,10 @@ class TypedDag::Configuration
56
56
  config[:to_column] || 'to_id'
57
57
  end
58
58
 
59
+ def count_column
60
+ config[:count_column] || 'count'
61
+ end
62
+
59
63
  def types
60
64
  config[:types] || default_types
61
65
  end
@@ -3,12 +3,17 @@ module TypedDag::Edge
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
+ before_create :set_count
6
7
  after_create :add_closures
7
8
  after_update :alter_closure
8
9
  after_destroy :truncate_closures
9
10
 
10
11
  private
11
12
 
13
+ def set_count
14
+ send("#{_dag_options.count_column}=", 1) if send(_dag_options.count_column).zero?
15
+ end
16
+
12
17
  def add_closures
13
18
  return unless direct?
14
19
 
@@ -20,7 +25,7 @@ module TypedDag::Edge
20
25
  # However, #persisted? will be false for destroyed records.
21
26
  return unless direct? && !new_record?
22
27
 
23
- self.class.connection.execute truncate_dag_closure_sql(self)
28
+ update_and_delete_closure(self)
24
29
  end
25
30
 
26
31
  def truncate_closures_with_former_values
@@ -35,7 +40,7 @@ module TypedDag::Edge
35
40
 
36
41
  former_values_relation.attributes = changes
37
42
 
38
- self.class.connection.execute truncate_dag_closure_sql(former_values_relation)
43
+ update_and_delete_closure(former_values_relation)
39
44
  end
40
45
 
41
46
  def alter_closure
@@ -45,6 +50,11 @@ module TypedDag::Edge
45
50
  add_closures
46
51
  end
47
52
 
53
+ def update_and_delete_closure(relation)
54
+ self.class.connection.execute truncate_dag_closure_sql(relation)
55
+ self.class.connection.execute delete_zero_count_sql(relation)
56
+ end
57
+
48
58
  def add_dag_closure_sql
49
59
  TypedDag::Sql::AddClosure.sql(self)
50
60
  end
@@ -53,6 +63,10 @@ module TypedDag::Edge
53
63
  TypedDag::Sql::TruncateClosure.sql(relation)
54
64
  end
55
65
 
66
+ def delete_zero_count_sql(relation)
67
+ TypedDag::Sql::DeleteZeroCount.sql(relation)
68
+ end
69
+
56
70
  def from_id_value
57
71
  send(_dag_options.from_column)
58
72
  end
data/lib/typed_dag/sql.rb CHANGED
@@ -6,5 +6,6 @@ module TypedDag
6
6
  require 'typed_dag/sql/get_circular'
7
7
  require 'typed_dag/sql/remove_invalid_relation'
8
8
  require 'typed_dag/sql/insert_reflexive'
9
+ require 'typed_dag/sql/delete_zero_count'
9
10
  end
10
11
  end
@@ -15,18 +15,49 @@ module TypedDag::Sql::AddClosure
15
15
 
16
16
  def sql
17
17
  <<-SQL
18
- INSERT INTO #{table_name}
19
- (#{from_column},
20
- #{to_column},
21
- #{type_select_list})
22
- #{closure_select}
18
+ #{insert_sql}
19
+ #{on_duplicate}
23
20
  SQL
24
21
  end
25
22
 
26
23
  private
27
24
 
25
+ def on_duplicate
26
+ if helper.mysql_db?
27
+ on_duplicate_mysql
28
+ else
29
+ on_duplicate_postgresql
30
+ end
31
+ end
32
+
33
+ def on_duplicate_mysql
34
+ <<-SQL
35
+ ON DUPLICATE KEY
36
+ UPDATE #{count_column} = #{table_name}.#{count_column} + VALUES(#{count_column})
37
+ SQL
38
+ end
39
+
40
+ def on_duplicate_postgresql
41
+ <<-SQL
42
+ ON CONFLICT (#{column_list})
43
+ DO UPDATE SET #{count_column} = #{table_name}.#{count_column} + EXCLUDED.#{count_column}
44
+ SQL
45
+ end
46
+
47
+ def insert_sql
48
+ <<-SQL
49
+ INSERT INTO #{table_name}
50
+ (#{column_list}, #{count_column})
51
+ #{closure_select}
52
+ SQL
53
+ end
54
+
28
55
  def closure_select
29
56
  TypedDag::Sql::SelectClosure.sql(relation)
30
57
  end
58
+
59
+ def column_list
60
+ "#{from_column}, #{to_column}, #{type_select_list}"
61
+ end
31
62
  end
32
63
  end
@@ -0,0 +1,22 @@
1
+ require 'typed_dag/sql/helper'
2
+
3
+ module TypedDag::Sql::DeleteZeroCount
4
+ def self.sql(relation)
5
+ Sql.new(relation).sql
6
+ end
7
+
8
+ class Sql
9
+ include TypedDag::Sql::RelationAccess
10
+
11
+ def initialize(relation)
12
+ self.relation = relation
13
+ end
14
+
15
+ def sql
16
+ <<-SQL
17
+ DELETE FROM #{table_name}
18
+ WHERE #{count_column} = 0
19
+ SQL
20
+ end
21
+ end
22
+ end
@@ -13,8 +13,10 @@ module TypedDag::Sql::GetCircular
13
13
  def sql(depth)
14
14
  <<-SQL
15
15
  SELECT
16
- r1.#{helper.from_column},
17
- r1.#{helper.to_column}
16
+ r1.#{helper.from_column} AS r1_from_column,
17
+ r1.#{helper.to_column} AS r1_to_column,
18
+ r2.#{helper.from_column} AS r2_from_column,
19
+ r2.#{helper.to_column} AS r2_to_column
18
20
  FROM #{helper.table_name} r1
19
21
  JOIN #{helper.table_name} r2
20
22
  ON #{join_condition(depth)}
@@ -23,13 +23,23 @@ class TypedDag::Sql::Helper
23
23
  configuration.type_columns
24
24
  end
25
25
 
26
+ def count_column
27
+ configuration.count_column
28
+ end
29
+
26
30
  def type_select_list
27
31
  type_columns.join(', ')
28
32
  end
29
33
 
30
34
  def type_select_summed_columns(prefix1, prefix2)
31
35
  type_columns
32
- .map { |column| "#{prefix1}.#{column} + #{prefix2}.#{column}" }
36
+ .map { |column| "#{prefix1}.#{column} + #{prefix2}.#{column} " }
37
+ .join(', ')
38
+ end
39
+
40
+ def type_select_summed_columns_aliased(prefix1, prefix2)
41
+ type_columns
42
+ .map { |column| "(#{prefix1}.#{column} + #{prefix2}.#{column}) #{column}" }
33
43
  .join(', ')
34
44
  end
35
45
 
@@ -45,6 +55,10 @@ class TypedDag::Sql::Helper
45
55
  type_columns.map { |column| "#{prefix}#{column} = 1" }.join(' XOR ')
46
56
  end
47
57
 
58
+ def mysql_db?
59
+ ActiveRecord::Base.connection.adapter_name == 'Mysql2'
60
+ end
61
+
48
62
  private
49
63
 
50
64
  attr_accessor :configuration
@@ -11,19 +11,50 @@ module TypedDag::Sql::InsertClosureOfDepth
11
11
  end
12
12
 
13
13
  def sql(depth)
14
+ if helper.mysql_db?
15
+ sql_mysql(depth)
16
+ else
17
+ sql_postgresql(depth)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def sql_mysql(depth)
24
+ <<-SQL
25
+ #{insert_sql(depth)}
26
+ ON DUPLICATE KEY
27
+ UPDATE #{helper.table_name}.#{helper.count_column} = #{helper.table_name}.#{helper.count_column} + VALUES(#{helper.count_column})
28
+ SQL
29
+ end
30
+
31
+ def sql_postgresql(depth)
32
+ <<-SQL
33
+ #{insert_sql(depth)}
34
+ ON CONFLICT (#{insert_list})
35
+ DO UPDATE SET #{helper.count_column} = #{helper.table_name}.#{helper.count_column} + EXCLUDED.#{helper.count_column}
36
+ SQL
37
+ end
38
+
39
+ def insert_sql(depth)
14
40
  <<-SQL
15
41
  INSERT INTO #{helper.table_name}
16
- (#{insert_list})
17
- SELECT
18
- #{select_list}
42
+ (#{insert_list}, #{helper.count_column})
43
+ SELECT #{insert_list}, #{helper.count_column} FROM
44
+ (#{sum_select(depth)}) to_insert
45
+ SQL
46
+ end
47
+
48
+ def sum_select(depth)
49
+ <<-SQL
50
+ SELECT #{select_list}, SUM(r1.#{helper.count_column} * r2.#{helper.count_column}) AS #{helper.count_column}
19
51
  FROM #{helper.table_name} r1
20
52
  JOIN #{helper.table_name} r2
21
53
  ON #{join_condition(depth)}
54
+ GROUP BY #{group_list}
22
55
  SQL
23
56
  end
24
57
 
25
- private
26
-
27
58
  def insert_list
28
59
  [helper.from_column,
29
60
  helper.to_column,
@@ -31,6 +62,14 @@ module TypedDag::Sql::InsertClosureOfDepth
31
62
  end
32
63
 
33
64
  def select_list
65
+ <<-SQL
66
+ r1.#{helper.from_column},
67
+ r2.#{helper.to_column},
68
+ #{helper.type_select_summed_columns_aliased('r1', 'r2')}
69
+ SQL
70
+ end
71
+
72
+ def group_list
34
73
  <<-SQL
35
74
  r1.#{helper.from_column},
36
75
  r2.#{helper.to_column},
@@ -14,8 +14,9 @@ module TypedDag::Sql::InsertReflexive
14
14
  <<-SQL
15
15
  INSERT INTO #{helper.table_name}
16
16
  (#{helper.from_column},
17
- #{helper.to_column})
18
- SELECT id, id
17
+ #{helper.to_column},
18
+ #{helper.count_column})
19
+ SELECT id, id, 1
19
20
  FROM #{helper.node_table_name}
20
21
  SQL
21
22
  end
@@ -11,6 +11,7 @@ module TypedDag::Sql::RelationAccess
11
11
  delegate :table_name,
12
12
  :from_column,
13
13
  :to_column,
14
+ :count_column,
14
15
  :type_columns,
15
16
  :type_select_list,
16
17
  to: :helper
@@ -15,15 +15,26 @@ module TypedDag::Sql::SelectClosure
15
15
  def sql
16
16
  <<-SQL
17
17
  SELECT
18
- r1.#{from_column},
19
- r2.#{to_column},
20
- #{depth_sum_case}
18
+ #{from_column},
19
+ #{to_column},
20
+ #{type_columns.join(', ')},
21
+ SUM(#{count_column}) AS #{count_column}
21
22
  FROM
22
- #{table_name} r1
23
- JOIN
24
- #{table_name} r2
25
- ON
26
- (#{relations_join_combines_paths_condition})
23
+ (SELECT
24
+ r1.#{from_column},
25
+ r2.#{to_column},
26
+ #{depth_sum_case},
27
+ r1.#{count_column} * r2.#{count_column} AS #{count_column}
28
+ FROM
29
+ #{table_name} r1
30
+ JOIN
31
+ #{table_name} r2
32
+ ON
33
+ (#{relations_join_combines_paths_condition})) unique_rows
34
+ GROUP BY
35
+ #{from_column},
36
+ #{to_column},
37
+ #{type_columns.join(', ')}
27
38
  SQL
28
39
  end
29
40
 
@@ -40,7 +51,7 @@ module TypedDag::Sql::SelectClosure
40
51
  ELSE 0
41
52
  END AS #{column}
42
53
  SQL
43
- end.join(', ')
54
+ end.map(&:strip).join(', ')
44
55
  end
45
56
 
46
57
  def relations_join_combines_paths_condition
@@ -13,7 +13,7 @@ module TypedDag::Sql::TruncateClosure
13
13
  end
14
14
 
15
15
  def sql
16
- if mysql_db?
16
+ if helper.mysql_db?
17
17
  sql_mysql
18
18
  else
19
19
  sql_postgresql
@@ -26,39 +26,37 @@ module TypedDag::Sql::TruncateClosure
26
26
 
27
27
  def sql_mysql
28
28
  <<-SQL
29
- DELETE
30
- deletion_table
31
- FROM
32
- #{table_name} deletion_table
33
- INNER JOIN
34
- #{selection_table} selection_table
35
- ON deletion_table.id = selection_table.id
29
+ UPDATE #{table_name}
30
+ JOIN
31
+ (#{closure_select}) removed_#{table_name}
32
+ ON #{table_name}.#{from_column} = removed_#{table_name}.#{from_column}
33
+ AND #{table_name}.#{to_column} = removed_#{table_name}.#{to_column}
34
+ AND #{types_equality_condition}
35
+ SET
36
+ #{table_name}.#{count_column} = #{table_name}.#{count_column} - removed_#{table_name}.#{count_column}
36
37
  SQL
37
38
  end
38
39
 
39
40
  def sql_postgresql
40
41
  <<-SQL
41
- DELETE FROM
42
- #{table_name} deletion_table
43
- USING
44
- #{selection_table} selection_table
45
- WHERE deletion_table.id = selection_table.id
42
+ UPDATE #{table_name}
43
+ SET
44
+ #{count_column} = #{table_name}.#{count_column} - removed_#{table_name}.#{count_column}
45
+ FROM
46
+ (#{closure_select}) removed_#{table_name}
47
+ WHERE #{table_name}.#{from_column} = removed_#{table_name}.#{from_column}
48
+ AND #{table_name}.#{to_column} = removed_#{table_name}.#{to_column}
49
+ AND #{types_equality_condition}
46
50
  SQL
47
51
  end
48
52
 
49
53
  def selection_table
50
54
  <<-SQL
51
- (SELECT id
52
- FROM (
53
- SELECT COUNT(*) count, #{from_column}, #{to_column}, #{type_select_list}
54
- FROM
55
- (#{closure_select}) aggregation
56
- GROUP BY #{from_column}, #{to_column}, #{type_select_list}) criteria
57
-
58
- JOIN
59
- (#{rank_similar_relations}) ranked
60
- ON
61
- #{ranked_critieria_join_condition})
55
+ (
56
+ SELECT COUNT(*) #{count_column}, #{from_column}, #{to_column}, #{type_select_list}
57
+ FROM
58
+ (#{closure_select}) aggregation
59
+ GROUP BY #{from_column}, #{to_column}, #{type_select_list})
62
60
  SQL
63
61
  end
64
62
 
@@ -66,113 +64,10 @@ module TypedDag::Sql::TruncateClosure
66
64
  TypedDag::Sql::SelectClosure.sql(relation)
67
65
  end
68
66
 
69
- def rank_similar_relations
70
- if mysql_db?
71
- rank_similar_relations_mysql
72
- else
73
- rank_similar_relations_postgresql
74
- end
75
- end
76
-
77
- def ranked_critieria_join_condition
78
- <<-SQL
79
- ranked.#{from_column} = criteria.#{from_column}
80
- AND ranked.#{to_column} = criteria.#{to_column}
81
- AND #{types_equality_condition}
82
- AND count >= row_number
83
- SQL
84
- end
85
-
86
- def type_column_values_pairs
87
- type_columns.map do |column|
88
- [column, relation.send(column)]
89
- end
90
- end
91
-
92
67
  def types_equality_condition
93
68
  type_columns.map do |column|
94
- "ranked.#{column} = criteria.#{column}"
69
+ "#{table_name}.#{column} = removed_#{table_name}.#{column}"
95
70
  end.join(' AND ')
96
71
  end
97
-
98
- def mysql_db?
99
- ActiveRecord::Base.connection.adapter_name == 'Mysql2'
100
- end
101
-
102
- def rank_similar_relations_mysql
103
- <<-SQL
104
- SELECT
105
- id,
106
- #{from_column},
107
- #{to_column},
108
- #{type_select_list},
109
- greatest(@cur_count := IF(#{compare_mysql_variables},
110
- @cur_count + 1, 1),
111
- least(0, #{assign_mysql_variables})) AS row_number
112
- FROM
113
- #{table_name}
114
-
115
- CROSS JOIN (SELECT #{initialize_mysql_variables}) params_initialization
116
-
117
- WHERE
118
- #{only_relations_in_closure_condition}
119
-
120
- ORDER BY #{from_column}, #{to_column}, #{type_select_list}
121
- SQL
122
- end
123
-
124
- def rank_similar_relations_postgresql
125
- <<-SQL
126
- SELECT *, ROW_NUMBER() OVER(
127
- PARTITION BY #{from_column}, #{to_column}, #{type_select_list}
128
- )
129
- FROM
130
- #{table_name}
131
- WHERE
132
- #{only_relations_in_closure_condition}
133
- SQL
134
- end
135
-
136
- def only_relations_in_closure_condition
137
- <<-SQL
138
- #{from_column} IN (SELECT #{from_column} FROM #{table_name} WHERE #{to_column} = #{from_id_value}) OR #{from_column} = #{from_id_value}
139
- AND
140
- #{to_column} IN (SELECT #{to_column} FROM #{table_name} WHERE #{from_column} = #{from_id_value})
141
- SQL
142
- end
143
-
144
- def initialize_mysql_variables
145
- variable_string = "@cur_count := NULL,
146
- @cur_#{from_column} := NULL,
147
- @cur_#{to_column} := NULL"
148
-
149
- type_columns.each do |column|
150
- variable_string += ", @cur_#{column} := NULL"
151
- end
152
-
153
- variable_string
154
- end
155
-
156
- def assign_mysql_variables
157
- variable_string = "@cur_#{from_column} := #{from_column},
158
- @cur_#{to_column} := #{to_column}"
159
-
160
- type_columns.each do |column|
161
- variable_string += ", @cur_#{column} := #{column}"
162
- end
163
-
164
- variable_string
165
- end
166
-
167
- def compare_mysql_variables
168
- variable_string = "@cur_#{from_column} = #{from_column} AND
169
- @cur_#{to_column} = #{to_column}"
170
-
171
- type_columns.each do |column|
172
- variable_string += " AND @cur_#{column} = #{column}"
173
- end
174
-
175
- variable_string
176
- end
177
72
  end
178
73
  end