dm-constraints 0.9.11 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,8 @@
1
- === 0.9.11 / 2009-03-29
1
+ === 0.10.0 / 2009-10-15
2
+
3
+ * Updated to work with dm-core 0.10.0
2
4
 
3
- * 5 major enhancements:
5
+ === 0.9.11 / 2009-03-29
4
6
 
5
7
  * Added :destroy! constraints
6
8
  * Added support for 1:1 constraints
data/Manifest.txt CHANGED
@@ -1,14 +1,12 @@
1
- History.txt
1
+ History.rdoc
2
2
  LICENSE
3
3
  Manifest.txt
4
- README.txt
4
+ README.rdoc
5
5
  Rakefile
6
6
  TODO
7
7
  lib/dm-constraints.rb
8
- lib/dm-constraints/data_objects_adapter.rb
9
8
  lib/dm-constraints/delete_constraint.rb
10
- lib/dm-constraints/mysql_adapter.rb
11
- lib/dm-constraints/postgres_adapter.rb
9
+ lib/dm-constraints/migrations.rb
12
10
  lib/dm-constraints/version.rb
13
11
  spec/integration/constraints_spec.rb
14
12
  spec/spec.opts
File without changes
data/Rakefile CHANGED
@@ -1,5 +1,4 @@
1
1
  require 'pathname'
2
- require 'rubygems'
3
2
 
4
3
  ROOT = Pathname(__FILE__).dirname.expand_path
5
4
  JRUBY = RUBY_PLATFORM =~ /java/
@@ -14,10 +13,10 @@ GEM_NAME = 'dm-constraints'
14
13
  GEM_VERSION = DataMapper::Constraints::VERSION
15
14
  GEM_DEPENDENCIES = [['dm-core', GEM_VERSION]]
16
15
  GEM_CLEAN = %w[ log pkg coverage ]
17
- GEM_EXTRAS = { :has_rdoc => true, :extra_rdoc_files => %w[ README.txt LICENSE TODO History.txt ] }
16
+ GEM_EXTRAS = { :has_rdoc => true, :extra_rdoc_files => %w[ README.rdoc LICENSE TODO History.rdoc ] }
18
17
 
19
18
  PROJECT_NAME = 'datamapper'
20
- PROJECT_URL = "http://github.com/sam/dm-more/tree/master/#{GEM_NAME}"
19
+ PROJECT_URL = "http://github.com/datamapper/dm-more/tree/master/#{GEM_NAME}"
21
20
  PROJECT_DESCRIPTION = PROJECT_SUMMARY = 'DataMapper plugin constraining relationships'
22
21
 
23
22
  [ ROOT, ROOT.parent ].each do |dir|
@@ -1,13 +1,12 @@
1
1
  module DataMapper
2
2
  module Constraints
3
3
  module DeleteConstraint
4
-
5
4
  def self.included(base)
6
5
  base.extend(ClassMethods)
7
6
  end
8
7
 
9
8
  module ClassMethods
10
- DELETE_CONSTRAINT_OPTIONS = [:protect, :destroy, :destroy!, :set_nil, :skip]
9
+ CONSTRAINT_OPTIONS = [ :protect, :destroy, :destroy!, :set_nil, :skip ].to_set.freeze
11
10
 
12
11
  ##
13
12
  # Checks that the constraint type is appropriate to the relationship
@@ -23,26 +22,25 @@ module DataMapper
23
22
  # @return [nil]
24
23
  #
25
24
  # @api semi-public
26
- def check_delete_constraint_type(cardinality, name, options = {})
27
- #Make sure options contains :constraint key, whether nil or not
28
- options[:constraint] ||= nil
29
- constraint_type = options[:constraint]
30
- return if constraint_type.nil?
25
+ def check_delete_constraint_type(cardinality, name, *args)
26
+ options = extract_options(args)
31
27
 
32
- delete_constraint_options = DELETE_CONSTRAINT_OPTIONS.map { |o| ":#{o}" }
33
- if !DELETE_CONSTRAINT_OPTIONS.include?(constraint_type)
34
- raise ArgumentError, ":constraint option must be one of #{delete_constraint_options * ', '}"
35
- end
28
+ return unless options.key?(:constraint)
29
+
30
+ constraint = options[:constraint]
36
31
 
