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.
- checksums.yaml +7 -0
- data/README.md +3 -0
- data/config/initializers/01_custom_preloaders.rb +2 -0
- data/config/initializers/arbitrary_prefetch.rb +1 -0
- data/config/initializers/cancancan.rb +34 -0
- data/lib/miscellany.rb +6 -0
- data/lib/miscellany/active_record/arbitrary_prefetch.rb +163 -0
- data/lib/miscellany/active_record/batch_matcher.rb +199 -0
- data/lib/miscellany/active_record/batched_destruction.rb +96 -0
- data/lib/miscellany/active_record/custom_preloaders.rb +59 -0
- data/lib/miscellany/batch_processor.rb +41 -0
- data/lib/miscellany/batching_csv_processor.rb +86 -0
- data/lib/miscellany/controller/http_error_handling.rb +62 -0
- data/lib/miscellany/controller/json_uploads.rb +41 -0
- data/lib/miscellany/controller/sliced_response.rb +271 -0
- data/lib/miscellany/param_validator.rb +442 -0
- data/lib/miscellany/version.rb +3 -0
- data/miscellany.gemspec +43 -0
- metadata +297 -0
@@ -0,0 +1,442 @@
|
|
1
|
+
module Miscellany
|
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 timezone].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 self.record_type(model, key: :id)
|
17
|
+
Proc.new do |param, *args|
|
18
|
+
model.find_by!(key => param)
|
19
|
+
rescue ActiveRecord::RecordNotFound
|
20
|
+
raise ArgumentError
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(block, context, parameters = nil, options = nil)
|
25
|
+
@block = block
|
26
|
+
@context = context
|
27
|
+
@params = parameters || context.params
|
28
|
+
@subkeys = []
|
29
|
+
@options = options || {}
|
30
|
+
@errors = {}
|
31
|
+
@explicit_parameters = []
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.check(params, context: nil, &blk)
|
35
|
+
pv = new(blk, context, params)
|
36
|
+
pv.apply_checks
|
37
|
+
pv.errors
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.assert(params, context: nil, handle:, &blk)
|
41
|
+
errors = check(params, context: context, &blk)
|
42
|
+
if errors.present?
|
43
|
+
handle.call(errors)
|
44
|
+
else
|
45
|
+
params
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def apply_checks(&blk)
|
50
|
+
blk ||= @block
|
51
|
+
args = trim_arguments(blk, [params, @subkeys[-1]])
|
52
|
+
instance_exec(*args, &blk)
|
53
|
+
end
|
54
|
+
|
55
|
+
def parameter(param_keys, *args, **kwargs, &blk)
|
56
|
+
param_keys = Array(param_keys)
|
57
|
+
opts = normalize_opts(*args, **kwargs, &blk)
|
58
|
+
|
59
|
+
checks = {}
|
60
|
+
PREFIXES.each do |pfx|
|
61
|
+
pfx_keys = opts[pfx]&.keys&.select { |k| opts[pfx][k] } || []
|
62
|
+
pfx_keys.each do |k|
|
63
|
+
checks[k] = pfx # TODO: Support filters connected to multiple prefixes
|
64
|
+
end
|
65
|
+
# TODO: warn if pfx != :all && param_keys.length == 1
|
66
|
+
end
|
67
|
+
NON_PREFIXED.each do |k|
|
68
|
+
checks[k] = nil
|
69
|
+
end
|
70
|
+
|
71
|
+
all_results = {}
|
72
|
+
param_keys.each do |pk|
|
73
|
+
check_results = all_results[pk] = {}
|
74
|
+
run_check = ->(check, &blk) { exec_check(check_results, check, checks, options: opts, &blk) }
|
75
|
+
|
76
|
+
exec_check(check_results, :type) { coerce_type(params, pk, opts) } || next
|
77
|
+
|
78
|
+
run_check[:specified] { params.key?(pk) || 'must be specified' } || next
|
79
|
+
run_check[:present] { params[pk].present? || 'must be present' } || next
|
80
|
+
|
81
|
+
# Set Default
|
82
|
+
if params[pk].nil? && !opts[:default].nil?
|
83
|
+
params[pk] ||= opts[:default].respond_to?(:call) ? opts[:default].call : opts[:default]
|
84
|
+
next # We can assume that the default value is allowed
|
85
|
+
end
|
86
|
+
|
87
|
+
# Apply Transform
|
88
|
+
params[pk] = opts[:transform].to_proc.call(params[pk]) if params.include?(pk) && opts[:transform]
|
89
|
+
|
90
|
+
next if params[pk].nil?
|
91
|
+
|
92
|
+
run_check[:pattern] do |pattern|
|
93
|
+
return true if params[pk].to_s.match?(pattern)
|
94
|
+
|
95
|
+
"must match pattern: #{pattern.inspect}"
|
96
|
+
end
|
97
|
+
|
98
|
+
run_check[:in] do |one_of|
|
99
|
+
next true if one_of.include?(params[pk])
|
100
|
+
|
101
|
+
if one_of.is_a?(Range)
|
102
|
+
"must be between: #{one_of.begin}..#{one_of.end}"
|
103
|
+
else
|
104
|
+
"must be one of: #{one_of.to_a.join(', ')}"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Nested check
|
109
|
+
run_check[:block] do |blk|
|
110
|
+
iterate_array = false # TODO
|
111
|
+
sub_parameter(pk) do
|
112
|
+
if params.is_a?(Array) && iterate_array
|
113
|
+
params.each_with_index do |v, i|
|
114
|
+
sub_parameter(i) { apply_checks(&blk) }
|
115
|
+
end
|
116
|
+
else
|
117
|
+
apply_checks(&blk)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Nested check
|
123
|
+
run_check[:items] do |blk|
|
124
|
+
sub_parameter(pk) do
|
125
|
+
if params.is_a?(Array)
|
126
|
+
params.each_with_index do |v, i|
|
127
|
+
sub_parameter(i) { apply_checks(&blk) }
|
128
|
+
end
|
129
|
+
else
|
130
|
+
raise "items: validator can only be used with Arrays"
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
final_errors = {}
|
137
|
+
checks.each do |check, check_prefix|
|
138
|
+
if check_prefix == :all || check_prefix == nil
|
139
|
+
all_results.each do |field, err_map|
|
140
|
+
errs = err_map[check]
|
141
|
+
next unless errs.present?
|
142
|
+
|
143
|
+
final_errors[field] = merge_error_hashes(final_errors[field], errs)
|
144
|
+
end
|
145
|
+
elsif check_prefix == :none
|
146
|
+
all_results.each do |field, err_map|
|
147
|
+
errs = err_map[check]
|
148
|
+
final_errors[field] = merge_error_hashes(final_errors[field], "must NOT be #{check}") unless errs.present?
|
149
|
+
end
|
150
|
+
else
|
151
|
+
counts = check_pass_count(check, all_results)
|
152
|
+
field_key = param_keys.join(', ')
|
153
|
+
string_prefixes = {
|
154
|
+
onep: 'One or more of',
|
155
|
+
onem: 'At most one of',
|
156
|
+
one: 'Exactly one of',
|
157
|
+
}
|
158
|
+
|
159
|
+
if (counts[:passed] != 1 && check_prefix == :one) ||
|
160
|
+
(counts[:passed] > 1 && check_prefix == :onem) ||
|
161
|
+
(counts[:passed] < 1 && check_prefix == :onep)
|
162
|
+
|
163
|
+
final_errors = merge_error_hashes(final_errors, "#{string_prefixes[check_prefix]} #{field_key} #{check}")
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
@errors = merge_error_hashes(@errors, final_errors)
|
169
|
+
final_errors
|
170
|
+
end
|
171
|
+
|
172
|
+
alias p parameter
|
173
|
+
|
174
|
+
protected
|
175
|
+
|
176
|
+
def check_pass_count(check, all_results)
|
177
|
+
counts = { passed: 0, failed: 0, skipped: 0 }
|
178
|
+
all_results.each do |_field, field_results|
|
179
|
+
result = field_results[check]
|
180
|
+
key = if result.nil?
|
181
|
+
:skipped
|
182
|
+
elsif result.present?
|
183
|
+
:failed
|
184
|
+
else
|
185
|
+
:passed
|
186
|
+
end
|
187
|
+
counts[key] += 1
|
188
|
+
end
|
189
|
+
counts
|
190
|
+
end
|
191
|
+
|
192
|
+
def exec_check(state, check, checks_to_run = nil, options: nil, &blk)
|
193
|
+
return true if checks_to_run && !checks_to_run[check] && !NON_PREFIXED.include?(check)
|
194
|
+
|
195
|
+
# TODO: Support Running checks of the same type for different prefixes
|
196
|
+
|
197
|
+
check_prefixes = NON_PREFIXED.include?(check) ? [nil] : Array(checks_to_run&.[](check))
|
198
|
+
return true unless check_prefixes.present?
|
199
|
+
|
200
|
+
check_prefixes.each do |check_prefix|
|
201
|
+
initial_errors = @errors
|
202
|
+
@errors = []
|
203
|
+
prefix_options = (check_prefix.nil? ? options : options&.[](check_prefix)) || {}
|
204
|
+
args = trim_arguments(blk, [prefix_options[check]])
|
205
|
+
|
206
|
+
result = yield(*args)
|
207
|
+
result = "failed validation #{check}" if result == false
|
208
|
+
|
209
|
+
if result.present? && result != true
|
210
|
+
result = options[:message] if options&.[](:message).present?
|
211
|
+
Array(result).each do |e|
|
212
|
+
@errors << e
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
state[check] = merge_error_hashes(state[check], @errors)
|
217
|
+
@errors = initial_errors
|
218
|
+
end
|
219
|
+
|
220
|
+
!state[check].present?
|
221
|
+
end
|
222
|
+
|
223
|
+
def coerce_type(params, key, opts)
|
224
|
+
value = params[key]
|
225
|
+
return nil if value.nil? || !opts[:type].present?
|
226
|
+
|
227
|
+
types = Array(opts[:type])
|
228
|
+
types.each do |t|
|
229
|
+
params[key] = coerce_single_type(value, t, opts)
|
230
|
+
return true
|
231
|
+
rescue ArgumentError, TypeError => err
|
232
|
+
end
|
233
|
+
|
234
|
+
"'#{value}' could not be cast to a #{types.join(' or a ')}"
|
235
|
+
end
|
236
|
+
|
237
|
+
def coerce_single_type(param, type, options)
|
238
|
+
return param if (param.is_a?(type) rescue false)
|
239
|
+
|
240
|
+
if type.is_a?(Class) && type <= ActiveRecord::Base
|
241
|
+
type = self.class.record_type(type)
|
242
|
+
end
|
243
|
+
|
244
|
+
return type.call(param, options) if type.is_a?(Proc)
|
245
|
+
|
246
|
+
if (param.is_a?(Array) && type != Array) || ((param.is_a?(Hash) || param.is_a?(ActionController::Parameters)) && type != Hash)
|
247
|
+
raise ArgumentError
|
248
|
+
end
|
249
|
+
return param if (param.is_a?(ActionController::Parameters) && type == Hash rescue false)
|
250
|
+
|
251
|
+
# Primitives
|
252
|
+
return Integer(param) if type == Integer
|
253
|
+
return Float(param) if type == Float
|
254
|
+
return Float(param) if type == Numeric
|
255
|
+
return String(param) if type == String
|
256
|
+
|
257
|
+
# Date/Time
|
258
|
+
if TIME_TYPES.include? type
|
259
|
+
if tz = options[:timezone]
|
260
|
+
tz = ActiveSupport::TimeZone[tz] if tz.is_a?(String)
|
261
|
+
dt = options[:format].present? ? tz.strptime(param, options[:format]) : tz.parse(param)
|
262
|
+
dt = dt.to_date if type == Date
|
263
|
+
return dt
|
264
|
+
else
|
265
|
+
if options[:format].present?
|
266
|
+
return type.strptime(param, options[:format])
|
267
|
+
else
|
268
|
+
return type.parse(param)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Arrays/Hashes
|
274
|
+
raise ArgumentError if (type == Array || type == Hash) && !param.respond_to?(:split)
|
275
|
+
return Array(param.split(options[:delimiter] || ',')) if type == Array
|
276
|
+
return Hash[param.split(options[:delimiter] || ',').map { |c| c.split(options[:separator] || ':') }] if type == Hash
|
277
|
+
|
278
|
+
# Booleans
|
279
|
+
if [TrueClass, FalseClass, :boolean, :bool].include?(type)
|
280
|
+
return false if /^(false|f|no|n|0)$/i === param.to_s
|
281
|
+
return true if /^(true|t|yes|y|1)$/i === param.to_s
|
282
|
+
|
283
|
+
raise ArgumentError
|
284
|
+
end
|
285
|
+
|
286
|
+
# BigDecimals
|
287
|
+
if type == BigDecimal
|
288
|
+
param = param.delete('$,').strip.to_f if param.is_a?(String)
|
289
|
+
return BigDecimal(param, (options[:precision] || DEFAULT_PRECISION))
|
290
|
+
end
|
291
|
+
nil
|
292
|
+
end
|
293
|
+
|
294
|
+
def normalize_opts(*args, **kwargs, &blk)
|
295
|
+
# Stage 1 - Convert args to kwargs
|
296
|
+
args, norm = convert_positional_args(args, kwargs)
|
297
|
+
if blk.present?
|
298
|
+
type = args.delete(:items) ? :all_items : :all_block
|
299
|
+
set_hash_key(norm, type, blk)
|
300
|
+
end
|
301
|
+
set_hash_key(norm, :type, args.pop(0)) if args.present?
|
302
|
+
|
303
|
+
# Stage 2
|
304
|
+
norm = convert_flags(norm)
|
305
|
+
|
306
|
+
# Stage 3
|
307
|
+
norm = convert_prefixed_keys(norm)
|
308
|
+
|
309
|
+
extra_kwargs = norm.keys - PREFIXES - NON_PREFIXED
|
310
|
+
raise ArgumentError, "Unrecognized postitional arguments: #{args.inspect}" if args.present?
|
311
|
+
raise ArgumentError, "Unrecognized keyword arguments: #{extra_kwargs.inspect}" if extra_kwargs.present?
|
312
|
+
|
313
|
+
norm
|
314
|
+
end
|
315
|
+
|
316
|
+
def convert_positional_args(args, kwargs)
|
317
|
+
dest_hash = { **kwargs }
|
318
|
+
args = args.reject do |arg|
|
319
|
+
next false unless arg.is_a?(Symbol)
|
320
|
+
|
321
|
+
flag, pfx = split_key(arg)
|
322
|
+
next false unless VALID_FLAGS.include?(flag) && (pfx.nil? || PREFIXES.include?(pfx))
|
323
|
+
|
324
|
+
set_hash_key(dest_hash, :"#{pfx || 'all'}_#{flag}", true)
|
325
|
+
true
|
326
|
+
end
|
327
|
+
[args, dest_hash]
|
328
|
+
end
|
329
|
+
|
330
|
+
def convert_flags(h)
|
331
|
+
dest_hash = {}
|
332
|
+
h.each do |k, v|
|
333
|
+
if VALID_FLAGS.include?(k) && normalize_prefix(v)
|
334
|
+
set_hash_key(dest_hash, :"#{normalize_prefix(v)}_#{k}", true)
|
335
|
+
else
|
336
|
+
dest_hash[k] = v
|
337
|
+
end
|
338
|
+
end
|
339
|
+
dest_hash
|
340
|
+
end
|
341
|
+
|
342
|
+
def convert_prefixed_keys(h)
|
343
|
+
dest_hash = {}
|
344
|
+
|
345
|
+
h.each do |k, v|
|
346
|
+
flag, pfx = split_key(k)
|
347
|
+
if !CHECKS.include?(flag)
|
348
|
+
dest_hash[k] = v
|
349
|
+
elsif NON_PREFIXED.include?(flag)
|
350
|
+
# TODO: Raise warning if pfx
|
351
|
+
dest_hash[k] = v
|
352
|
+
elsif normalize_prefix(flag)
|
353
|
+
dest_hash[flag] = merge_hashes(dest_hash[normalize_prefix(flag)] || {}, v)
|
354
|
+
elsif pfx.nil? || PREFIXES.include?(pfx)
|
355
|
+
pfx ||= :all
|
356
|
+
dest_hash[pfx] ||= {}
|
357
|
+
set_hash_key(dest_hash[pfx], flag, v)
|
358
|
+
else
|
359
|
+
dest_hash[k] = v
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
dest_hash
|
364
|
+
end
|
365
|
+
|
366
|
+
def normalize_prefix(prefix)
|
367
|
+
prefix = PREFIX_ALIASES[prefix] if PREFIX_ALIASES.include?(prefix)
|
368
|
+
return nil unless PREFIXES.include?(prefix)
|
369
|
+
|
370
|
+
prefix
|
371
|
+
end
|
372
|
+
|
373
|
+
def split_key(key)
|
374
|
+
skey = key.to_s
|
375
|
+
ALL_PREFIXES.each do |pfx|
|
376
|
+
spfx = pfx.to_s
|
377
|
+
next unless skey.starts_with?("#{spfx}_")
|
378
|
+
|
379
|
+
return [skey[(spfx.length + 1)..-1].to_sym, PREFIX_ALIASES[pfx] || pfx]
|
380
|
+
end
|
381
|
+
[key, nil]
|
382
|
+
end
|
383
|
+
|
384
|
+
def sub_parameter(k)
|
385
|
+
@subkeys.push(k)
|
386
|
+
yield
|
387
|
+
ensure
|
388
|
+
@subkeys.pop
|
389
|
+
end
|
390
|
+
|
391
|
+
def params
|
392
|
+
p = @params
|
393
|
+
@subkeys.each { |k| p = p[k] }
|
394
|
+
p
|
395
|
+
end
|
396
|
+
|
397
|
+
def merge_error_hashes(target, from)
|
398
|
+
target ||= []
|
399
|
+
if target.is_a?(Hash)
|
400
|
+
ta = []
|
401
|
+
th = target
|
402
|
+
else
|
403
|
+
ta = target
|
404
|
+
th = target[-1].is_a?(Hash) ? ta.pop : {}
|
405
|
+
end
|
406
|
+
|
407
|
+
if from.is_a?(Hash)
|
408
|
+
from.each_pair do |k, v|
|
409
|
+
th[k] = merge_error_hashes(th[k], v)
|
410
|
+
end
|
411
|
+
elsif from.is_a?(Array)
|
412
|
+
merge_error_hashes(th, from.pop) if from[-1].is_a?(Hash)
|
413
|
+
from.each { |f| ta << f }
|
414
|
+
else
|
415
|
+
ta << from
|
416
|
+
end
|
417
|
+
|
418
|
+
return th if !ta.present? && th.present?
|
419
|
+
|
420
|
+
ta << th if th.present?
|
421
|
+
ta
|
422
|
+
end
|
423
|
+
|
424
|
+
def merge_hashes(h1, h2)
|
425
|
+
h2.each do |k, v|
|
426
|
+
set_hash_key(h1, k, v)
|
427
|
+
end
|
428
|
+
h1
|
429
|
+
end
|
430
|
+
|
431
|
+
def set_hash_key(h, k, v)
|
432
|
+
k = k.to_sym
|
433
|
+
# TODO: warn if h[k].present?
|
434
|
+
h[k] = v
|
435
|
+
end
|
436
|
+
|
437
|
+
def trim_arguments(blk, args)
|
438
|
+
return args if blk.arity.negative?
|
439
|
+
args[0..(blk.arity.abs - 1)]
|
440
|
+
end
|
441
|
+
end
|
442
|
+
end
|
data/miscellany.gemspec
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
|
5
|
+
begin
|
6
|
+
require "miscellany/version"
|
7
|
+
version = Miscellany::VERSION
|
8
|
+
rescue LoadError
|
9
|
+
version = "0.0.0.docker"
|
10
|
+
end
|
11
|
+
|
12
|
+
Gem::Specification.new do |spec|
|
13
|
+
spec.name = "miscellany"
|
14
|
+
spec.version = version
|
15
|
+
spec.authors = ["Ethan Knapp"]
|
16
|
+
spec.email = ["eknapp@instructure.com"]
|
17
|
+
|
18
|
+
spec.summary = "Gem for a bunch of random, re-usable Rails Concerns & Helpers"
|
19
|
+
spec.homepage = "https://instructure.com"
|
20
|
+
|
21
|
+
spec.files = Dir["{app,config,db,lib}/**/*", "README.md", "*.gemspec"]
|
22
|
+
spec.test_files = Dir["spec/**/*"]
|
23
|
+
spec.require_paths = ['lib']
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler", "~> 1.15"
|
26
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
27
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
28
|
+
spec.add_development_dependency "rspec-rails"
|
29
|
+
spec.add_development_dependency "pg"
|
30
|
+
spec.add_development_dependency "factory"
|
31
|
+
spec.add_development_dependency "factory_bot"
|
32
|
+
spec.add_development_dependency "timecop"
|
33
|
+
spec.add_development_dependency "webmock"
|
34
|
+
spec.add_development_dependency "sinatra", ">= 0"
|
35
|
+
spec.add_development_dependency "shoulda-matchers"
|
36
|
+
spec.add_development_dependency "yard"
|
37
|
+
spec.add_development_dependency "pry"
|
38
|
+
spec.add_development_dependency "pry-nav"
|
39
|
+
spec.add_development_dependency "rubocop"
|
40
|
+
|
41
|
+
spec.add_dependency "rails", ">= 5"
|
42
|
+
spec.add_dependency "activerecord-import"
|
43
|
+
end
|