activeadmin_batched_export 0.1.0 → 0.3.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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/CODE_OF_CONDUCT.md +14 -26
  4. data/CONTRIBUTING.md +11 -17
  5. data/GOVERNANCE.md +11 -18
  6. data/README.md +46 -13
  7. data/SECURITY.md +10 -13
  8. data/activeadmin_batched_export.gemspec +2 -2
  9. data/app/assets/controllers/activeadmin_batched_export/batched_export_controller.js +136 -96
  10. data/app/assets/javascripts/activeadmin_batched_export/chunk_assembly.mjs +30 -0
  11. data/app/views/active_admin/batched_export/_actions.html.erb +12 -1
  12. data/app/views/active_admin/batched_export/_progress.html.erb +1 -1
  13. data/app/views/active_admin/batched_export/workspace.html.erb +3 -0
  14. data/app/views/active_admin/shared/_download_format_links.html.erb +9 -3
  15. data/config/locales/activeadmin_batched_export.en.yml +7 -2
  16. data/lib/activeadmin/batched_export/chunk_renderer.rb +113 -0
  17. data/lib/activeadmin/batched_export/configuration.rb +5 -3
  18. data/lib/activeadmin/batched_export/controller_methods.rb +115 -113
  19. data/lib/activeadmin/batched_export/engine.rb +37 -1
  20. data/lib/activeadmin/batched_export/errors.rb +9 -0
  21. data/lib/activeadmin/batched_export/export_cursor.rb +56 -0
  22. data/lib/activeadmin/batched_export/keyset_page.rb +84 -0
  23. data/lib/activeadmin/batched_export/resource_extension.rb +11 -0
  24. data/lib/activeadmin/batched_export/row_sanitizer.rb +11 -0
  25. data/lib/activeadmin/batched_export/snapshot_page.rb +140 -0
  26. data/lib/activeadmin/batched_export/snapshot_row.rb +41 -0
  27. data/lib/activeadmin/batched_export/styles.rb +1 -0
  28. data/lib/activeadmin/batched_export/version.rb +1 -1
  29. metadata +13 -5
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "builder"
4
- require "csv"
3
+ require "activeadmin/batched_export/chunk_renderer"
4
+ require "activeadmin/batched_export/errors"
5
+ require "activeadmin/batched_export/export_cursor"
6
+ require "activeadmin/batched_export/keyset_page"
7
+ require "activeadmin/batched_export/snapshot_page"
5
8
 
6
9
  module ActiveAdmin
7
10
  module BatchedExport
8
11
  module ControllerMethods
9
12
  extend ActiveSupport::Concern
13
+ include ChunkRenderer
10
14
 
11
15
  def batched_export
12
16
  authorize! ActiveAdmin::Authorization::READ, active_admin_config.resource_class
@@ -18,29 +22,64 @@ module ActiveAdmin
18
22
  return render(json: batched_export_meta(export_format))
19
23
  end
20
24
 
21
- batch_page = params[:batch_page].to_i
22
- if batch_page.positive?
23
- if request.format.symbol != export_format
24
- head :not_acceptable
25
- return
26
- end
27
- ensure_batch_download_format_allowed!(export_format)
28
- page = page_relation(batch_page)
29
- if page.out_of_range?
30
- head :not_found
31
- return
32
- end
33
- begin
34
- body = batched_export_batch_body(export_format, batch_page, page: page)
35
- rescue ExportMacroCatalog::UnknownMacroError
36
- return render(
37
- plain: I18n.t("active_admin.batched_export_page.unknown_macro"),
38
- status: :unprocessable_content
39
- )
40
- end
41
- return render(plain: body, content_type: batch_content_type(export_format))
25
+ return render_export_batch(export_format) if export_batch_request?(export_format)
26
+
27
+ render_export_workspace(export_format)
28
+ end
29
+
30
+ private
31
+
32
+ def normalized_export_format
33
+ format_name = params[:export_format].to_s.downcase
34
+ format_name = "csv" if format_name.blank?
35
+ unless %w[csv xml json].include?(format_name)
36
+ render(plain: "Invalid export format", status: :bad_request)
37
+ return nil
42
38
  end