37
- if constraint_type == :set_nil && self.relationships[name].is_a?(DataMapper::Associations::RelationshipChain)
38
- raise ArgumentError, "Constraint type :set_nil is not valid for M:M relationships"
32
+ unless CONSTRAINT_OPTIONS.include?(constraint)
33
+ raise ArgumentError, ":constraint option must be one of #{CONSTRAINT_OPTIONS.to_a.join(', ')}"
39
34
  end
40
35
 
41
- if cardinality == 1 && constraint_type == :destroy!
42
- raise ArgumentError, "Constraint type :destroy! is not valid for 1:1 relationships"
36
+ # XXX: is any constraint valid with a :through relationship?
37
+ if constraint == :set_nil && options.key?(:through)
38
+ raise ArgumentError, 'Constraint type :set_nil is not valid for relationships using :through'
43
39
  end
44
40
  end
45
41
 
42
+ private
43
+
46
44
  ##
47
45
  # Temporarily changes the visibility of a method so a block can be evaluated against it
48
46
  #
@@ -62,38 +60,25 @@ module DataMapper
62
60
  def with_changed_method_visibility(method, from_visibility, to_visibility, &block)
63
61
  send(to_visibility, method)
64
62
  yield
63
+ ensure
65
64
  send(from_visibility, method)
66
65
  end
67
-
68
66
  end
69
67
 
70
68
  ##
71
- # Addes the delete constraint options to a relationship
69
+ # Adds the delete constraint options to a relationship
72
70
  #
73
- # @param params [*ARGS] Arguments passed to Relationship#initialize or RelationshipChain#initialize
74
- #
75
- # @notes This takes *params because it runs before the initializer for Relationships and RelationshipChains
76
- # which have different method signatures
71
+ # @param params [*ARGS] Arguments passed to Relationship#initialize
77
72
  #
78
73
  # @return [nil]
79
74
  #
80
75
  # @api semi-public
81
- def add_delete_constraint_option(*params)
82
- opts = params.last
83
-
84
- if opts.is_a?(Hash)
85
- #if it is a chain, set the constraint on the 1:M near relationship(anonymous)
86
- if self.is_a?(DataMapper::Associations::RelationshipChain)
87
- opts = params.last
88
- near_rel = opts[:parent_model].relationships[opts[:near_relationship_name]]
89
- near_rel.options[:constraint] = opts[:constraint]
90
- near_rel.instance_variable_set "@delete_constraint", opts[:constraint]
91
- end
92
-
93
- @delete_constraint = params.last[:constraint]
94
- end
76
+ def add_constraint_option(name, child_model, parent_model, options = {})
77
+ @constraint = options.fetch(:constraint, :protect) || :skip
95
78
  end
96
79
 
80
+ private
81
+
97
82
  ##
98
83
  # Checks delete constraints prior to destroying a dm resource or collection
99
84
  #
@@ -101,86 +86,32 @@ module DataMapper
101
86
  #
102
87
  # @notes
103
88
  # - It only considers a relationship's constraints if this is the parent model (ie a child shouldn't delete a parent)
104
- # - RelationshipChains are skipped, as they are evaluated by their underlying 1:M relationships
89
+ # - Many to Many Relationships are skipped, as they are evaluated by their underlying 1:M relationships
105
90
  #
106
91
  # @returns [nil]
107
92
  #
108
93
  # @api semi-public
109
94
  def check_delete_constraints
110
- model.relationships.each do |rel_name, rel|
111
- #Only look at relationships where this model is the parent
112
- next if rel.parent_model != model
113
-
114
- #Don't delete across M:M relationships, instead use their anonymous 1:M Relationships
115
- next if rel.is_a?(DataMapper::Associations::RelationshipChain)
116
-
117
- children = self.send(rel_name)
118
- if children.kind_of?(DataMapper::Collection)
119
- check_collection_delete_constraints(rel,children)
120
- elsif children
121
- check_resource_delete_constraints(rel,children)
95
+ relationships.each_value do |relationship|
96
+ next unless relationship.respond_to?(:constraint)
97
+ next unless association = relationship.get(self)
98
+
99
+ delete_allowed = case constraint = relationship.constraint
100
+ when :protect
101
+ Array(association).empty?
102
+ when :destroy, :destroy!
103
+ association.send(constraint)
104
+ when :set_nil
105
+ Array(association).all? do |resource|
106
+ resource.update(relationship.inverse => nil)
107
+ end
108
+ when :skip
109
+ true # do nothing
122
110
  end
