cold_storage 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.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Global defaults. Every model can override most of these through its own
5
+ # `archivable` options.
6
+ class Configuration
7
+ TYPE_MISMATCH_STRATEGIES = %i[warn raise change ignore].freeze
8
+
9
+ # Master switch. When false, nothing is copied or deleted: scheduled runs
10
+ # report themselves as skipped and the on_destroy hook stands down. Handy
11
+ # for the test environment, which has no archive database.
12
+ # Schema tasks keep working, so `schema:check` can still run in CI.
13
+ attr_accessor :enabled
14
+ # Name of the database.yml entry holding the archive database.
15
+ attr_accessor :archive_database
16
+ # Rows copied (and deleted) per round trip.
17
+ attr_accessor :batch_size
18
+ # Default column used to decide how old a record is.
19
+ attr_accessor :timestamp_column
20
+ # Default column used by `deleted: true`.
21
+ attr_accessor :deleted_column
22
+ # Extra column added to every archive table, holding the archiving time.
23
+ # Set to nil to disable.
24
+ attr_accessor :archived_at_column
25
+ # Delete rows from the primary database once they are safely copied.
26
+ attr_accessor :delete_after_archive
27
+ # :delete_all (fast, no callbacks) or :destroy_all (slow, runs callbacks).
28
+ attr_accessor :delete_method
29
+ # Copy indexes to the archive tables.
30
+ attr_accessor :mirror_indexes
31
+ # Copy NOT NULL constraints. Off by default: an archive table should never
32
+ # reject historical rows because the primary schema grew stricter later.
33
+ attr_accessor :mirror_null_constraints
34
+ # Copy column defaults. Off by default, rows are always inserted complete.
35
+ attr_accessor :mirror_defaults
36
+ # Drop archive columns that no longer exist in the primary database. Off by
37
+ # default so that already archived data keeps its columns.
38
+ attr_accessor :drop_removed_columns
39
+ # What to do when a column type differs: :warn, :raise, :change or :ignore.
40
+ attr_reader :on_type_mismatch
41
+ # What to do when `on_destroy:` cannot reach the archive database:
42
+ # :raise (block the delete, keep the data) or :log (let the delete through).
43
+ attr_reader :on_destroy_error
44
+ # Run the schema mirror automatically after db:migrate / db:schema:load.
45
+ attr_accessor :sync_schema_after_migrate
46
+ # Seconds to sleep between batches, to keep long runs off the hot path.
47
+ attr_accessor :throttle
48
+ # ActiveJob queue used by the bundled jobs.
49
+ attr_accessor :job_queue
50
+ # Parent class of the bundled jobs, as a string.
51
+ attr_accessor :job_parent_class
52
+ # Log every statement/plan the gem produces without touching any data.
53
+ attr_accessor :dry_run
54
+ attr_accessor :logger
55
+
56
+ def initialize
57
+ @enabled = true
58
+ @archive_database = :archive
59
+ @batch_size = 1_000
60
+ @timestamp_column = :created_at
61
+ @deleted_column = :deleted_at
62
+ @archived_at_column = :archived_at
63
+ @delete_after_archive = true
64
+ @delete_method = :delete_all
65
+ @mirror_indexes = true
66
+ @mirror_null_constraints = false
67
+ @mirror_defaults = false
68
+ @drop_removed_columns = false
69
+ @on_type_mismatch = :warn
70
+ @on_destroy_error = :raise
71
+ @sync_schema_after_migrate = true
72
+ @throttle = 0
73
+ @job_queue = :default
74
+ @job_parent_class = 'ActiveJob::Base'
75
+ @dry_run = false
76
+ @logger = nil
77
+ end
78
+
79
+ def on_type_mismatch=(strategy)
80
+ strategy = strategy.to_sym
81
+ unless TYPE_MISMATCH_STRATEGIES.include?(strategy)
82
+ raise ConfigurationError,
83
+ "on_type_mismatch must be one of #{TYPE_MISMATCH_STRATEGIES.join(', ')}, got #{strategy.inspect}"
84
+ end
85
+
86
+ @on_type_mismatch = strategy
87
+ end
88
+
89
+ def on_destroy_error=(strategy)
90
+ strategy = strategy.to_sym
91
+ raise ConfigurationError, 'on_destroy_error must be :raise or :log' unless %i[raise log].include?(strategy)
92
+
93
+ @on_destroy_error = strategy
94
+ end
95
+
96
+ def delete_method=(method)
97
+ method = method.to_sym
98
+ unless %i[delete_all destroy_all].include?(method)
99
+ raise ConfigurationError, "delete_method must be :delete_all or :destroy_all, got #{method.inspect}"
100
+ end
101
+
102
+ @delete_method = method
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # The gem's own tables inside the archive database. They are created on
5
+ # demand: the archive database needs no migrations of its own.
6
+ module InternalSchema
7
+ RUNS_TABLE = 'cold_storage_runs'
8
+ METADATA_TABLE = 'cold_storage_metadata'
9
+
10
+ class << self
11
+ def ensure!
12
+ ArchiveRecord.with_archive_connection do |connection|
13
+ create_runs_table(connection)
14
+ create_metadata_table(connection)
15
+ end
16
+ true
17
+ end
18
+
19
+ def ready?
20
+ ArchiveRecord.with_archive_connection do |connection|
21
+ connection.table_exists?(RUNS_TABLE) && connection.table_exists?(METADATA_TABLE)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def create_runs_table(connection)
28
+ return if connection.table_exists?(RUNS_TABLE)
29
+
30
+ connection.create_table(RUNS_TABLE) do |t|
31
+ t.string :archived_model, null: false
32
+ t.string :archived_table
33
+ t.string :status, null: false, default: 'running'
34
+ t.integer :archived_count, null: false, default: 0
35
+ t.integer :deleted_count, null: false, default: 0
36
+ t.datetime :started_at
37
+ t.datetime :finished_at
38
+ t.text :error_message
39
+ t.timestamps
40
+ end
41
+ connection.add_index(RUNS_TABLE, %i[archived_model status finished_at],
42
+ name: 'index_cold_storage_runs_on_model_and_status')
43
+ end
44
+
45
+ def create_metadata_table(connection)
46
+ return if connection.table_exists?(METADATA_TABLE)
47
+
48
+ connection.create_table(METADATA_TABLE, id: false) do |t|
49
+ t.string :key, null: false, primary_key: true
50
+ t.text :value
51
+ t.datetime :updated_at
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # ActiveJob entry points.
5
+ #
6
+ # The classes are built on first use (or when ActiveJob loads) so that the
7
+ # gem does not force ActiveJob on applications that do not use it, and so
8
+ # that `config.job_parent_class` is honoured.
9
+ #
10
+ # ColdStorage::ArchiveModelJob.perform_later('Payroll')
11
+ # ColdStorage::ArchiveAllJob.perform_later
12
+ module Jobs
13
+ JOBS = %i[ArchiveModelJob ArchiveAllJob].freeze
14
+
15
+ class << self
16
+ # @return [Boolean] whether the job classes exist now
17
+ def define!
18
+ return false unless defined?(ActiveJob::Base)
19
+ return true if ColdStorage.const_defined?(:ArchiveModelJob, false)
20
+
21
+ parent = ColdStorage.config.job_parent_class.constantize
22
+ ColdStorage.const_set(:ArchiveModelJob, build_model_job(parent))
23
+ ColdStorage.const_set(:ArchiveAllJob, build_all_job(parent))
24
+ true
25
+ end
26
+
27
+ private
28
+
29
+ # Archives one model.
30
+ def build_model_job(parent)
31
+ Class.new(parent) do
32
+ queue_as { ColdStorage.config.job_queue }
33
+
34
+ def perform(model_name, **options)
35
+ ColdStorage.archive(model_name.to_s.constantize, **options.symbolize_keys)
36
+ end
37
+ end
38
+ end
39
+
40
+ # Fans out over every archivable model. Models whose `every:` window has
41
+ # not elapsed skip themselves, so this is safe to schedule daily.
42
+ def build_all_job(parent)
43
+ Class.new(parent) do
44
+ queue_as { ColdStorage.config.job_queue }
45
+
46
+ def perform(**options)
47
+ ColdStorage.models.each do |model|
48
+ ColdStorage::ArchiveModelJob.perform_later(model.name, **options.symbolize_keys)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ def self.const_missing(name)
57
+ return super unless Jobs::JOBS.include?(name) && Jobs.define!
58
+
59
+ const_get(name)
60
+ end
61
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Shared, prefixed logging.
5
+ module Logging
6
+ PREFIX = '[ColdStorage]'
7
+
8
+ private
9
+
10
+ def log(message, level: :info)
11
+ ColdStorage.logger.public_send(level) { "#{PREFIX} #{message}" }
12
+ end
13
+
14
+ def warn_log(message)
15
+ log(message, level: :warn)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Key/value store in the archive database. Holds the migration version the
5
+ # mirrored schema was last synced against.
6
+ class Metadata < ArchiveRecord
7
+ self.table_name = InternalSchema::METADATA_TABLE
8
+ self.primary_key = 'key'
9
+
10
+ SCHEMA_VERSION_KEY = 'schema_version'
11
+
12
+ class << self
13
+ def get(key)
14
+ InternalSchema.ensure!
15
+ where(key: key.to_s).pick(:value)
16
+ end
17
+
18
+ def set(key, value)
19
+ InternalSchema.ensure!
20
+ upsert({ key: key.to_s, value: value.to_s, updated_at: Time.current }, unique_by: :key)
21
+ value
22
+ end
23
+
24
+ def schema_version
25
+ get(SCHEMA_VERSION_KEY)
26
+ end
27
+
28
+ def schema_version=(version)
29
+ set(SCHEMA_VERSION_KEY, version)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Extended onto ActiveRecord::Base, so every model can declare itself
5
+ # archivable.
6
+ module Model
7
+ # Declares which rows of this model move to the archive database.
8
+ #
9
+ # class Payroll < ApplicationRecord
10
+ # archivable after: 18.months, every: 1.month
11
+ # end
12
+ #
13
+ # Options:
14
+ # after: Duration. Rows older than this are archivable.
15
+ # (alias: older_than:)
16
+ # on: Column the age is measured on.
17
+ # Default: :created_at, or the deleted column when
18
+ # deleted: true.
19
+ # deleted: true to archive soft-deleted rows only.
20
+ # deleted_column: Column holding the soft-delete timestamp.
21
+ # Default: ColdStorage.config.deleted_column.
22
+ # on_destroy: true to copy a row to the archive whenever it is
23
+ # really deleted (destroy/destroy_all), so hard
24
+ # deletes are kept too. (alias: hard_delete:)
25
+ # every: Duration. Minimum time between two runs.
26
+ # scope: Symbol, proc or relation narrowing the selection.
27
+ # cascade: has_many/has_one association names to archive
28
+ # together with their parent.
29
+ # batch_size: Rows moved per round trip.
30
+ # delete_after_archive: false to copy without deleting the source rows.
31
+ # delete_method: :delete_all (default) or :destroy_all.
32
+ #
33
+ # @return [ColdStorage::Policy]
34
+ def archivable(**options)
35
+ include ColdStorage::Archivable unless include?(ColdStorage::Archivable)
36
+
37
+ self.archiving_policy = Policy.new(self, **options)
38
+ install_archive_on_destroy! if archiving_policy.on_destroy?
39
+ Registry.register(self)
40
+ archiving_policy
41
+ end
42
+
43
+ # Every model answers this, so callers never have to rescue NoMethodError.
44
+ def archivable?
45
+ false
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # The `archivable` options of a single model, and the relation they describe.
5
+ #
6
+ # archivable after: 18.months # older than 18 months
7
+ # archivable after: 30.days, on: :closed_at # ... measured on another column
8
+ # archivable deleted: true # soft-deleted rows only
9
+ # archivable deleted: true, after: 90.days # soft-deleted 90+ days ago
10
+ # archivable after: 1.year, every: 1.month # ... and only run monthly
11
+ # archivable after: 2.years, cascade: [:items] # take the children along
12
+ # archivable after: 2.years, cascade: { items: [:taxes] } # ... and theirs
13
+ # archivable on_destroy: true # catch hard deletes
14
+ class Policy
15
+ OPTIONS = %i[
16
+ after older_than on deleted deleted_column every scope
17
+ batch_size delete_after_archive delete_method cascade
18
+ on_destroy hard_delete
19
+ ].freeze
20
+
21
+ attr_reader :model_name, :after, :on, :every, :scope, :batch_size,
22
+ :delete_method, :cascade, :deleted_column
23
+
24
+ def initialize(model, **options)
25
+ unknown = options.keys - OPTIONS
26
+ unless unknown.empty?
27
+ raise InvalidPolicyError,
28
+ "unknown archivable option(s) #{unknown.map(&:inspect).join(', ')} for #{model}. " \
29
+ "Known options: #{OPTIONS.map(&:inspect).join(', ')}"
30
+ end
31
+
32
+ @model_name = model.name
33
+ @after = normalize_duration(options[:after] || options[:older_than])
34
+ @deleted = options.fetch(:deleted, false)
35
+ @deleted_column = (options[:deleted_column] || ColdStorage.config.deleted_column).to_sym
36
+ @on = (options[:on] || default_column).to_sym
37
+ @every = normalize_duration(options[:every])
38
+ @scope = options[:scope]
39
+ @batch_size = options[:batch_size]
40
+ @delete_after_archive = options[:delete_after_archive]
41
+ @delete_method = options[:delete_method]
42
+ @cascade = AssociationTree.normalize(options[:cascade])
43
+ @on_destroy = options.fetch(:on_destroy) { options.fetch(:hard_delete, false) }
44
+
45
+ validate!
46
+ end
47
+
48
+ def deleted?
49
+ @deleted
50
+ end
51
+
52
+ # Copy the row to the archive when it is really destroyed.
53
+ def on_destroy?
54
+ @on_destroy
55
+ end
56
+
57
+ # Does this policy describe rows a scheduled run should sweep up?
58
+ # `on_destroy: true` on its own does not: it only reacts to deletes.
59
+ def sweeps?
60
+ !after.nil? || deleted? || !scope.nil?
61
+ end
62
+
63
+ def model
64
+ @model_name.constantize
65
+ end
66
+
67
+ def batch_size_value
68
+ batch_size || ColdStorage.config.batch_size
69
+ end
70
+
71
+ def delete_after_archive?
72
+ @delete_after_archive.nil? ? ColdStorage.config.delete_after_archive : @delete_after_archive
73
+ end
74
+
75
+ def delete_method_value
76
+ delete_method || ColdStorage.config.delete_method
77
+ end
78
+
79
+ # The rows that are archivable right now.
80
+ #
81
+ # Always starts from `unscoped`: a soft-delete default scope would
82
+ # otherwise hide exactly the rows we are asked to archive.
83
+ #
84
+ # @return [ActiveRecord::Relation]
85
+ def relation(klass = model)
86
+ return klass.none unless sweeps?
87
+
88
+ relation = klass.unscoped
89
+ relation = relation.where.not(deleted_column => nil) if deleted?
90
+ relation = relation.where(klass.arel_table[on].lt(cutoff)) if after
91
+ apply_scope(relation)
92
+ end
93
+
94
+ # @return [Time, nil] rows on the `on:` column older than this are archivable
95
+ def cutoff(now = Time.current)
96
+ return nil unless after
97
+
98
+ now - after
99
+ end
100
+
101
+ # Has enough time passed since the last successful run?
102
+ def due?(last_run_at)
103
+ return true if every.nil? || last_run_at.nil?
104
+
105
+ last_run_at <= Time.current - every
106
+ end
107
+
108
+ # Checks the options against the real table. Raises with an actionable
109
+ # message instead of failing later with a SQL error.
110
+ def validate_against_schema!(klass = model)
111
+ columns = klass.column_names
112
+ check_column!(klass, on, columns)
113
+ check_column!(klass, deleted_column, columns) if deleted?
114
+
115
+ if klass.primary_key.nil? || klass.primary_key.is_a?(Array)
116
+ raise InvalidPolicyError,
117
+ "#{klass} must have a single-column primary key to be archivable (got #{klass.primary_key.inspect})"
118
+ end
119
+
120
+ validate_cascade!(klass, cascade)
121
+ true
122
+ end
123
+
124
+ def cascade_reflection!(klass, name)
125
+ self.class.cascade_reflection!(klass, name)
126
+ end
127
+
128
+ # @return [ActiveRecord::Reflection::AssociationReflection]
129
+ def self.cascade_reflection!(klass, name)
130
+ reflection = klass.reflect_on_association(name)
131
+ raise InvalidPolicyError, "#{klass} has no association #{name.inspect} to cascade to" if reflection.nil?
132
+
133
+ if reflection.through_reflection?
134
+ raise InvalidPolicyError, "cascade does not support :through associations (#{klass}##{name})"
135
+ end
136
+
137
+ unless reflection.collection? || reflection.has_one?
138
+ raise InvalidPolicyError,
139
+ "cascade only supports has_many / has_one associations (#{klass}##{name} is a #{reflection.macro})"
140
+ end
141
+
142
+ reflection
143
+ end
144
+
145
+ # @return [String] one-line summary used by the status report
146
+ def to_s
147
+ parts = []
148
+ parts << 'deleted' if deleted?
149
+ parts << "#{on} < #{humanize_duration(after)} ago" if after
150
+ parts << "scope: #{scope.is_a?(Symbol) ? scope : 'custom'}" if scope
151
+ parts << 'on destroy' if on_destroy?
152
+ parts << "every #{humanize_duration(every)}" if every && sweeps?
153
+ parts << "cascade: #{cascade.keys.join(', ')}" if cascade.any?
154
+ parts << 'keeps source rows' unless delete_after_archive?
155
+ parts.join(', ')
156
+ end
157
+
158
+ private
159
+
160
+ def default_column
161
+ @deleted ? @deleted_column : ColdStorage.config.timestamp_column
162
+ end
163
+
164
+ def apply_scope(relation)
165
+ case scope
166
+ when nil then relation
167
+ when Symbol then relation.public_send(scope)
168
+ when Proc then relation.instance_exec(&scope)
169
+ else relation.merge(scope)
170
+ end
171
+ end
172
+
173
+ def validate!
174
+ return if sweeps? || on_destroy?
175
+
176
+ raise InvalidPolicyError,
177
+ "#{model_name}: archivable needs at least one of after:, deleted:, scope: or on_destroy:. " \
178
+ 'Without a criterion every row of the table would be archived.'
179
+ end
180
+
181
+ # Walks the whole cascade tree, so a typo deep in it is reported before
182
+ # anything moves.
183
+ def validate_cascade!(klass, tree)
184
+ tree.each do |name, nested|
185
+ reflection = self.class.cascade_reflection!(klass, name)
186
+ validate_cascade!(reflection.klass, AssociationTree.normalize(nested))
187
+ end
188
+ end
189
+
190
+ def check_column!(klass, column, columns)
191
+ return if columns.include?(column.to_s)
192
+
193
+ raise InvalidPolicyError,
194
+ "#{klass} has no column #{column.inspect} (archivable on:/deleted_column:). " \
195
+ "Available: #{columns.sort.join(', ')}"
196
+ end
197
+
198
+ def normalize_duration(value)
199
+ case value
200
+ when nil then nil
201
+ when ActiveSupport::Duration then value
202
+ when Numeric then value.seconds
203
+ else
204
+ raise InvalidPolicyError, "expected a duration like 6.months, got #{value.inspect}"
205
+ end
206
+ end
207
+
208
+ def humanize_duration(duration)
209
+ duration.inspect
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Rails wiring: the `archivable` macro, the logger, the rake tasks, and the
5
+ # automatic schema sync after migrations.
6
+ class Railtie < ::Rails::Railtie
7
+ config.cold_storage = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer 'cold_storage.model' do
10
+ ActiveSupport.on_load(:active_record) do
11
+ extend ColdStorage::Model
12
+ end
13
+ end
14
+
15
+ initializer 'cold_storage.jobs' do
16
+ ActiveSupport.on_load(:active_job) do
17
+ ColdStorage::Jobs.define!
18
+ end
19
+ end
20
+
21
+ initializer 'cold_storage.logger' do |app|
22
+ app.config.after_initialize do
23
+ ColdStorage.config.logger ||= Rails.logger
24
+ app.config.cold_storage.each { |key, value| ColdStorage.config.public_send(:"#{key}=", value) }
25
+ end
26
+ end
27
+
28
+ rake_tasks do
29
+ load File.expand_path('tasks/cold_storage.rake', __dir__)
30
+ ColdStorage::Railtie.hook_into_migrations!
31
+ end
32
+
33
+ # After the primary database changes, bring the archive schema along - for
34
+ # the archivable models only.
35
+ def self.hook_into_migrations!
36
+ %w[db:migrate db:migrate:up db:migrate:down db:rollback db:schema:load].each do |name|
37
+ next unless Rake::Task.task_defined?(name)
38
+
39
+ Rake::Task[name].enhance do
40
+ next unless ColdStorage.config.sync_schema_after_migrate
41
+
42
+ sync = Rake::Task['cold_storage:schema:sync']
43
+ sync.reenable
44
+ sync.invoke
45
+ rescue ColdStorage::ConfigurationError => e
46
+ Rails.logger&.info { "#{ColdStorage::Logging::PREFIX} skipping schema sync: #{e.message}" }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ColdStorage
6
+ # Keeps track of the models that called `archivable`.
7
+ #
8
+ # Model names are stored as strings, never as class objects, so that code
9
+ # reloading in development does not pin stale constants.
10
+ module Registry
11
+ MUTEX = Mutex.new
12
+ private_constant :MUTEX
13
+
14
+ class << self
15
+ def register(model)
16
+ MUTEX.synchronize { names << model.name } if model.name
17
+ model
18
+ end
19
+
20
+ def registered?(model)
21
+ MUTEX.synchronize { names.include?(model.to_s) }
22
+ end
23
+
24
+ # @return [Array<String>]
25
+ def model_names(eager_load: true)
26
+ load_application! if eager_load
27
+ MUTEX.synchronize { names.to_a }.sort
28
+ end
29
+
30
+ # @return [Array<Class>] the archivable models, skipping names that no
31
+ # longer resolve (removed or renamed classes).
32
+ def models(eager_load: true)
33
+ model_names(eager_load: eager_load).filter_map do |name|
34
+ klass = name.safe_constantize
35
+ klass if klass.respond_to?(:archivable?) && klass.archivable?
36
+ end
37
+ end
38
+
39
+ def clear!
40
+ MUTEX.synchronize { @names = Set.new }
41
+ end
42
+
43
+ private
44
+
45
+ def names
46
+ @names ||= Set.new
47
+ end
48
+
49
+ def load_application!
50
+ return if @eager_loaded
51
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
52
+
53
+ Rails.application.eager_load!
54
+ @eager_loaded = true
55
+ rescue StandardError => e
56
+ ColdStorage.logger.warn { "#{Logging::PREFIX} eager load failed: #{e.class}: #{e.message}" }
57
+ end
58
+ end
59
+ end
60
+ end