39
+ format_name.to_sym
40
+ end
43
41
 
42
+ def export_batch_request?(export_format)
43
+ %i[csv json xml].include?(request.format.symbol) &&
44
+ request.format.symbol == export_format &&
45
+ params[:export_meta].blank?
46
+ end
47
+
48
+ def render_export_batch(export_format)
49
+ ensure_batch_download_format_allowed!(export_format)
50
+ refuse_over_max_export_rows! if starting_export_walk?
51
+ field, direction = batched_export_sort_pair
52
+ cursor = decode_export_cursor(field: field, direction: direction)
53
+ page = snapshot_export_page(field: field, direction: direction, cursor: cursor)
54
+ response.set_header(ExportCursor::NEXT_HEADER, page.next_cursor) if page.next_cursor
55
+ response.set_header(SnapshotPage::HEADER, page.snapshot_token) if page.snapshot_token
56
+ body = batched_export_batch_body(export_format, page.records, first_page: page.first_page)
57
+ render(plain: body, content_type: batch_content_type(export_format))
58
+ rescue ExportCursor::Invalid
59
+ render(plain: "Invalid export cursor", status: :bad_request)
60
+ rescue InvalidExportSnapshotError
61
+ render(
62
+ plain: I18n.t("active_admin.batched_export_page.unavailable_session"),
63
+ status: :bad_request
64
+ )
65
+ rescue ExportTooLargeError
66
+ render(
67
+ plain: I18n.t("active_admin.batched_export_page.over_max_rows"),
68
+ status: :bad_request
69
+ )
70
+ rescue UnresolvableExportColumnsError
71
+ render(
72
+ plain: I18n.t("active_admin.batched_export_page.select_at_least_one_column"),
73
+ status: :bad_request
74
+ )
75
+ rescue ExportMacroCatalog::UnknownMacroError
76
+ render(
77
+ plain: I18n.t("active_admin.batched_export_page.unknown_macro"),
78
+ status: :unprocessable_content
79
+ )
80
+ end
81
+
82
+ def render_export_workspace(export_format)
44
83
  ensure_batch_download_format_allowed!(export_format)
45
84
  @batched_export_format = export_format