123
- end # relationships
124
- end # check_delete_constraints
125
111
 
126
- ##
127
- # Performs the meat of the check_delete_constraints method for a collection of resources
128
- #
129
- # @param rel [DataMapper::Associations::Relationship] relationship being evaluated
130
- #
131
- # @param children [~DataMapper::Collection] child records to constrain
132
- #
133
- # @see #check_delete_constraints
134
- #
135
- # @api semi-public
136
- def check_collection_delete_constraints(rel, children)
137
- case rel.delete_constraint
138
- when nil, :protect
139
- unless children.empty?
140
- DataMapper.logger.error("Could not delete #{self.class} a child #{children.first.class} exists")
141
- throw(:halt,false)
142
- end
143
- when :destroy
144
- children.each{|child| child.destroy}
145
- when :destroy!
146
- children.destroy!
147
- when :set_nil
148
- children.each do |child|
149
- child.class.many_to_one_relationships.each do |mto_rel|
150
- child.send("#{mto_rel.name}=", nil) if child.send(mto_rel.name).eql?(self)
151
- end
152
- end
153
- end
154
- end
155
-
156
- ##
157
- # Performs the meat of check_delete_constraints method for a single resource
158
- #
159
- # @param rel [DataMapper::Associations::Relationship] the relationship to evaluate
160
- #
161
- # @param child [~DataMapper::Model] the model to constrain
162
- #
163
- # @see #check_delete_constraints
164
- #
165
- # @api semi-public
166
- def check_resource_delete_constraints(rel, child)
167
- case rel.delete_constraint
168
- when nil, :protect
169
- unless child.nil?
170
- DataMapper.logger.error("Could not delete #{self.class} a child #{child.class} exists")
171
- throw(:halt,false)
172
- end
173
- when :destroy
174
- child.destroy
175
- when :destroy!
176
- #not supported in dm-master, an exception should have been raised on class load
177
- when :set_nil
178
- child.class.many_to_one_relationships.each do |mto_rel|
179
- child.send("#{mto_rel.name}=", nil) if child.send(mto_rel.name).eql?(self)
180
- end
112
+ throw(:halt, false) unless delete_allowed
181
113
  end
182
114
  end
183
-
184
115
  end # DeleteConstraint
185
116
  end # Constraints
186
117
  end # DataMapper
