advanced_ar 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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