46
85
  @batched_export_meta_url = batched_export_url_for(
@@ -57,20 +96,10 @@ module ActiveAdmin
57
96
  render "active_admin/batched_export/workspace", layout: "active_admin"
58
97
  end
59
98
 
60
- private
61
-
62
- def normalized_export_format
63
- format_name = params[:export_format].to_s.downcase
64
- format_name = "csv" if format_name.blank?
65
- unless %w[csv xml json].include?(format_name)
66
- render(plain: "Invalid export format", status: :bad_request)
67
- return nil
68
- end
69
- format_name.to_sym
70
- end
71
-
72
99
  def batched_export_url_for(request_format:, extra_params: {})
73
- query = request.query_parameters.except(:format, :commit, :page, :batch_page, :export_meta)
100
+ query = request.query_parameters.except(
101
+ :format, :commit, :page, :batch_page, :export_meta, :export_cursor, :export_snapshot
102
+ )
74
103
  hash = query.respond_to?(:to_unsafe_h) ? query.to_unsafe_h : query.to_h
75
104
  hash = hash.merge(extra_params.stringify_keys)
76
105
  url_for(action: :batched_export, format: request_format, params: hash, only_path: true)
@@ -92,16 +121,34 @@ module ActiveAdmin
92
121
  first_page = paginate(base, 1, effective_batch_size)
93
122
  total_count = first_page.total_count
94
123
  total_batches = total_count.zero? ? 0 : first_page.total_pages
124
+ cap = BatchedExport.config.max_export_rows
95
125
  {
96
126
  export_format: export_format,
97
127
  total_count: total_count,
98
128
  total_batches: total_batches,
99
129
  batch_size: effective_batch_size,
100
130
  filename: export_filename(export_format),
101
- large_export: total_count >= BatchedExport.config.large_export_row_threshold
131
+ large_export: total_count >= BatchedExport.config.large_export_row_threshold,
132
+ over_max: cap.present? && total_count > cap,
133
+ max_export_rows: cap
102
134
  }
103
135
  end
104
136
 
137
+ def export_filtered_count
138
+ paginate(find_collection(except: [:pagination]), 1, effective_batch_size).total_count
139
+ end
140
+
141
+ def starting_export_walk?
142
+ params[:export_snapshot].blank? && params[:export_cursor].blank?
143
+ end
144
+
145
+ def refuse_over_max_export_rows!
146
+ cap = BatchedExport.config.max_export_rows
147
+ return if cap.blank?
148
+
149
+ raise ExportTooLargeError if export_filtered_count > cap
150
+ end
151
+
105
152
  def export_filename(format_symbol)
106
153
  filename_proc = active_admin_config.batched_export_filename_proc
107
154
  if filename_proc
@@ -112,15 +159,6 @@ module ActiveAdmin
112
159
  "#{base}-#{Time.zone.now.to_date}.#{format_symbol}"
113
160
  end
114
161
 
115
- def batched_export_batch_body(export_format, batch_page, page: nil)
116
- case export_format
117
- when :csv then batched_csv_chunk(batch_page, page: page)
118
- when :json then batched_json_chunk(batch_page, page: page)
119
- when :xml then batched_xml_chunk(batch_page, page: page)
120
- else ""
121
- end
122
- end
123
-
124
162
  def batch_content_type(export_format)
125
163
  case export_format
126
164
  when :csv then "text/csv; charset=utf-8"
@@ -130,84 +168,46 @@ module ActiveAdmin
130
168
  end
131
169
  end
132
170
 
133
- def batched_csv_chunk(batch_page, page: nil)
134
- builder = active_admin_config.csv_builder
135
- options = builder.options.dup
136
- csv_options = options.except(:encoding_options, :humanize_name, :byte_order_mark)
137
- columns = batched_export_filter_columns(builder.exec_columns(view_context))
138
- buffer = +""
139
- byte_order_mark = options[:byte_order_mark]
140
- buffer << byte_order_mark if batch_page == 1 && byte_order_mark
141
- if batch_page == 1 && options.fetch(:column_names, true)
142
- header_line = columns.map do |column|
143
- ActiveAdmin::Sanitizer.sanitize(builder.send(:encode, column.name, options))
144
- end
145
- buffer << CSV.generate_line(header_line, **csv_options)
146
- end
147
- paginated_export_rows(batch_page, page: page) do |resource|
148
- row = builder.build_row(resource, columns, options)
149
- row = apply_export_macros(row, columns, resource)
150
- buffer << CSV.generate_line(row, **csv_options)
171
+ def batched_export_sort_pair
172
+ model = active_admin_config.resource_class
173
+ order_param = params[:order].presence || active_admin_config.sort_order
174
+ clause = ActiveAdmin::OrderClause.new(active_admin_config, order_param)
175
+ field = clause.valid? ? clause.field.to_s.split(".").last : nil
176
+ if field && model.column_names.include?(field)
177
+ [field, clause.order.to_s]
178
+ else
179
+ [model.primary_key.to_s, "desc"]
151
180
  end
152
- buffer
153
181
  end
154
182
 
155
- def batched_json_chunk(batch_page, page: nil)
156
- builder = active_admin_config.csv_builder
157
- options = builder.options
158
- columns = batched_export_filter_columns(builder.exec_columns(view_context))
159
- names = columns.map(&:name)
160
- rows = []
161
- paginated_export_rows(batch_page, page: page) do |resource|
162
- row = apply_export_macros(builder.build_row(resource, columns, options), columns, resource)
163
- rows << names.zip(row).to_h
164
- end
165
- rows.to_json
166
- end
183
+ def decode_export_cursor(field:, direction:)
184
+ raw = params[:export_cursor]
185
+ return nil if raw.blank?
167
186
 
168
- def batched_xml_chunk(batch_page, page: nil)
169
- builder = active_admin_config.csv_builder
170
- options = builder.options
171
- columns = batched_export_filter_columns(builder.exec_columns(view_context))
172
- xml = Builder::XmlMarkup.new(indent: 0)
173
- paginated_export_rows(batch_page, page: page) do |resource|
174
- row = apply_export_macros(builder.build_row(resource, columns, options), columns, resource)
175
- xml.batch do
176
- xml.record do
177
- columns.each_with_index do |column, index|
178
- xml.field("name" => column.name) { xml.text!(row[index].to_s) }
179
- end
180
- end
181
- end
187
+ cursor = ExportCursor.decode(raw)
188
+ unless cursor.field == field && cursor.direction == direction
189
+ raise ExportCursor::Invalid, "mismatch"
182
190
  end
183
- xml.target!
184
- end
185
-
186
- def apply_export_macros(row, columns, resource)
187
- ExportMacroResolver.apply(
188
- row: row,
189
- columns: columns,
190
- resource: resource,
191
- resource_settings: active_admin_config.batched_export_settings,
192
- registry: merged_macro_registry
193
- )
194
- end
195
191
 
196
- def merged_macro_registry
197
- BatchedExport.config.registered_macros.merge(ExportMacroCatalog.global_registry)
192
+ cursor
198
193
  end
199
194
 
200
- def paginated_export_rows(batch_page, page: nil)
201
- (page || page_relation(batch_page)).each do |resource|
202
- yield apply_decorator(resource)
203
- end
195
+ def snapshot_export_page(field:, direction:, cursor:)
196
+ SnapshotPage.fetch(
197
+ export_collection,
198
+ model: active_admin_config.resource_class,
199
+ field: field,
200
+ direction: direction,
201
+ cursor: cursor,
202
+ snapshot_param: params[:export_snapshot],
203
+ limit: effective_batch_size
204
+ )
204
205
  end
205
206
 
206
- def page_relation(page)
207
+ def export_collection
207
208
  collection = find_collection(except: [:pagination])
208
209
  includes_list = active_admin_config.batched_export_includes
209
- collection = collection.includes(includes_list) if includes_list.present?
210
- paginate(collection, page, effective_batch_size)
210
+ includes_list.present? ? collection.includes(includes_list) : collection
211
211
  end
212
212
 
213
213
  def effective_batch_size
@@ -269,7 +269,9 @@ module ActiveAdmin
269
269
  return columns if indices.empty?
270
270
 
271
271
  resolved = indices.filter_map { |index| columns[index] }
272
- resolved.presence || columns
272
+ raise UnresolvableExportColumnsError if resolved.empty?
273
+
274
+ resolved
273
275
  end
274
276
  end
275
277
  end
@@ -12,13 +12,23 @@ module ActiveAdmin
12
12
  require "activeadmin/batched_export/export_macro_resolver"
13
13
  require "activeadmin/batched_export/configuration"
14
14
  require "activeadmin/batched_export/resource_extension"
15
+ require "activeadmin/batched_export/errors"
16
+ require "activeadmin/batched_export/export_cursor"
17
+ require "activeadmin/batched_export/keyset_page"
18
+ require "activeadmin/batched_export/snapshot_row"
19
+ require "activeadmin/batched_export/snapshot_page"
20
+ require "activeadmin/batched_export/row_sanitizer"
21
+ require "activeadmin/batched_export/chunk_renderer"
15
22
  require "activeadmin/batched_export/controller_methods"
16
23
  require "activeadmin/batched_export/install"
17
24
  end
18
25
 
19
26
  initializer "activeadmin_batched_export.assets" do |app|
20
27
  assets_path = root.join("app/assets")
21
- app.config.importmap.cache_sweepers << assets_path.join("controllers") if app.config.respond_to?(:importmap)
28
+ next unless app.config.respond_to?(:importmap)
29
+
30
+ app.config.importmap.cache_sweepers << assets_path.join("controllers")
31
+ app.config.importmap.cache_sweepers << assets_path.join("javascripts")
22
32
  end
23
33
 
24
34
  initializer "activeadmin_batched_export.importmap", after: :load_config_initializers do
@@ -27,6 +37,8 @@ module ActiveAdmin
27
37
  pin_controller = proc do |importmap|
28
38
  importmap.pin "controllers/activeadmin_batched_export/batched_export_controller",
29
39
  to: "activeadmin_batched_export/batched_export_controller.js"
40
+ importmap.pin "activeadmin_batched_export/chunk_assembly",
41
+ to: "activeadmin_batched_export/chunk_assembly.mjs"
30
42
  end
31
43
 
32
44
  Rails.application.importmap.draw(&pin_controller)
@@ -37,6 +49,30 @@ module ActiveAdmin
37
49
  I18n.load_path << root.join("config/locales/activeadmin_batched_export.en.yml")
38
50
  end
39
51
 
52
+ initializer "activeadmin_batched_export.view_overrides", after: "activeadmin_batched_export.load_lib" do
53
+ views_path = root.join("app/views").to_s
54
+
55
+ ActiveSupport.on_load(:active_admin_controller) do
56
+ prepend_view_path(views_path)
57
+ end
58
+
59
+ if defined?(ActiveAdmin::BaseController)
60
+ ActiveAdmin::BaseController.prepend_view_path(views_path)
61
+ end
62
+ end
63
+
64
+ initializer "activeadmin_batched_export.after_load", after: :load_config_initializers do
65
+ next unless defined?(ActiveAdmin) && ActiveAdmin.respond_to?(:after_load)
66
+
67
+ ActiveAdmin.after_load do
68
+ ActiveAdmin::BatchedExport::Install.call if defined?(ActiveAdmin::BatchedExport::Install)
69
+ end
70
+ end
71
+
72
+ initializer "activeadmin_batched_export.install", after: "active_admin.routes" do
73
+ ActiveAdmin::BatchedExport::Install.call if defined?(ActiveAdmin)
74
+ end
75
+
40
76
  config.to_prepare do
41
77
  ActiveAdmin::BatchedExport::Install.call if defined?(ActiveAdmin)
42
78
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module BatchedExport
5
+ class UnresolvableExportColumnsError < StandardError; end
6
+ class ExportTooLargeError < StandardError; end
7
+ class InvalidExportSnapshotError < StandardError; end
8
+ end
9
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+
6
+ module ActiveAdmin
7
+ module BatchedExport
8
+ class ExportCursor
9
+ Invalid = Class.new(StandardError)
10
+ NEXT_HEADER = "X-Batched-Export-Next"
11
+
12
+ Payload = Struct.new(:field, :direction, :primary_key, :sort_value, :position)
13
+
14
+ def self.encode(field:, direction:, primary_key:, sort_value:, position: nil)
15
+ payload = {
16
+ "f" => field,
17
+ "d" => direction,
18
+ "k" => dump_value(primary_key),
19
+ "s" => dump_value(sort_value)
20
+ }
21
+ payload["p"] = position unless position.nil?
22
+ Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
23
+ end
24
+
25
+ def self.decode(raw)
26
+ raise Invalid, "blank" if raw.nil? || raw.to_s.empty?
27
+
28
+ payload_from_hash(JSON.parse(Base64.urlsafe_decode64(raw.to_s)))
29
+ rescue ArgumentError, JSON::ParserError, KeyError, TypeError
30
+ raise Invalid
31
+ end
32
+
33
+ def self.dump_value(value)
34
+ case value
35
+ when Time, DateTime then value.iso8601(6)
36
+ when Date then value.iso8601
37
+ else value
38
+ end
39
+ end
40
+
41
+ def self.payload_from_hash(hash)
42
+ direction = hash.fetch("d")
43
+ raise Invalid, "direction" unless %w[asc desc].include?(direction)
44
+
45
+ Payload.new(
46
+ hash.fetch("f").to_s,
47
+ direction,
48
+ hash.fetch("k"),
49
+ hash.fetch("s"),
50
+ hash["p"]
51
+ )
52
+ end
53
+ private_class_method :payload_from_hash
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module BatchedExport
5
+ class KeysetPage
6
+ def self.records(relation, model:, field:, direction:, cursor:, limit:)
7
+ scoped = ordered(relation, model: model, field: field, direction: direction)
8
+ scoped = apply_after(scoped, model: model, field: field, direction: direction, cursor: cursor) if cursor
9
+ scoped.limit(limit).to_a
10
+ end
11
+
12
+ def self.ordered(relation, model:, field:, direction:)
13
+ table = model.arel_table
14
+ primary_key = model.primary_key.to_s
15
+ primary_key_order = arel_direction(table[primary_key], direction)
16
+ return relation.except(:order).order(primary_key_order) if field == primary_key
17
+
18
+ relation.except(:order).order(
19
+ table[field].eq(nil),
20
+ arel_direction(table[field], direction),
21
+ primary_key_order
22
+ )
23
+ end
24
+
25
+ def self.next_cursor(records, field:, direction:, primary_key:, limit:, position: nil)
26
+ return nil if records.empty? || records.length < limit
27
+
28
+ last_record = records.last
29
+ ExportCursor.encode(
30
+ field: field,
31
+ direction: direction,
32
+ primary_key: last_record.public_send(primary_key),
33
+ sort_value: last_record.public_send(field),
34
+ position: position
35
+ )
36
+ end
37
+
38
+ def self.apply_after(relation, model:, field:, direction:, cursor:)
39
+ table = model.arel_table
40
+ primary_key = model.primary_key
41
+ primary_key_value = cast(model, primary_key, cursor.primary_key)
42
+ if field == primary_key.to_s
43
+ return after_primary_key(relation, table[primary_key], primary_key_value, direction)
44
+ end
45
+
46
+ after_sort_and_primary_key(
47
+ relation,
48
+ table[field],
49
+ cast(model, field, cursor.sort_value),
50
+ table[primary_key],
51
+ primary_key_value,
52
+ direction
53
+ )
54
+ end
55
+
56
+ def self.arel_direction(column, direction)
57
+ (direction == "desc") ? column.desc : column.asc
58
+ end
59
+
60
+ def self.after_primary_key(relation, column, value, direction)
61
+ comparator = (direction == "desc") ? :lt : :gt
62
+ relation.where(column.public_send(comparator, value))
63
+ end
64
+
65
+ def self.after_sort_and_primary_key(relation, sort_column, sort_value, pk_column, pk_value, direction)
66
+ pk_past = pk_column.public_send((direction == "desc") ? :lt : :gt, pk_value)
67
+ return relation.where(sort_column.eq(nil).and(pk_past)) if sort_value.nil?
68
+
69
+ sort_past = sort_column.public_send((direction == "desc") ? :lt : :gt, sort_value)
70
+ relation.where(
71
+ sort_past.and(sort_column.not_eq(nil))
72
+ .or(sort_column.eq(sort_value).and(pk_past))
73
+ .or(sort_column.eq(nil))
74
+ )
75
+ end
76
+
77
+ def self.cast(model, name, raw)
78
+ model.type_for_attribute(name).cast(raw)
79
+ end
80
+ private_class_method :apply_after, :arel_direction, :after_primary_key,
81
+ :after_sort_and_primary_key, :cast
82
+ end
83
+ end
84
+ end
@@ -11,12 +11,22 @@ module ActiveAdmin
11
11
  @batched_export_settings = value || {}
12
12
  end
13
13
 
14
+ def batched_export_configured?
15
+ @batched_export_configured == true
16
+ end
17
+
18
+ def batched_export_configured=(value)
19
+ @batched_export_configured = value
20
+ end
21
+
14
22
  def batched_export_enabled?
15
23
  settings = batched_export_settings
16
24
  if settings.key?(:enabled) || settings.key?("enabled")
17
25
  return settings[:enabled] != false && settings["enabled"] != false
18
26
  end
19
27
 
28
+ return true if batched_export_configured?
29
+
20
30
  BatchedExport.config.default_enabled
21
31
  end
22
32
 
@@ -55,6 +65,7 @@ module ActiveAdmin
55
65
 
56
66
  module ResourceDSL
57
67
  def batched_export(**options)
68
+ config.batched_export_configured = true
58
69
  config.batched_export_settings = options
59
70
  end
60
71
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module BatchedExport
5
+ module RowSanitizer
6
+ def self.apply(row)
7
+ row.map { |cell| ActiveAdmin::Sanitizer.sanitize(cell) }
8
+ end
9
+ end
10
+ end
11
+ end