advanced_ar 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,94 @@
1
+ module AdvancedAR::BatchedDestruction
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ define_model_callbacks :bulk_destroy
6
+ define_model_callbacks :destroy_batch
7
+
8
+ before_destroy_batch do
9
+ # TODO Delete Dependant Relations
10
+ model_class.reflections.each do |name, reflection|
11
+ options = reflection.options
12
+ end
13
+ end
14
+ end
15
+
16
+ class_methods do
17
+ def bulk_destroy(**kwargs)
18
+ return to_sql
19
+ bulk_destroy_internal(self, **kwargs)
20
+ end
21
+
22
+ # Hook for performing the actual deletion of items, may be used to facilitate soft-deletion.
23
+ # Must not call destroy().
24
+ # Default implementation is to delete the batch using delete_all(id: batch_ids).
25
+ def destroy_bulk_batch(batch, options)
26
+ delete_ids = batch.map(&:id)
27
+ where(id: delete_ids).delete_all()
28
+ end
29
+
30
+ private
31
+
32
+ def bulk_destroy_internal(items, **kwargs)
33
+ options = {}
34
+ options.merge!(kwargs)
35
+ ClassCallbackExector.run_callbacks(model_class, :bulk_destroy, options: options) do
36
+ if items.respond_to?(:find_in_batches)
37
+ items.find_in_batches do |batch|
38
+ _destroy_batch(batch, options)
39
+ end
40
+ else
41
+ _destroy_batch(items, options)
42
+ end
43
+ end
44
+ end
45
+
46
+ def _destroy_batch(batch, options)
47
+ ClassCallbackExector.run_callbacks(model_class, :destroy_batch, {
48
+ model_class: model_class,
49
+ batch: batch,
50
+ }) do
51
+ model_class.destroy_bulk_batch(batch, options)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def model_class
58
+ try(:model) || self
59
+ end
60
+ end
61
+
62
+ def destroy(*args, legacy: false, **kwargs)
63
+ if legacy
64
+ super(*args)
65
+ else
66
+ self.class.send(:bulk_destroy_internal, [self], **kwargs)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # These classes are some Hackery to allow us to use callbacks against the Model classes instead of Model instances
73
+ class ClassCallbackExector
74
+ include ActiveSupport::Callbacks
75
+
76
+ attr_reader :callback_class
77
+ delegate :__callbacks, to: :callback_class
78
+ delegate_missing_to :callback_class
79
+
80
+ def initialize(cls, env)
81
+ @callback_class = cls
82
+ env.keys.each do |k|
83
+ define_singleton_method(k) do
84
+ env[k]
85
+ end
86
+ end
87
+ @options = options
88
+ end
89
+
90
+ def self.run_callbacks(cls, callback, env={}, &blk)
91
+ new(cls, env).run_callbacks(callback, &blk)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,86 @@
1
+ module AdvancedAR
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,57 @@
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 AdvancedAR::CustomPreloaders
33
+ module AssociationBuilderExtension
34
+ def self.build(model, reflection); end
35
+
36
+ def self.valid_options
37
+ [:preloader]
38
+ end
39
+ end
40
+
41
+ module PreloaderExtension
42
+ def preloader_for(reflection, owners)
43
+ cust_preloader = reflection.options[:preloader]
44
+ if cust_preloader.present?
45
+ cust_preloader = cust_preloader.constantize if cust_preloader.is_a?(String)
46
+ cust_preloader
47
+ else
48
+ super
49
+ end
50
+ end
51
+ end
52
+
53
+ def self.install
54
+ ActiveRecord::Associations::Builder::Association.extensions << AssociationBuilderExtension
55
+ ActiveRecord::Associations::Preloader.prepend(CustomPreloaders::PreloaderExtension)
56
+ end
57
+ end
@@ -0,0 +1,421 @@
1
+ module AdvancedAR
2
+ class ParamValidator
3
+ attr_accessor :context, :options, :errors
4
+
5
+ delegate_missing_to :context
6
+
7
+ TIME_TYPES = [Date, DateTime, Time].freeze
8
+
9
+ CHECKS = %i[type specified present default transform in block items pattern].freeze
10
+ NON_PREFIXED = %i[default transform type message].freeze
11
+ PREFIXES = %i[all onem onep one none].freeze
12
+ PREFIX_ALIASES = { any: :onep, not: :none }.freeze
13
+ ALL_PREFIXES = (PREFIXES + PREFIX_ALIASES.keys).freeze
14
+ VALID_FLAGS = %i[present specified].freeze
15
+
16
+ def initialize(block, context, parameters = nil, options = nil)
17
+ @block = block
18
+ @context = context
19
+ @params = parameters || context.params
20
+ @subkeys = []
21
+ @options = options || {}
22
+ @errors = {}
23
+ @explicit_parameters = []
24
+ end
25
+
26
+ def self.check(params, context: nil, &blk)
27
+ pv = new(blk, context, params)
28
+ pv.apply_checks
29
+ pv.errors
30
+ end
31
+
32
+ def self.assert(params, context: nil, handle:, &blk)
33
+ errors = check(params, context: context, &blk)
34
+ if errors.present?
35
+ handle.call(errors)
36
+ else
37
+ params
38
+ end
39
+ end
40
+
41
+ def apply_checks(&blk)
42
+ blk ||= @block
43
+ args = trim_arguments(blk, [params, @subkeys[-1]])
44
+ instance_exec(*args, &blk)
45
+ end
46
+
47
+ def parameter(param_keys, *args, **kwargs, &blk)
48
+ param_keys = Array(param_keys)
49
+ opts = normalize_opts(*args, **kwargs, &blk)
50
+
51
+ checks = {}
52
+ PREFIXES.each do |pfx|
53
+ pfx_keys = opts[pfx]&.keys&.select { |k| opts[pfx][k] } || []
54
+ pfx_keys.each do |k|
55
+ checks[k] = pfx # TODO: Support filters connected to multiple prefixes
56
+ end
57
+ # TODO: warn if pfx != :all && param_keys.length == 1
58
+ end
59
+ NON_PREFIXED.each do |k|
60
+ checks[k] = nil
61
+ end
62
+
63
+ all_results = {}
64
+ param_keys.each do |pk|
65
+ check_results = all_results[pk] = {}
66
+ run_check = ->(check, &blk) { exec_check(check_results, check, checks, options: opts, &blk) }
67
+
68
+ exec_check(check_results, :type) { coerce_type(params, pk, opts) } || next
69
+
70
+ run_check[:specified] { params.key?(pk) || 'must be specified' } || next
71
+ run_check[:present] { params[pk].present? || 'must be present' } || next
72
+
73
+ # Set Default
74
+ if params[pk].nil? && !opts[:default].nil?
75
+ params[pk] ||= opts[:default].respond_to?(:call) ? opts[:default].call : opts[:default]
76
+ next # We can assume that the default value is allowed
77
+ end
78
+
79
+ # Apply Transform
80
+ params[pk] = opts[:transform].to_proc.call(params[pk]) if params.include?(pk) && opts[:transform]
81
+
82
+ next if params[pk].nil?
83
+
84
+ run_check[:pattern] do |pattern|
85
+ return true if params[pk].to_s.match?(pattern)
86
+
87
+ "must match pattern: #{pattern.inspect}"
88
+ end
89
+
90
+ run_check[:in] do |one_of|
91
+ next true if one_of.include?(params[pk])
92
+
93
+ if one_of.is_a?(Range)
94
+ "must be between: #{one_of.begin}..#{one_of.end}"
95
+ else
96
+ "must be one of: #{one_of.to_a.join(', ')}"
97
+ end
98
+ end
99
+
100
+ # Nested check
101
+ run_check[:block] do |blk|
102
+ iterate_array = false # TODO
103
+ sub_parameter(pk) do
104
+ if params.is_a?(Array) && iterate_array
105
+ params.each_with_index do |v, i|
106
+ sub_parameter(i) { apply_checks(&blk) }
107
+ end
108
+ else
109
+ apply_checks(&blk)
110
+ end
111
+ end
112
+ end
113
+
114
+ # Nested check
115
+ run_check[:items] do |blk|
116
+ sub_parameter(pk) do
117
+ if params.is_a?(Array)
118
+ params.each_with_index do |v, i|
119
+ sub_parameter(i) { apply_checks(&blk) }
120
+ end
121
+ else
122
+ raise "items: validator can only be used with Arrays"
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ final_errors = {}
129
+ checks.each do |check, check_prefix|
130
+ if check_prefix == :all || check_prefix == nil
131
+ all_results.each do |field, err_map|
132
+ errs = err_map[check]
133
+ next unless errs.present?
134
+
135
+ final_errors[field] = merge_error_hashes(final_errors[field], errs)
136
+ end
137
+ elsif check_prefix == :none
138
+ all_results.each do |field, err_map|
139
+ errs = err_map[check]
140
+ final_errors[field] = merge_error_hashes(final_errors[field], "must NOT be #{check}") unless errs.present?
141
+ end
142
+ else
143
+ counts = check_pass_count(check, all_results)
144
+ field_key = param_keys.join(', ')
145
+ string_prefixes = {
146
+ onep: 'One or more of',
147
+ onem: 'At most one of',
148
+ one: 'Exactly one of',
149
+ }
150
+
151
+ if (counts[:passed] != 1 && check_prefix == :one) ||
152
+ (counts[:passed] > 1 && check_prefix == :onem) ||
153
+ (counts[:passed] < 1 && check_prefix == :onep)
154
+
155
+ final_errors = merge_error_hashes(final_errors, "#{string_prefixes[check_prefix]} #{field_key} #{check}")
156
+ end
157
+ end
158
+ end
159
+
160
+ @errors = merge_error_hashes(@errors, final_errors)
161
+ final_errors
162
+ end
163
+
164
+ alias p parameter
165
+
166
+ protected
167
+
168
+ def check_pass_count(check, all_results)
169
+ counts = { passed: 0, failed: 0, skipped: 0 }
170
+ all_results.each do |_field, field_results|
171
+ result = field_results[check]
172
+ key = if result.nil?
173
+ :skipped
174
+ elsif result.present?
175
+ :failed
176
+ else
177
+ :passed
178
+ end
179
+ counts[key] += 1
180
+ end
181
+ counts
182
+ end
183
+
184
+ def exec_check(state, check, checks_to_run = nil, options: nil, &blk)
185
+ return true if checks_to_run && !checks_to_run[check] && !NON_PREFIXED.include?(check)
186
+
187
+ # TODO: Support Running checks of the same type for different prefixes
188
+
189
+ check_prefixes = NON_PREFIXED.include?(check) ? [nil] : Array(checks_to_run&.[](check))
190
+ return true unless check_prefixes.present?
191
+
192
+ check_prefixes.each do |check_prefix|
193
+ initial_errors = @errors
194
+ @errors = []
195
+ prefix_options = (check_prefix.nil? ? options : options&.[](check_prefix)) || {}
196
+ args = trim_arguments(blk, [prefix_options[check]])
197
+
198
+ result = yield(*args)
199
+ result = "failed validation #{check}" if result == false
200
+
201
+ if result.present? && result != true
202
+ result = options[:message] if options&.[](:message).present?
203
+ Array(result).each do |e|
204
+ @errors << e
205
+ end
206
+ end
207
+
208
+ state[check] = merge_error_hashes(state[check], @errors)
209
+ @errors = initial_errors
210
+ end
211
+
212
+ !state[check].present?
213
+ end
214
+
215
+ def coerce_type(params, key, opts)
216
+ value = params[key]
217
+ return nil if value.nil? || !opts[:type].present?
218
+
219
+ types = Array(opts[:type])
220
+ types.each do |t|
221
+ params[key] = coerce_single_type(value, t, opts)
222
+ return true
223
+ rescue ArgumentError, TypeError => err
224
+ end
225
+
226
+ "'#{value}' could not be cast to a #{types.join(' or a ')}"
227
+ end
228
+
229
+ def coerce_single_type(param, type, options)
230
+ return param if (param.is_a?(type) rescue false)
231
+
232
+ if (param.is_a?(Array) && type != Array) || ((param.is_a?(Hash) || param.is_a?(ActionController::Parameters)) && type != Hash)
233
+ raise ArgumentError
234
+ end
235
+ return param if (param.is_a?(ActionController::Parameters) && type == Hash rescue false)
236
+
237
+ # Primitives
238
+ return Integer(param) if type == Integer
239
+ return Float(param) if type == Float
240
+ return Float(param) if type == Numeric
241
+ return String(param) if type == String
242
+
243
+ # Date/Time
244
+ if TIME_TYPES.include? type
245
+ if options[:format].present?
246
+ return type.strptime(param, options[:format])
247
+ else
248
+ return type.parse(param)
249
+ end
250
+ end
251
+
252
+ # Arrays/Hashes
253
+ raise ArgumentError if (type == Array || type == Hash) && !param.respond_to?(:split)
254
+ return Array(param.split(options[:delimiter] || ',')) if type == Array
255
+ return Hash[param.split(options[:delimiter] || ',').map { |c| c.split(options[:separator] || ':') }] if type == Hash
256
+
257
+ # Booleans
258
+ if [TrueClass, FalseClass, :boolean, :bool].include?(type)
259
+ return false if /^(false|f|no|n|0)$/i === param.to_s
260
+ return true if /^(true|t|yes|y|1)$/i === param.to_s
261
+
262
+ raise ArgumentError
263
+ end
264
+
265
+ # BigDecimals
266
+ if type == BigDecimal
267
+ param = param.delete('$,').strip.to_f if param.is_a?(String)
268
+ return BigDecimal(param, (options[:precision] || DEFAULT_PRECISION))
269
+ end
270
+ nil
271
+ end
272
+
273
+ def normalize_opts(*args, **kwargs, &blk)
274
+ # Stage 1 - Convert args to kwargs
275
+ args, norm = convert_positional_args(args, kwargs)
276
+ if blk.present?
277
+ type = args.delete(:items) ? :all_items : :all_block
278
+ set_hash_key(norm, type, blk)
279
+ end
280
+ set_hash_key(norm, :type, args.pop(0)) if args.present?
281
+
282
+ # Stage 2
283
+ norm = convert_flags(norm)
284
+
285
+ # Stage 3
286
+ norm = convert_prefixed_keys(norm)
287
+
288
+ extra_kwargs = norm.keys - PREFIXES - NON_PREFIXED
289
+ raise ArgumentError, "Unrecognized postitional arguments: #{args.inspect}" if args.present?
290
+ raise ArgumentError, "Unrecognized keyword arguments: #{extra_kwargs.inspect}" if extra_kwargs.present?
291
+
292
+ norm
293
+ end
294
+
295
+ def convert_positional_args(args, kwargs)
296
+ dest_hash = { **kwargs }
297
+ args = args.reject do |arg|
298
+ next false unless arg.is_a?(Symbol)
299
+
300
+ flag, pfx = split_key(arg)
301
+ next false unless VALID_FLAGS.include?(flag) && (pfx.nil? || PREFIXES.include?(pfx))
302
+
303
+ set_hash_key(dest_hash, :"#{pfx || 'all'}_#{flag}", true)
304
+ true
305
+ end
306
+ [args, dest_hash]
307
+ end
308
+
309
+ def convert_flags(h)
310
+ dest_hash = {}
311
+ h.each do |k, v|
312
+ if VALID_FLAGS.include?(k) && normalize_prefix(v)
313
+ set_hash_key(dest_hash, :"#{normalize_prefix(v)}_#{k}", true)
314
+ else
315
+ dest_hash[k] = v
316
+ end
317
+ end
318
+ dest_hash
319
+ end
320
+
321
+ def convert_prefixed_keys(h)
322
+ dest_hash = {}
323
+
324
+ h.each do |k, v|
325
+ flag, pfx = split_key(k)
326
+ if !CHECKS.include?(flag)
327
+ dest_hash[k] = v
328
+ elsif NON_PREFIXED.include?(flag)
329
+ # TODO: Raise warning if pfx
330
+ dest_hash[k] = v
331
+ elsif normalize_prefix(flag)
332
+ dest_hash[flag] = merge_hashes(dest_hash[normalize_prefix(flag)] || {}, v)
333
+ elsif pfx.nil? || PREFIXES.include?(pfx)
334
+ pfx ||= :all
335
+ dest_hash[pfx] ||= {}
336
+ set_hash_key(dest_hash[pfx], flag, v)
337
+ else
338
+ dest_hash[k] = v
339
+ end
340
+ end
341
+
342
+ dest_hash
343
+ end
344
+
345
+ def normalize_prefix(prefix)
346
+ prefix = PREFIX_ALIASES[prefix] if PREFIX_ALIASES.include?(prefix)
347
+ return nil unless PREFIXES.include?(prefix)
348
+
349
+ prefix
350
+ end
351
+
352
+ def split_key(key)
353
+ skey = key.to_s
354
+ ALL_PREFIXES.each do |pfx|
355
+ spfx = pfx.to_s
356
+ next unless skey.starts_with?("#{spfx}_")
357
+
358
+ return [skey[(spfx.length + 1)..-1].to_sym, PREFIX_ALIASES[pfx] || pfx]
359
+ end
360
+ [key, nil]
361
+ end
362
+
363
+ def sub_parameter(k)
364
+ @subkeys.push(k)
365
+ yield
366
+ ensure
367
+ @subkeys.pop
368
+ end
369
+
370
+ def params
371
+ p = @params
372
+ @subkeys.each { |k| p = p[k] }
373
+ p
374
+ end
375
+
376
+ def merge_error_hashes(target, from)
377
+ target ||= []
378
+ if target.is_a?(Hash)
379
+ ta = []
380
+ th = target
381
+ else
382
+ ta = target
383
+ th = target[-1].is_a?(Hash) ? ta.pop : {}
384
+ end
385
+
386
+ if from.is_a?(Hash)
387
+ from.each_pair do |k, v|
388
+ th[k] = merge_error_hashes(th[k], v)
389
+ end
390
+ elsif from.is_a?(Array)
391
+ merge_error_hashes(th, from.pop) if from[-1].is_a?(Hash)
392
+ from.each { |f| ta << f }
393
+ else
394
+ ta << from
395
+ end
396
+
397
+ return th if !ta.present? && th.present?
398
+
399
+ ta << th if th.present?
400
+ ta
401
+ end
402
+
403
+ def merge_hashes(h1, h2)
404
+ h2.each do |k, v|
405
+ set_hash_key(h1, k, v)
406
+ end
407
+ h1
408
+ end
409
+
410
+ def set_hash_key(h, k, v)
411
+ k = k.to_sym
412
+ # TODO: warn if h[k].present?
413
+ h[k] = v
414
+ end
415
+
416
+ def trim_arguments(blk, args)
417
+ return args if blk.arity.negative?
418
+ args[0..(blk.arity.abs - 1)]
419
+ end
420
+ end
421
+ end