@@ -0,0 +1,274 @@
1
+ module DataMapper
2
+ module Constraints
3
+ module Migrations
4
+ module SingletonMethods
5
+ def self.included(base)
6
+ # TODO: figure out how to make this work without AMC
7
+ base.class_eval <<-RUBY, __FILE__, __LINE__ + 1
8
+ alias_method :auto_migrate_down_without_constraints!, :auto_migrate_down!
9
+ alias_method :auto_migrate_down!, :auto_migrate_down_with_constraints!
10
+
11
+ alias_method :auto_migrate_up_without_constraints!, :auto_migrate_up!
12
+ alias_method :auto_migrate_up!, :auto_migrate_up_with_constraints!
13
+ RUBY
14
+ end
15
+
16
+ def auto_migrate_down_with_constraints!(repository_name = nil)
17
+ repository_execute(:auto_migrate_down_with_constraints!, repository_name)
18
+ auto_migrate_down_without_constraints!(repository_name)
19
+ end
20
+
21
+ def auto_migrate_up_with_constraints!(repository_name = nil)
22
+ auto_migrate_up_without_constraints!(repository_name)
23
+ repository_execute(:auto_migrate_up_with_constraints!, repository_name)
24
+ end
25
+ end
26
+
27
+ module DataObjectsAdapter
28
+ ##
29
+ # Determine if a constraint exists for a table
30
+ #
31
+ # @param storage_name [Symbol]
32
+ # name of table to check constraint on
33
+ # @param constraint_name [~String]
34
+ # name of constraint to check for
35
+ #
36
+ # @return [Boolean]
37
+ #
38
+ # @api private
39
+ def constraint_exists?(storage_name, constraint_name)
40
+ statement = <<-SQL.compress_lines
41
+ SELECT COUNT(*)
42
+ FROM "information_schema"."table_constraints"
43
+ WHERE "constraint_type" = 'FOREIGN KEY'
44
+ AND "table_schema" = ?
45
+ AND "table_name" = ?
46
+ AND "constraint_name" = ?
47
+ SQL
48
+
49
+ query(statement, schema_name, storage_name, constraint_name).first > 0
50
+ end
51
+
52
+ ##
53
+ # Create the constraint for a relationship
54
+ #
55
+ # @param relationship [Relationship]
56
+ # the relationship to create the constraint for
57
+ #
58
+ # @return [true, false]
59
+ # true if creating the constraints was successful
60
+ #
61
+ # @api semipublic
62
+ def create_relationship_constraint(relationship)
63
+ return false unless valid_relationship_for_constraint?(relationship)
64
+
65
+ source_model = relationship.source_model
66
+ source_table = source_model.storage_name(name)
67
+ source_key = relationship.source_key
68
+
69
+ constraint_name = constraint_name(source_table, relationship.name)
70
+ return false if constraint_exists?(source_table, constraint_name)
71
+
72
+ constraint_type = case relationship.inverse.constraint
73
+ when :protect then 'NO ACTION'
74
+ when :destroy, :destroy! then 'CASCADE'
75
+ when :set_nil then 'SET NULL'
76
+ end
77
+
78
+ return false if constraint_type.nil?
79
+
80
+ storage_name = relationship.source_model.storage_name(name)
81
+ reference_storage_name = relationship.target_model.storage_name(name)
82
+
83
+ foreign_keys = relationship.source_key.map { |p| property_to_column_name(p, false) }
84
+ reference_keys = relationship.target_key.map { |p| property_to_column_name(p, false) }
85
+
86
+ execute(create_constraints_statement(storage_name, constraint_name, constraint_type, foreign_keys, reference_storage_name, reference_keys))
87
+ end
88
+
89
+ ##
90
+ # Remove the constraint for a relationship
91
+ #
92
+ # @param relationship [Relationship]
93
+ # the relationship to remove the constraint for
94
+ #
95
+ # @return [true, false]
96
+ # true if destroying the constraint was successful
97
+ #
98
+ # @api semipublic
99
+ def destroy_relationship_constraint(relationship)
100
+ return false unless valid_relationship_for_constraint?(relationship)
101
+
102
+ source_model = relationship.source_model
103
+ source_table = source_model.storage_name(name)
104
+
105
+ constraint_name = constraint_name(source_table, relationship.name)
106
+ return false unless constraint_exists?(source_table, constraint_name)
107
+
108
+ execute(destroy_constraints_statement(source_table, constraint_name))
109
+ end
110
+
111
+ private
112
+
113
+ ##
114
+ # Check to see if the relationship's constraints can be used
115
+ #
116
+ # Only one-to-one, one-to-many, and many-to-many relationships
117
+ # can be used for constraints. They must also be in the same
118
+ # repository as the adapter is connected to.
119
+ #
120
+ # @param relationship [Relationship]
121
+ # the relationship to check
122
+ #
123
+ # @return [true, false]
124
+ # true if a constraint can be established for relationship
125
+ #
126
+ # @api private
127
+ def valid_relationship_for_constraint?(relationship)
128
+ return false unless relationship.source_repository_name == name || relationship.source_repository_name.nil?
129
+ return false unless relationship.target_repository_name == name || relationship.target_repository_name.nil?
130
+ return false unless relationship.kind_of?(Associations::ManyToOne::Relationship)
131
+ true
132
+ end
133
+
134
+ module SQL
135
+ private
136
+
137
+ ##
138
+ # Generates the SQL statement to create a constraint
139
+ #
140
+ # @param constraint_name [String]
141
+ # name of the foreign key constraint
142
+ # @param constraint_type [String]
143
+ # type of foreign key constraint to add to the table
144
+ # @param storage_name [String]
145
+ # name of table to constrain
146
+ # @param foreign_keys [Array[String]]
147
+ # columns in the table that refer to foreign table
148
+ # @param reference_storage_name [String]
149
+ # table the foreign key refers to
150
+ # @param reference_storage_name [Array[String]]
151
+ # columns the foreign table that are referred to
152
+ #
153
+ # @return [String]
154
+ # SQL DDL Statement to create a constraint
155
+ #
156
+ # @api private
157
+ def create_constraints_statement(storage_name, constraint_name, constraint_type, foreign_keys, reference_storage_name, reference_keys)
158
+ <<-SQL.compress_lines
159
+ ALTER TABLE #{quote_name(storage_name)}
160
+ ADD CONSTRAINT #{quote_name(constraint_name)}
161
+ FOREIGN KEY (#{foreign_keys.join(', ')})
162
+ REFERENCES #{quote_name(reference_storage_name)} (#{reference_keys.join(', ')})
163
+ ON DELETE #{constraint_type}
164
+ ON UPDATE #{constraint_type}
165
+ SQL
166
+ end
167
+
168
+ ##
169
+ # Generates the SQL statement to destroy a constraint
170
+ #
171
+ # @param storage_name [String]
172
+ # name of table to constrain
173
+ # @param constraint_name [String]
174
+ # name of foreign key constraint
175
+ #
176
+ # @return [String]
177
+ # SQL DDL Statement to destroy a constraint
178
+ #
179
+ # @api private
180
+ def destroy_constraints_statement(storage_name, constraint_name)
181
+ <<-SQL.compress_lines
182
+ ALTER TABLE #{quote_name(storage_name)}
183
+ DROP CONSTRAINT #{quote_name(constraint_name)}
184
+ SQL
185
+ end
186
+
187
+ ##
188
+ # generates a unique constraint name given a table and a relationships
189
+ #
190
+ # @param storage_name [String]
191
+ # name of table to constrain
192
+ # @param relationships_name [String]
193
+ # name of the relationship to constrain
194
+ #
195
+ # @return [String]
196
+ # name of the constraint
197
+ #
198
+ # @api private
199
+ def constraint_name(storage_name, relationship_name)
200
+ identifier = "#{storage_name}_#{relationship_name}"[0, self.class::IDENTIFIER_MAX_LENGTH - 3]
201
+ "#{identifier}_fk"
202
+ end
203
+ end
204
+
205
+ include SQL
206
+ end
207
+
208
+ module MysqlAdapter
209
+ module SQL
210
+ private
211
+
212
+ ##
213
+ # MySQL specific query to drop a foreign key
214
+ #
215
+ # @param storage_name [String]
216
+ # name of table to constrain
217
+ # @param constraint_name [String]
218
+ # name of foreign key constraint
219
+ #
220
+ # @return [String]
221
+ # SQL DDL Statement to destroy a constraint
222
+ #
223
+ # @api private
224
+ def destroy_constraints_statement(storage_name, constraint_name)
225
+ <<-SQL.compress_lines
226
+ ALTER TABLE #{quote_name(storage_name)}
227
+ DROP FOREIGN KEY #{quote_name(constraint_name)}
228
+ SQL
229
+ end
230
+ end
231
+
232
+ include SQL
233
+ end
234
+
235
+ module Sqlite3Adapter
236
+ def constraint_exists?(*)
237
+ false
238
+ end
239
+
240
+ def create_relationship_constraint(*)
241
+ false
242
+ end
243
+
244
+ def destroy_relationship_constraint(*)
245
+ false
246
+ end
247
+ end
248
+
249
+ module Model
250
+ def auto_migrate_down_with_constraints!(repository_name = self.repository_name)
251
+ return unless storage_exists?(repository_name)
252
+ return if self.respond_to?(:is_remixable?) && self.is_remixable?
253
+ execute_each_relationship(:destroy_relationship_constraint, repository_name)
254
+ end
255
+
256
+ def auto_migrate_up_with_constraints!(repository_name = self.repository_name)
257
+ return if self.respond_to?(:is_remixable?) && self.is_remixable?
258
+ execute_each_relationship(:create_relationship_constraint, repository_name)
259
+ end
260
+
261
+ private
262
+
263
+ def execute_each_relationship(method, repository_name)
264
+ adapter = DataMapper.repository(repository_name).adapter
265
+ return unless adapter.respond_to?(method)
266
+
267
+ relationships(repository_name).each_value do |relationship|
268
+ adapter.send(method, relationship)
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
@@ -1,5 +1,5 @@
1
1
  module DataMapper
2
2
  module Constraints
3
- VERSION = '0.9.11'
3
+ VERSION = '0.10.0'.freeze
4
4
  end
5
5
  end
@@ -1,58 +1,42 @@
1
- # Needed to import datamapper and other gems
2
- require 'rubygems'
3
- require 'pathname'
4
-
5
- # Add all external dependencies for the plugin here
6
- gem 'dm-core', '0.9.11'
7
- require 'dm-core'
8
-
9
- # Require plugin-files
10
- require Pathname(__FILE__).dirname.expand_path / 'dm-constraints' / 'data_objects_adapter'
11
- require Pathname(__FILE__).dirname.expand_path / 'dm-constraints' / 'postgres_adapter'
12
- require Pathname(__FILE__).dirname.expand_path / 'dm-constraints' / 'mysql_adapter'
13
- require Pathname(__FILE__).dirname.expand_path / 'dm-constraints' / 'delete_constraint'
1
+ require 'dm-constraints/delete_constraint'
2
+ require 'dm-constraints/migrations'
3
+ require 'dm-constraints/version'
14
4
 
15
5
  module DataMapper
16
6
  module Associations
17
- class RelationshipChain
7
+ class OneToMany::Relationship
18
8
  include Extlib::Hook
19
- include DataMapper::Constraints::DeleteConstraint
9
+ include Constraints::DeleteConstraint
20
10
 
21
- attr_reader :delete_constraint
22
11
  OPTIONS << :constraint
23
12
 
13
+ attr_reader :constraint
14
+
24
15
  # initialize is a private method in Relationship
25
16
  # and private methods can not be "advised" (hooked into)
26
17
  # in extlib.
27
18
  with_changed_method_visibility(:initialize, :private, :public) do
28
- before :initialize, :add_delete_constraint_option
19
+ before :initialize, :add_constraint_option
29
20
  end
30
21
  end
31
- end
32
- end
33
22
 
34
- module DataMapper
35
- module Associations
36
- class Relationship
37
- include Extlib::Hook
38
- include DataMapper::Constraints::DeleteConstraint
23
+ class ManyToMany::Relationship
39
24
 
40
- attr_reader :delete_constraint
41
25
  OPTIONS << :constraint
42
26
 
43
- # initialize is a private method in Relationship
44
- # and private methods can not be "advised" (hooked into)
45
- # in extlib.
46
- with_changed_method_visibility(:initialize, :private, :public) do
47
- before :initialize, :add_delete_constraint_option
27
+ private
28
+
29
+ # TODO: document
30
+ # @api semipublic
31
+ chainable do
32
+ def one_to_many_options
33
+ super.merge(:constraint => @constraint)
34
+ end
48
35
  end
49
36
  end
50
37
  end
51
- end
52
38
 
53
- module DataMapper
54
39
  module Constraints
55
-
56
40
  include DeleteConstraint
57
41
 
58
42
  module ClassMethods
@@ -65,28 +49,22 @@ module DataMapper
65
49
  #
66
50
  def self.included(model)
67
51
  model.extend(ClassMethods)
68
- model.class_eval do
52
+ model.class_eval <<-RUBY, __FILE__, __LINE__ + 1
69
53
  before_class_method :has, :check_delete_constraint_type
70
- if method_defined?(:destroy)
54
+
55
+ if instance_methods.any? { |m| m.to_sym == :destroy }
71
56
  before :destroy, :check_delete_constraints
72
57
  end
73
- end
58
+ RUBY
74
59
  end
75
-
76
- end
77
-
78
- class AutoMigrator
79
- include Extlib::Hook
80
- include DataMapper::Constraints::DataObjectsAdapter::Migration
81
60
  end
82
61
 
83
- module Adapters
84
- if defined?(MysqlAdapter)
85
- MysqlAdapter.send :include, DataMapper::Constraints::MysqlAdapter::SQL
86
- end
87
-
88
- if defined?(PostgresAdapter)
89
- PostgresAdapter.send :include, DataMapper::Constraints::PostgresAdapter::SQL
62
+ module Migrations
63
+ constants.each do |const_name|
64
+ if Constraints::Migrations.const_defined?(const_name)
65
+ mod = const_get(const_name)
66
+ mod.send(:include, Constraints::Migrations.const_get(const_name))
67
+ end
90
68
  end
91
69
  end
92
70
  end