miscellany 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,59 @@
1
+ # Example Usage (Preloading items with reference to the same Polymorphic Object):
2
+ #
3
+ # has_many :related_objects, -> (self) { where(poly_type: self.poly_type, poly_id: self.poly_id) }, preloader: 'RelatedObjectPreloader'
4
+ #
5
+ # class RelatedObjectPreloader < ActiveRecord::Associations::Preloader::Association
6
+ # def run(preloader)
7
+ # @preloaded_records = []
8
+ # owners.group_by(&:poly_type).each do |type, owner_group|
9
+ # ids = owner_group.map(&:poly_id)
10
+ #
11
+ # ids_to_priors = {}
12
+ # scope = Poly.scope_for_association.where(poly_type: self.poly_type, poly_id: self.poly_id)
13
+ # scope.find_each do |pa|
14
+ # ids_to_priors[pa.poly_id] ||= []
15
+ # ids_to_priors[pa.poly_id] << pa
16
+ # @preloaded_records << pa
17
+ # end
18
+ #
19
+ # owner_group.each do |owner|
20
+ # priors = ids_to_priors[owner.poly_id] || []
21
+ #
22
+ # association = owner.association(reflection.name)
23
+ # association.loaded!
24
+ # association.target = priors
25
+ #
26
+ # # association.set_inverse_instance(record)
27
+ # end
28
+ # end
29
+ # end
30
+ # end
31
+ #
32
+ module Miscellany
33
+ module CustomPreloaders
34
+ module AssociationBuilderExtension
35
+ def self.build(model, reflection); end
36
+
37
+ def self.valid_options
38
+ [:preloader]
39
+ end
40
+ end
41
+
42
+ module PreloaderExtension
43
+ def preloader_for(reflection, owners)
44
+ cust_preloader = reflection.options[:preloader]
45
+ if cust_preloader.present?
46
+ cust_preloader = cust_preloader.constantize if cust_preloader.is_a?(String)
47
+ cust_preloader
48
+ else
49
+ super
50
+ end
51
+ end
52
+ end
53
+
54
+ def self.install
55
+ ActiveRecord::Associations::Builder::Association.extensions << AssociationBuilderExtension
56
+ ActiveRecord::Associations::Preloader.prepend(CustomPreloaders::PreloaderExtension)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ module Miscellany
2
+ # An array that "processes" after so many items are added.
3
+ #
4
+ # Example Usage:
5
+ # batches = BatchProcessor.new(of: 1000) do |batch|
6
+ # # Process the batch somehow
7
+ # end
8
+ # enumerator_of_some_kind.each { |item| batches << item }
9
+ # batches.flush
10
+ class BatchProcessor
11
+ attr_reader :batch_size
12
+
13
+ def initialize(of: 1000, &blk)
14
+ @batch_size = of
15
+ @block = blk
16
+ @current_batch = []
17
+ end
18
+
19
+ def <<(item)
20
+ @current_batch << item
21
+ process_batch if @current_batch.count >= batch_size
22
+ end
23
+
24
+ def add_all(items)
25
+ items.each do |i|
26
+ self << i
27
+ end
28
+ end
29
+
30
+ def flush
31
+ process_batch if @current_batch.present?
32
+ end
33
+
34
+ protected
35
+
36
+ def process_batch
37
+ @block.call(@current_batch)
38
+ @current_batch = []
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,86 @@
1
+ module Miscellany
2
+ class BatchingCsvProcessor
3
+ attr_accessor :csv, :file_name
4
+
5
+ class RowError < StandardError; end
6
+
7
+ def initialize(csv, file_name: nil)
8
+ @csv = csv
9
+ @file_name = file_name
10
+ end
11
+
12
+ def process_in_batches(&blk)
13
+ batch = BatchProcessor.new(&blk)
14
+ CSV.new(csv, headers: true, header_converters: :symbol).each.with_index do |row, line|
15
+ row[:line_number] = line + 1
16
+ next unless validate_line(row)
17
+
18
+ batch << row
19
+ end
20
+ batch.flush
21
+ end
22
+
23
+ def get_row_errors(row); end
24
+
25
+ def find_or_init(_row)
26
+ raise NotImplementedError
27
+ end
28
+
29
+ def apply_row_to_model(_row, _instance)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ def log_line_error(message, line_number, **kwargs)
34
+ raise NotImplementedError
35
+ end
36
+
37
+ def validate_line(row)
38
+ errors = get_row_errors(row) || []
39
+ if errors.present?
40
+ log_line_error(errors[0], row[:line_number])
41
+ false
42
+ else
43
+ true
44
+ end
45
+ end
46
+
47
+ def batch_rows_to_models(rows)
48
+ rows.map { |row| build_model_from_row(row) }.reject! { |inst| inst.nil? || !inst.changed? }
49
+ end
50
+
51
+ def build_model_from_row(row)
52
+ model = find_or_init(row)
53
+ apply_row_to_model(row, model)
54
+ return nil if model.respond_to?(:discarded_at) && model.discarded_at.present? && !model.persisted?
55
+
56
+ model
57
+ rescue ActiveRecord::RecordNotFound, RowError => err
58
+ log_line_error(err.message, row[:line_number], exception: err)
59
+ nil
60
+ rescue StandardError => err
61
+ log_line_error('An Internal Error Occurred', row[:line_number], exception: err)
62
+ Raven.capture_exception(err)
63
+ nil
64
+ end
65
+
66
+ def map_defined_columns(row, map)
67
+ newh = {}
68
+ map.each do |newk, oldk|
69
+ next unless row.include?(oldk)
70
+
71
+ newh[newk] = row[oldk]
72
+ end
73
+ newh
74
+ end
75
+
76
+ def self.file_matches?(file)
77
+ header = file.readline.strip.split(',')
78
+ file.try(:rewind)
79
+ headers_match?(header)
80
+ end
81
+
82
+ def self.headers_match?(headers)
83
+ (self::HEADERS - headers).empty?
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,62 @@
1
+ module Miscellany
2
+ module HttpErrorHandling
3
+ extend ActiveSupport::Concern
4
+
5
+ class UnauthorizedError < StandardError; end
6
+
7
+ class HttpError < StandardError
8
+ attr_accessor :status, :extra
9
+ def initialize(arg = nil, status: nil, message: '', **extra)
10
+ if arg.is_a?(Numeric)
11
+ raise ArgumentError, ':status supplied multiple times' if status.present?
12
+
13
+ status = arg
14
+ elsif arg.present?
15
+ raise ArgumentError, ':message supplied multiple times' if message.present?
16
+
17
+ message = arg
18
+ end
19
+ super(message)
20
+ @status = status
21
+ @extra = extra
22
+ end
23
+ end
24
+
25
+ module ClassMethods
26
+ def http_error(status = 400, message = nil, &blk)
27
+ message = blk if blk.present?
28
+ lambda { |err|
29
+ # This is necessary to ensure that HttpErrors are never handled as StandardErrors
30
+ if err.is_a?(HttpError)
31
+ render_http_error(err)
32
+ else
33
+ render_http_error(err, status: status, message: message)
34
+ end
35
+ }
36
+ end
37
+
38
+ def rescue_with_http_error(*errors, status: nil, message: nil, **kwargs)
39
+ rescue_from(*errors, with: http_error(status, message), **kwargs)
40
+ end
41
+ end
42
+
43
+ included do
44
+ rescue_from HttpError do |err|
45
+ render_http_error(err)
46
+ end
47
+
48
+ rescue_with_http_error UnauthorizedError, status: 401
49
+ end
50
+
51
+ def render_http_error(err, status: nil, message: nil)
52
+ status = err.status if err.is_a?(HttpError) && status.nil?
53
+ status ||= 400
54
+ message ||= err.message
55
+ message.message.call(err) if message.is_a?(Proc)
56
+ response_json = { status: status }
57
+ response_json[:message] = message if message.present?
58
+ response_json.merge!(err.extra)
59
+ render json: response_json, status: status
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,41 @@
1
+ module Miscellany
2
+ module JsonUploads
3
+ extend ActiveSupport::Concern
4
+
5
+ # This hackery allows using JSON params along with file-uploads
6
+ # On the frontend, use MultiPart form data, and add _parameters as an entry
7
+ def params
8
+ if @_params.nil?
9
+ params_obj = @_params = super
10
+ phash = params_obj.instance_variable_get(:@parameters)
11
+ if phash[:_parameters].present?
12
+ json_layer = JSON.parse(phash.delete(:_parameters))
13
+ shared_keys = phash.keys & json_layer.keys
14
+ main_layer = phash.slice(*shared_keys)
15
+ merge_params(json_layer, main_layer)
16
+ phash.merge!(json_layer)
17
+ end
18
+ end
19
+ @_params
20
+ end
21
+
22
+ private
23
+
24
+ def merge_params(base, layer)
25
+ if base.is_a?(Array) && (layer.is_a?(Hash) || layer.is_a?(Array))
26
+ base.each_with_index.map do |v, i|
27
+ over_key = layer.is_a?(Hash) ? i.to_s : i
28
+ merge_params(v, layer[over_key])
29
+ end
30
+ elsif base.is_a?(Hash) && layer.is_a?(Hash)
31
+ base.each do |k, v|
32
+ base[k] = merge_params(v, layer[k])
33
+ end
34
+ layer.merge(base)
35
+ else
36
+ Rails.logger.warn 'Duplicate parameter passed' if base.present? && layer.present?
37
+ layer.presence || base
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,271 @@
1
+ module Miscellany
2
+ module SlicedResponse
3
+ extend ActiveSupport::Concern
4
+
5
+ include HttpErrorHandling
6
+
7
+ def slice_results(queryset, **kwargs)
8
+ @sliced_data = sliced_json(queryset, **kwargs) { |x| x }
9
+ end
10
+
11
+ # rubocop:disable Metrics/ParameterLists
12
+ def sliced_json(
13
+ queryset, slice_params = params,
14
+ max_size: 50, default_size: 25, allow_all: false,
15
+ default_sort: nil, valid_sorts: nil,
16
+ &blk
17
+ )
18
+ normalized_sorts = normalize_sort_options(valid_sorts || queryset.column_names, default: default_sort)
19
+
20
+ slice = Slice.build(
21
+ queryset, slice_params,
22
+ default_page_size: default_size,
23
+ item_transformer: blk,
24
+ max_size: max_size,
25
+ default_size: default_size,
26
+ allow_all: allow_all,
27
+ valid_sorts: normalized_sorts,
28
+ )
29
+
30
+ slice.render_json
31
+ end
32
+ # rubocop:enable Metrics/ParameterLists
33
+
34
+ def as_sliced_json(queryset, slice: nil, total_count: nil, &blk)
35
+ slice = Slice.build(queryset, slice, total_count: total_count, item_transformer: blk)
36
+ slice.render_json
37
+ end
38
+
39
+ def bearcat_as_sliced_json(*args, transform: nil, **kwargs, &blk)
40
+ bearcat_exec = ->(slice) {
41
+ response = blk.call({
42
+ per_page: slice.page_size,
43
+ page: slice.page_number,
44
+ })
45
+
46
+ slice.rendered_json[:page_count] = response.page_count
47
+
48
+ response
49
+ }
50
+ sliced_json(bearcat_exec, *args, valid_sorts: {}, **kwargs, allow_all: false, &transform)
51
+ end
52
+
53
+ private
54
+
55
+ def normalize_sort_options(sorts, default: nil)
56
+ norm_sorts = { }
57
+
58
+ sorts.each do |s|
59
+ if s.is_a?(Hash)
60
+ s.each do |k,v|
61
+ sort_hash = normalize_sort(v, key: k)
62
+ norm_sorts[k] = sort_hash
63
+ end
64
+ else
65
+ sort_hash = normalize_sort(s)
66
+ norm_sorts[sort_hash[:column]] = sort_hash
67
+ # default ||= sort_hash
68
+ end
69
+ end
70
+
71
+ if default.present?
72
+ norm_default = normalize_sort(default)
73
+ reference = norm_sorts[norm_default[:key].to_s] || norm_default
74
+ norm_sorts[:default] = {
75
+ key: reference[:key],
76
+ column: reference[:column],
77
+ order: norm_default[:order] || reference[:order],
78
+ }
79
+ end
80
+
81
+ norm_sorts
82
+ end
83
+
84
+ def normalize_sort(sort, key: nil)
85
+ sort = sort.to_s if sort.is_a?(Symbol)
86
+ if sort.is_a?(String)
87
+ m = sort.match(/(\w+)(?: (ASC|DESC)(!?))?/)
88
+ sort = { column: m[1], order: m[2], force_order: m[3].present? }.compact
89
+ elsif sort.is_a?(Proc)
90
+ sort = { column: sort }
91
+ end
92
+ sort[:key] = key || sort[:column]
93
+ sort.compact
94
+ end
95
+
96
+ class Slice
97
+ attr_accessor :slice_start, :slice_end
98
+ attr_accessor :page_size, :page_number
99
+ attr_accessor :sort
100
+
101
+ attr_reader :items
102
+ attr_reader :options
103
+
104
+ def initialize(items, options={})
105
+ @items = items
106
+ @options = options
107
+ end
108
+
109
+ # rubocop:disable Metrics/PerceivedComplexity
110
+ def self.build(queryset, arg, options={})
111
+ new(queryset, options).tap do |slice|
112
+ if arg.is_a?(Array)
113
+ slice_bounds = arg
114
+ elsif arg.is_a?(String)
115
+ slice_bounds = arg.split(':').map(&:to_i)
116
+ elsif arg.is_a?(Range)
117
+ slice_bounds = arg.minmax
118
+ elsif arg.respond_to? :[]
119
+ if arg[:slice] == 'all' || arg[:page] == 'all'
120
+ slice_bounds = [0, -1]
121
+ elsif arg[:slice].present?
122
+ slice_bounds = arg[:slice].split(':').map(&:to_i)
123
+ else
124
+ page_size = slice[:page_size] = (arg[:page_size] || options[:default_page_size]).to_i
125
+ page_number = slice[:page_number] = (arg[:page] || 1).to_i
126
+ slice_bounds = [(page_number - 1) * page_size, page_number * page_size]
127
+ end
128
+
129
+ slice[:sort] = parse_and_validate_sorts(arg[:sort], options[:valid_sorts])
130
+ end
131
+
132
+ if slice_bounds.present?
133
+ slice[:slice_start] = slice_bounds[0]
134
+ slice[:slice_end] = slice_bounds[1]
135
+ end
136
+
137
+ if slice[:slice_end] == -1
138
+ raise HttpErrorHandling::HttpError, message: "cannot request whole collection" unless options[:allow_all]
139
+ else
140
+ if options[:max_size] && (slice[:slice_end] - slice[:slice_start]) > [options[:max_size], options[:default_size]].max
141
+ raise HttpErrorHandling::HttpError, message: "cannot request more than #{options[:max_size]} objects"
142
+ end
143
+ end
144
+ end
145
+ end
146
+ # rubocop:enable Metrics/PerceivedComplexity
147
+
148
+ def [](key)
149
+ send(key)
150
+ end
151
+
152
+ def []=(key, val)
153
+ send(:"#{key}=", val)
154
+ end
155
+
156
+ def render_json
157
+ return @rendered_json if defined?(@rendered_json)
158
+
159
+ json = @rendered_json = {
160
+ slice_start: slice_start,
161
+ slice_end: slice_end,
162
+ }
163
+
164
+ rendered_items
165
+
166
+ if page_size.present?
167
+ json[:page] ||= page_number
168
+ json[:page_size] ||= page_size
169
+ end
170
+
171
+ if total_item_count.present?
172
+ json[:total_count] ||= total_item_count
173
+ json[:page_count] ||= (total_item_count.to_f / page_size).ceil if page_size.present?
174
+ end
175
+
176
+ if sort.present?
177
+ json[:sort] ||= sort.map do |s|
178
+ "#{s[:key] || s[:column]} #{s[:order] || 'ASC'}"
179
+ end.join(', ')
180
+ end
181
+
182
+ json[:slice_start] ||= 0
183
+ json[:slice_end] ||= total_item_count
184
+
185
+ json[:items] = rendered_items
186
+
187
+ json
188
+ end
189
+ def rendered_json; render_json; end
190
+
191
+ def self.parse_and_validate_sorts(sortstr, sorts_map, silent_failure: true)
192
+ (sortstr || '').split(',').map do |s|
193
+ m = s.strip.match(/^(\w+)(?: (ASC|DESC))?$/)
194
+
195
+ if m.nil?
196
+ next if silent_failure
197
+ raise HttpErrorHandling::HttpError, message: 'Could not parse sort parameter'
198
+ end
199
+
200
+ resolved_sort = sorts_map[m[1]]
201
+ unless resolved_sort.present?
202
+ next if silent_failure
203
+ raise HttpErrorHandling::HttpError, message: 'Could not parse sort parameter'
204
+ end
205
+
206
+ sort = resolved_sort.dup
207
+ sort[:order] = m[2] if m[2].present? && !sort[:force_order]
208
+ sort
209
+ end.compact.presence || [sorts_map[:default]].compact
210
+ end
211
+
212
+ private
213
+
214
+ def rendered_items
215
+ ritems = sliced_items
216
+ ritems = ritems.map(&options[:item_transformer]) if options[:item_transformer]
217
+ ritems
218
+ end
219
+
220
+ def total_item_count
221
+ @total_item_count ||= options[:total_count] || (items.respond_to?(:count) && items.count) || nil
222
+ end
223
+
224
+ def sliced_items
225
+ @sliced_items ||= begin
226
+ if items.is_a?(Array)
227
+ items
228
+ elsif items.is_a?(Proc)
229
+ items.call(self)
230
+ elsif items.is_a?(ActiveRecord::Relation)
231
+ offset, limit = slice_bounds
232
+ limit -= offset unless limit.nil?
233
+ apply_ar_sort(items).limit(limit).offset(offset)
234
+ end
235
+ end
236
+ end
237
+
238
+ def slice_bounds
239
+ [slice_start, slice_end == -1 ? nil : slice_end]
240
+ end
241
+
242
+ def apply_ar_sort(qset)
243
+ if sort.present?
244
+ sorts = [ *Array(self.sort) ]
245
+ sorts << options[:valid_sorts][:default] if options.dig(:valid_sorts, :default).present?
246
+
247
+ sorts.reduce(qset) do |qset, sort|
248
+ if sort[:column].is_a?(Proc)
249
+ sort[:column].call(qset, sort[:order] || 'ASC')
250
+ else
251
+ qset.order(sort[:column] => sort[:order] || 'ASC')
252
+ end
253
+ end
254
+ else
255
+ qset
256
+ end
257
+ end
258
+ end
259
+
260
+ module JbuilderTemplateExt
261
+ def partial!(*args, **kwargs, &blk)
262
+ kwargs[:block] = blk if blk.present?
263
+ super(*args, **kwargs)
264
+ end
265
+ end
266
+
267
+ def self.install_extensions
268
+ ::JbuilderTemplate.prepend JbuilderTemplateExt
269
+ end
270
+ end
271
+ end