strong_migrations 0.8.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,402 @@
1
+ # TODO better pattern
2
+ module StrongMigrations
3
+ module Checks
4
+ private
5
+
6
+ def check_add_check_constraint(args)
7
+ table, expression, options = args
8
+ options ||= {}
9
+
10
+ if !new_table?(table)
11
+ if postgresql? && options[:validate] != false
12
+ add_options = options.merge(validate: false)
13
+ name = options[:name] || @migration.check_constraint_options(table, expression, options)[:name]
14
+ validate_options = {name: name}
15
+
16
+ if StrongMigrations.safe_by_default
17
+ safe_add_check_constraint(table, expression, add_options, validate_options)
18
+ throw :safe
19
+ end
20
+
21
+ raise_error :add_check_constraint,
22
+ add_check_constraint_code: command_str("add_check_constraint", [table, expression, add_options]),
23
+ validate_check_constraint_code: command_str("validate_check_constraint", [table, validate_options])
24
+ elsif mysql? || mariadb?
25
+ raise_error :add_check_constraint_mysql
26
+ end
27
+ end
28
+ end
29
+
30
+ def check_add_column(args)
31
+ table, column, type, options = args
32
+ options ||= {}
33
+ default = options[:default]
34
+
35
+ if !default.nil? && !adapter.add_column_default_safe?
36
+ if options[:null] == false
37
+ options = options.except(:null)
38
+ append = "
39
+
40
+ Then add the NOT NULL constraint in separate migrations."
41
+ end
42
+
43
+ raise_error :add_column_default,
44
+ add_command: command_str("add_column", [table, column, type, options.except(:default)]),
45
+ change_command: command_str("change_column_default", [table, column, default]),
46
+ remove_command: command_str("remove_column", [table, column]),
47
+ code: backfill_code(table, column, default),
48
+ append: append,
49
+ rewrite_blocks: adapter.rewrite_blocks
50
+ elsif default.is_a?(Proc) && postgresql?
51
+ # adding a column with a VOLATILE default is not safe
52
+ # https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-NOTES
53
+ # functions like random() and clock_timestamp() are VOLATILE
54
+ # check for Proc to match Active Record
55
+ raise_error :add_column_default_callable
56
+ end
57
+
58
+ if type.to_s == "json" && postgresql?
59
+ raise_error :add_column_json,
60
+ command: command_str("add_column", [table, column, :jsonb, options])
61
+ end
62
+ end
63
+
64
+ # unlike add_index, we don't make an exception here for new tables
65
+ #
66
+ # with add_index, it's fine to lock a new table even after inserting data
67
+ # since the table won't be in use by the application
68
+ #
69
+ # with add_foreign_key, this would cause issues since it locks the referenced table
70
+ #
71
+ # it's okay to allow if the table is empty, but not a fan of data-dependent checks,
72
+ # since the data in production could be different from development
73
+ #
74
+ # note: adding foreign_keys with create_table is fine
75
+ # since the table is always guaranteed to be empty
76
+ def check_add_foreign_key(args)
77
+ from_table, to_table, options = args
78
+ options ||= {}
79
+
80
+ validate = options.fetch(:validate, true)
81
+ if postgresql? && validate
82
+ if StrongMigrations.safe_by_default
83
+ safe_add_foreign_key(from_table, to_table, options)
84
+ throw :safe
85
+ end
86
+
87
+ raise_error :add_foreign_key,
88
+ add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
89
+ validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
90
+ end
91
+ end
92
+
93
+ def check_add_index(args)
94
+ table, columns, options = args
95
+ options ||= {}
96
+
97
+ if columns.is_a?(Array) && columns.size > 3 && !options[:unique]
98
+ raise_error :add_index_columns, header: "Best practice"
99
+ end
100
+
101
+ # safe to add non-concurrently to new tables (even after inserting data)
102
+ # since the table won't be in use by the application
103
+ if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
104
+ if StrongMigrations.safe_by_default
105
+ safe_add_index(table, columns, options)
106
+ throw :safe
107
+ end
108
+
109
+ raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
110
+ end
111
+ end
112
+
113
+ def check_add_reference(method, args)
114
+ table, reference, options = args
115
+ options ||= {}
116
+
117
+ if postgresql?
118
+ index_value = options.fetch(:index, true)
119
+ concurrently_set = index_value.is_a?(Hash) && index_value[:algorithm] == :concurrently
120
+ bad_index = index_value && !concurrently_set
121
+
122
+ if bad_index || options[:foreign_key]
123
+ if index_value.is_a?(Hash)
124
+ options[:index] = options[:index].merge(algorithm: :concurrently)
125
+ else
126
+ options = options.merge(index: {algorithm: :concurrently})
127
+ end
128
+
129
+ if StrongMigrations.safe_by_default
130
+ safe_add_reference(table, reference, options)
131
+ throw :safe
132
+ end
133
+
134
+ if options.delete(:foreign_key)
135
+ headline = "Adding a foreign key blocks writes on both tables."
136
+ append = "
137
+
138
+ Then add the foreign key in separate migrations."
139
+ else
140
+ headline = "Adding an index non-concurrently locks the table."
141
+ end
142
+
143
+ raise_error :add_reference,
144
+ headline: headline,
145
+ command: command_str(method, [table, reference, options]),
146
+ append: append
147
+ end
148
+ end
149
+ end
150
+
151
+ def check_change_column(args)
152
+ table, column, type, options = args
153
+ options ||= {}
154
+
155
+ safe = false
156
+ existing_column = connection.columns(table).find { |c| c.name.to_s == column.to_s }
157
+ if existing_column
158
+ existing_type = existing_column.sql_type.sub(/\(\d+(,\d+)?\)/, "")
159
+ safe = adapter.change_type_safe?(table, column, type, options, existing_column, existing_type)
160
+ end
161
+
162
+ # unsafe to set NOT NULL for safe types with Postgres
163
+ # TODO check if safe for MySQL and MariaDB
164
+ if safe && existing_column.null && options[:null] == false
165
+ raise_error :change_column_with_not_null
166
+ end
167
+
168
+ raise_error :change_column, rewrite_blocks: adapter.rewrite_blocks unless safe
169
+ end
170
+
171
+ def check_change_column_null(args)
172
+ table, column, null, default = args
173
+ if !null
174
+ if postgresql?
175
+ safe = false
176
+ safe_with_check_constraint = adapter.server_version >= Gem::Version.new("12")
177
+ if safe_with_check_constraint
178
+ safe = adapter.constraints(table).any? { |c| c["def"] == "CHECK ((#{column} IS NOT NULL))" || c["def"] == "CHECK ((#{connection.quote_column_name(column)} IS NOT NULL))" }
179
+ end
180
+
181
+ unless safe
182
+ # match https://github.com/nullobject/rein
183
+ constraint_name = "#{table}_#{column}_null"
184
+
185
+ add_code = constraint_str("ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IS NOT NULL) NOT VALID", [table, constraint_name, column])
186
+ validate_code = constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [table, constraint_name])
187
+ remove_code = constraint_str("ALTER TABLE %s DROP CONSTRAINT %s", [table, constraint_name])
188
+
189
+ constraint_methods = ar_version >= 6.1
190
+
191
+ validate_constraint_code =
192
+ if constraint_methods
193
+ String.new(command_str(:validate_check_constraint, [table, {name: constraint_name}]))
194
+ else
195
+ String.new(safety_assured_str(validate_code))
196
+ end
197
+
198
+ if safe_with_check_constraint
199
+ change_args = [table, column, null]
200
+
201
+ validate_constraint_code << "\n #{command_str(:change_column_null, change_args)}"
202
+
203
+ if constraint_methods
204
+ validate_constraint_code << "\n #{command_str(:remove_check_constraint, [table, {name: constraint_name}])}"
205
+ else
206
+ validate_constraint_code << "\n #{safety_assured_str(remove_code)}"
207
+ end
208
+ end
209
+
210
+ if StrongMigrations.safe_by_default
211
+ safe_change_column_null(add_code, validate_code, change_args, remove_code, default)
212
+ throw :safe
213
+ end
214
+
215
+ add_constraint_code =
216
+ if constraint_methods
217
+ # only quote when needed
218
+ expr_column = column.to_s =~ /\A[a-z0-9_]+\z/ ? column : connection.quote_column_name(column)
219
+ command_str(:add_check_constraint, [table, "#{expr_column} IS NOT NULL", {name: constraint_name, validate: false}])
220
+ else
221
+ safety_assured_str(add_code)
222
+ end
223
+
224
+ raise_error :change_column_null_postgresql,
225
+ add_constraint_code: add_constraint_code,
226
+ validate_constraint_code: "def change\n #{validate_constraint_code}\n end"
227
+ end
228
+ elsif mysql? || mariadb?
229
+ unless adapter.strict_mode?
230
+ raise_error :change_column_null_mysql
231
+ end
232
+ end
233
+
234
+ if !default.nil?
235
+ raise_error :change_column_null,
236
+ code: backfill_code(table, column, default)
237
+ end
238
+ end
239
+ end
240
+
241
+ def check_change_table
242
+ raise_error :change_table, header: "Possibly dangerous operation"
243
+ end
244
+
245
+ def check_create_table(args)
246
+ table, options = args
247
+ options ||= {}
248
+
249
+ raise_error :create_table if options[:force]
250
+
251
+ # keep track of new tables of add_index check
252
+ @new_tables << table.to_s
253
+ end
254
+
255
+ def check_execute
256
+ raise_error :execute, header: "Possibly dangerous operation"
257
+ end
258
+
259
+ def check_remove_column(method, args)
260
+ columns =
261
+ case method
262
+ when :remove_timestamps
263
+ ["created_at", "updated_at"]
264
+ when :remove_column
265
+ [args[1].to_s]
266
+ when :remove_columns
267
+ # Active Record 6.1+ supports options
268
+ if args.last.is_a?(Hash)
269
+ args[1..-2].map(&:to_s)
270
+ else
271
+ args[1..-1].map(&:to_s)
272
+ end
273
+ else
274
+ options = args[2] || {}
275
+ reference = args[1]
276
+ cols = []
277
+ cols << "#{reference}_type" if options[:polymorphic]
278
+ cols << "#{reference}_id"
279
+ cols
280
+ end
281
+
282
+ code = "self.ignored_columns = #{columns.inspect}"
283
+
284
+ raise_error :remove_column,
285
+ model: args[0].to_s.classify,
286
+ code: code,
287
+ command: command_str(method, args),
288
+ column_suffix: columns.size > 1 ? "s" : ""
289
+ end
290
+
291
+ def check_remove_index(args)
292
+ table, options = args
293
+ unless options.is_a?(Hash)
294
+ options = {column: options}
295
+ end
296
+ options ||= {}
297
+
298
+ if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
299
+ if StrongMigrations.safe_by_default
300
+ safe_remove_index(table, options)
301
+ throw :safe
302
+ end
303
+
304
+ raise_error :remove_index, command: command_str("remove_index", [table, options.merge(algorithm: :concurrently)])
305
+ end
306
+ end
307
+
308
+ def check_rename_column
309
+ raise_error :rename_column
310
+ end
311
+
312
+ def check_rename_table
313
+ raise_error :rename_table
314
+ end
315
+
316
+ def check_validate_check_constraint
317
+ if postgresql? && adapter.writes_blocked?
318
+ raise_error :validate_check_constraint
319
+ end
320
+ end
321
+
322
+ def check_validate_foreign_key
323
+ if postgresql? && adapter.writes_blocked?
324
+ raise_error :validate_foreign_key
325
+ end
326
+ end
327
+
328
+ # helpers
329
+
330
+ def postgresql?
331
+ adapter.instance_of?(Adapters::PostgreSQLAdapter)
332
+ end
333
+
334
+ def mysql?
335
+ adapter.instance_of?(Adapters::MySQLAdapter)
336
+ end
337
+
338
+ def mariadb?
339
+ adapter.instance_of?(Adapters::MariaDBAdapter)
340
+ end
341
+
342
+ def ar_version
343
+ ActiveRecord::VERSION::STRING.to_f
344
+ end
345
+
346
+ def raise_error(message_key, header: nil, append: nil, **vars)
347
+ return unless StrongMigrations.check_enabled?(message_key, version: version)
348
+
349
+ message = StrongMigrations.error_messages[message_key] || "Missing message"
350
+ message = message + append if append
351
+
352
+ vars[:migration_name] = @migration.class.name
353
+ vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
354
+ vars[:base_model] = "ApplicationRecord"
355
+
356
+ # escape % not followed by {
357
+ message = message.gsub(/%(?!{)/, "%%") % vars if message.include?("%")
358
+ @migration.stop!(message, header: header || "Dangerous operation detected")
359
+ end
360
+
361
+ def constraint_str(statement, identifiers)
362
+ # not all identifiers are tables, but this method of quoting should be fine
363
+ statement % identifiers.map { |v| connection.quote_table_name(v) }
364
+ end
365
+
366
+ def safety_assured_str(code)
367
+ "safety_assured do\n execute '#{code}' \n end"
368
+ end
369
+
370
+ def command_str(command, args)
371
+ str_args = args[0..-2].map { |a| a.inspect }
372
+
373
+ # prettier last arg
374
+ last_arg = args[-1]
375
+ if last_arg.is_a?(Hash)
376
+ if last_arg.any?
377
+ str_args << last_arg.map do |k, v|
378
+ if v.is_a?(Hash)
379
+ # pretty index: {algorithm: :concurrently}
380
+ "#{k}: {#{v.map { |k2, v2| "#{k2}: #{v2.inspect}" }.join(", ")}}"
381
+ else
382
+ "#{k}: #{v.inspect}"
383
+ end
384
+ end.join(", ")
385
+ end
386
+ else
387
+ str_args << last_arg.inspect
388
+ end
389
+
390
+ "#{command} #{str_args.join(", ")}"
391
+ end
392
+
393
+ def backfill_code(table, column, default)
394
+ model = table.to_s.classify
395
+ "#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
396
+ end
397
+
398
+ def new_table?(table)
399
+ @new_tables.include?(table.to_s)
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,222 @@
1
+ module StrongMigrations
2
+ self.error_messages = {
3
+ add_column_default:
4
+ "Adding a column with a non-null default blocks %{rewrite_blocks} while the entire table is rewritten.
5
+ Instead, add the column without a default value, then change the default.
6
+
7
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
8
+ def up
9
+ %{add_command}
10
+ %{change_command}
11
+ end
12
+
13
+ def down
14
+ %{remove_command}
15
+ end
16
+ end
17
+
18
+ Then backfill the existing rows in the Rails console or a separate migration with disable_ddl_transaction!.
19
+
20
+ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
21
+ disable_ddl_transaction!
22
+
23
+ def up
24
+ %{code}
25
+ end
26
+ end",
27
+
28
+ add_column_default_callable:
29
+ "Strong Migrations does not support inspecting callable default values.
30
+ Please make really sure you're not calling a VOLATILE function,
31
+ then wrap it in a safety_assured { ... } block.",
32
+
33
+ add_column_json:
34
+ "There's no equality operator for the json column type, which can cause errors for
35
+ existing SELECT DISTINCT queries in your application. Use jsonb instead.
36
+
37
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
38
+ def change
39
+ %{command}
40
+ end
41
+ end",
42
+
43
+ change_column:
44
+ "Changing the type of an existing column blocks %{rewrite_blocks}
45
+ while the entire table is rewritten. A safer approach is to:
46
+
47
+ 1. Create a new column
48
+ 2. Write to both columns
49
+ 3. Backfill data from the old column to the new column
50
+ 4. Move reads from the old column to the new column
51
+ 5. Stop writing to the old column
52
+ 6. Drop the old column",
53
+
54
+ change_column_with_not_null:
55
+ "Changing the type is safe, but setting NOT NULL is not.",
56
+
57
+ remove_column: "Active Record caches attributes, which causes problems
58
+ when removing columns. Be sure to ignore the column%{column_suffix}:
59
+
60
+ class %{model} < %{base_model}
61
+ %{code}
62
+ end
63
+
64
+ Deploy the code, then wrap this step in a safety_assured { ... } block.
65
+
66
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
67
+ def change
68
+ safety_assured { %{command} }
69
+ end
70
+ end",
71
+
72
+ rename_column:
73
+ "Renaming a column that's in use will cause errors
74
+ in your application. A safer approach is to:
75
+
76
+ 1. Create a new column
77
+ 2. Write to both columns
78
+ 3. Backfill data from the old column to new column
79
+ 4. Move reads from the old column to the new column
80
+ 5. Stop writing to the old column
81
+ 6. Drop the old column",
82
+
83
+ rename_table:
84
+ "Renaming a table that's in use will cause errors
85
+ in your application. A safer approach is to:
86
+
87
+ 1. Create a new table. Don't forget to recreate indexes from the old table
88
+ 2. Write to both tables
89
+ 3. Backfill data from the old table to new table
90
+ 4. Move reads from the old table to the new table
91
+ 5. Stop writing to the old table
92
+ 6. Drop the old table",
93
+
94
+ add_reference:
95
+ "%{headline} Instead, use:
96
+
97
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
98
+ disable_ddl_transaction!
99
+
100
+ def change
101
+ %{command}
102
+ end
103
+ end",
104
+
105
+ add_index:
106
+ "Adding an index non-concurrently blocks writes. Instead, use:
107
+
108
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
109
+ disable_ddl_transaction!
110
+
111
+ def change
112
+ %{command}
113
+ end
114
+ end",
115
+
116
+ remove_index:
117
+ "Removing an index non-concurrently blocks writes. Instead, use:
118
+
119
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
120
+ disable_ddl_transaction!
121
+
122
+ def change
123
+ %{command}
124
+ end
125
+ end",
126
+
127
+ add_index_columns:
128
+ "Adding a non-unique index with more than three columns rarely improves performance.
129
+ Instead, start an index with columns that narrow down the results the most.",
130
+
131
+ change_table:
132
+ "Strong Migrations does not support inspecting what happens inside a
133
+ change_table block, so cannot help you here. Please make really sure that what
134
+ you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
135
+
136
+ create_table:
137
+ "The force option will destroy existing tables.
138
+ If this is intended, drop the existing table first.
139
+ Otherwise, remove the force option.",
140
+
141
+ execute:
142
+ "Strong Migrations does not support inspecting what happens inside an
143
+ execute call, so cannot help you here. Please make really sure that what
144
+ you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
145
+
146
+ change_column_null:
147
+ "Passing a default value to change_column_null runs a single UPDATE query,
148
+ which can cause downtime. Instead, backfill the existing rows in the
149
+ Rails console or a separate migration with disable_ddl_transaction!.
150
+
151
+ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
152
+ disable_ddl_transaction!
153
+
154
+ def up
155
+ %{code}
156
+ end
157
+ end",
158
+
159
+ change_column_null_postgresql:
160
+ "Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
161
+ Instead, add a check constraint and validate it in a separate migration.
162
+
163
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
164
+ def change
165
+ %{add_constraint_code}
166
+ end
167
+ end
168
+
169
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
170
+ %{validate_constraint_code}
171
+ end",
172
+
173
+ change_column_null_mysql:
174
+ "Setting NOT NULL on an existing column is not safe without strict mode enabled.",
175
+
176
+ add_foreign_key:
177
+ "Adding a foreign key blocks writes on both tables. Instead,
178
+ add the foreign key without validating existing rows,
179
+ then validate them in a separate migration.
180
+
181
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
182
+ def change
183
+ %{add_foreign_key_code}
184
+ end
185
+ end
186
+
187
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
188
+ def change
189
+ %{validate_foreign_key_code}
190
+ end
191
+ end",
192
+
193
+ validate_foreign_key:
194
+ "Validating a foreign key while writes are blocked is dangerous.
195
+ Use disable_ddl_transaction! or a separate migration.",
196
+
197
+ add_check_constraint:
198
+ "Adding a check constraint key blocks reads and writes while every row is checked.
199
+ Instead, add the check constraint without validating existing rows,
200
+ then validate them in a separate migration.
201
+
202
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
203
+ def change
204
+ %{add_check_constraint_code}
205
+ end
206
+ end
207
+
208
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
209
+ def change
210
+ %{validate_check_constraint_code}
211
+ end
212
+ end",
213
+
214
+ add_check_constraint_mysql:
215
+ "Adding a check constraint to an existing table is not safe with your database engine.",
216
+
217
+ validate_check_constraint:
218
+ "Validating a check constraint while writes are blocked is dangerous.
219
+ Use disable_ddl_transaction! or a separate migration."
220
+ }
221
+ self.enabled_checks = (error_messages.keys - [:remove_index]).map { |k| [k, {}] }.to_h
222
+ end
@@ -12,8 +12,10 @@ module StrongMigrations
12
12
  # Active Record 7.0.2+ versioned schema
13
13
  return super if defined?(ActiveRecord::Schema::Definition) && is_a?(ActiveRecord::Schema::Definition)
14
14
 
15
- strong_migrations_checker.perform(method, *args) do
16
- super
15
+ catch(:safe) do
16
+ strong_migrations_checker.perform(method, *args) do
17
+ super
18
+ end
17
19
  end
18
20
  end
19
21
  ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
@@ -0,0 +1,19 @@
1
+ module StrongMigrations
2
+ module Migrator
3
+ def ddl_transaction(migration, *args)
4
+ return super unless StrongMigrations.lock_timeout_retries > 0 && use_transaction?(migration)
5
+
6
+ # handle MigrationProxy class
7
+ migration = migration.send(:migration) if migration.respond_to?(:migration, true)
8
+
9
+ # retry migration since the entire transaction needs to be rerun
10
+ checker = migration.send(:strong_migrations_checker)
11
+ checker.retry_lock_timeouts(check_committed: true) do
12
+ # failed transaction reverts timeout, so need to re-apply
13
+ checker.timeouts_set = false
14
+
15
+ super(migration, *args)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -69,9 +69,13 @@ module StrongMigrations
69
69
  end
70
70
  end
71
71
 
72
- def safe_change_column_null(add_code, validate_code, change_args, remove_code)
72
+ def safe_change_column_null(add_code, validate_code, change_args, remove_code, default)
73
73
  @migration.reversible do |dir|
74
74
  dir.up do
75
+ unless default.nil?
76
+ raise Error, "default value not supported yet with safe_by_default"
77
+ end
78
+
75
79
  @migration.safety_assured do
76
80
  @migration.execute(add_code)
77
81
  disable_transaction
@@ -1,3 +1,3 @@
1
1
  module StrongMigrations
2
- VERSION = "0.8.0"
2
+ VERSION = "1.0.0"
3
3
  end