miscellany 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,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