miscellany 0.1.31 → 0.2.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 +4 -4
- data/lib/miscellany/active_record/arbitrary_prefetch.rb +66 -4
- data/lib/miscellany/active_record/complex_query.rb +15 -6
- data/lib/miscellany/batching_csv_processor.rb +4 -2
- data/lib/miscellany/controller/http_error_handling.rb +4 -2
- data/lib/miscellany/controller/sliced_response.rb +14 -5
- data/lib/miscellany/local_lru_cache.rb +5 -6
- data/lib/miscellany/param_validator.rb +41 -8
- data/lib/miscellany/sort_lang.rb +4 -2
- data/lib/miscellany/version.rb +1 -1
- data/lib/miscellany.rb +9 -1
- data/miscellany.gemspec +3 -1
- data/spec/miscellany/arbitrary_prefetch_spec.rb +51 -0
- data/spec/miscellany/batch_matcher_spec.rb +130 -0
- data/spec/miscellany/batch_processor_spec.rb +75 -0
- data/spec/miscellany/batched_destruction_spec.rb +66 -0
- data/spec/miscellany/batching_csv_processor_spec.rb +138 -0
- data/spec/miscellany/complex_query_spec.rb +220 -0
- data/spec/miscellany/computed_columns_spec.rb +2 -1
- data/spec/miscellany/custom_preloaders_spec.rb +63 -0
- data/spec/miscellany/http_error_handling_spec.rb +137 -0
- data/spec/miscellany/jbuilder_partial_block_spec.rb +41 -0
- data/spec/miscellany/json_uploads_spec.rb +54 -0
- data/spec/miscellany/local_lru_cache_spec.rb +132 -0
- data/spec/miscellany/param_validator_spec.rb +97 -0
- data/spec/miscellany/require_spec.rb +34 -0
- data/spec/miscellany/sliced_response_spec.rb +31 -2
- data/spec/miscellany/sort_lang_spec.rb +33 -0
- metadata +48 -13
- data/config/initializers/cancancan.rb +0 -34
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c17779818cbaa02c273b128b603a78ffd7326ddd11fd5128aa44e67186be998
|
|
4
|
+
data.tar.gz: f0955c93b216e1e4916c20675868a9232d8b2aa53d019f512a29d3015b71ba80
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9a8d4a0ef8d98347690dffd6605fac5b458610045280e97f2916e5f8a12c830d9b3949a1f319868cc816c1a99327afd3105e0a3ad52dae17a93c9746a6e14edd
|
|
7
|
+
data.tar.gz: fae23a0570af847e51d455bd3a3ecb22f52dfc59dc2f00882e0c5515b28b4b192a190f0120b47e8c5f5047ef7542195bb8c426bfd7adbc2449cdad15aaa3c6dd
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Add support for creating arbitrary associations when using ActiveRecord
|
|
2
2
|
# Adds a `prefetch` method to ActiveRecord Queries.
|
|
3
3
|
# This method accepts a Hash. The keys of the Hash represent how the Association will be made available.
|
|
4
|
-
# The values of the Hash may be an array of [Symbol, Relation] or another (filtered) Relation.
|
|
4
|
+
# The values of the Hash may be an array of [Symbol, Relation or Proc], a Proc, or another (filtered) Relation.
|
|
5
5
|
# Objects are queried from an existing Association on the model. This Association is detemrined
|
|
6
|
-
# by either the Symbol when an array is passed,
|
|
6
|
+
# by either the Symbol when an array is passed, the association named in a Proc,
|
|
7
|
+
# or by finding an Assoication for the passed Relation's model
|
|
7
8
|
module Miscellany
|
|
8
9
|
module ArbitraryPrefetch
|
|
9
10
|
ACTIVE_RECORD_VERSION = ::Gem::Version.new(::ActiveRecord::VERSION::STRING).release
|
|
@@ -66,6 +67,43 @@ module Miscellany
|
|
|
66
67
|
end
|
|
67
68
|
end
|
|
68
69
|
|
|
70
|
+
# Evaluates a prefetch scope block such as `->{ comments.where(favorite: true) }`.
|
|
71
|
+
# Association names answer with an unscoped Relation on the association's target class
|
|
72
|
+
# and record which association the prefetch is based off of. Everything else falls
|
|
73
|
+
# through to the block's original `self`, so surrounding helper methods still work.
|
|
74
|
+
class ScopeResolver < BasicObject
|
|
75
|
+
attr_reader :source_key
|
|
76
|
+
|
|
77
|
+
def initialize(model, fallback)
|
|
78
|
+
@model = model
|
|
79
|
+
@fallback = fallback
|
|
80
|
+
@source_key = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def __evaluate__(block)
|
|
84
|
+
[instance_exec(&block), @source_key]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def method_missing(name, *args, &block)
|
|
88
|
+
reflection = @model.reflections[name.to_s]
|
|
89
|
+
|
|
90
|
+
if reflection.nil?
|
|
91
|
+
unless @fallback
|
|
92
|
+
::Kernel.raise ::NameError, "undefined local variable or method `#{name}' for prefetch scope on #{@model}"
|
|
93
|
+
end
|
|
94
|
+
return @fallback.__send__(name, *args, &block)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if @source_key && @source_key != name.to_sym
|
|
98
|
+
::Kernel.raise ::ArgumentError,
|
|
99
|
+
"prefetch scope references two associations (#{@source_key} and #{name}); it may only build off of one"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@source_key = name.to_sym
|
|
103
|
+
reflection.klass.all
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
69
107
|
module ActiveRecordPatches
|
|
70
108
|
module BasePatch
|
|
71
109
|
extend ActiveSupport::Concern
|
|
@@ -104,7 +142,7 @@ module Miscellany
|
|
|
104
142
|
def add_prefetches!(kwargs)
|
|
105
143
|
return unless kwargs.present?
|
|
106
144
|
|
|
107
|
-
if
|
|
145
|
+
if ACTIVE_RECORD_VERSION < ::Gem::Version.new('7.2.0')
|
|
108
146
|
assert_mutability!
|
|
109
147
|
else
|
|
110
148
|
assert_modifiable!
|
|
@@ -118,8 +156,10 @@ module Miscellany
|
|
|
118
156
|
end
|
|
119
157
|
|
|
120
158
|
def normalize_prefetch_options(attr, opts)
|
|
159
|
+
opts = resolve_prefetch_scope(opts) if opts.is_a?(Proc)
|
|
160
|
+
|
|
121
161
|
norm = if opts.is_a?(Array)
|
|
122
|
-
{ relation: opts[0], queryset: opts[1] }
|
|
162
|
+
{ relation: opts[0], queryset: prefetch_queryset(opts[0], opts[1]) }
|
|
123
163
|
elsif opts.is_a?(ActiveRecord::Relation)
|
|
124
164
|
rel_name = opts.model.name.underscore
|
|
125
165
|
rel = (model.reflections[rel_name] || model.reflections[rel_name.pluralize])&.name
|
|
@@ -133,6 +173,28 @@ module Miscellany
|
|
|
133
173
|
|
|
134
174
|
norm
|
|
135
175
|
end
|
|
176
|
+
|
|
177
|
+
# `->{ comments.where(favorite: true) }` - pull both the base association and the
|
|
178
|
+
# filtered queryset out of the block. Blocks that never name an association
|
|
179
|
+
# (`->{ Comment.where(...) }`) fall back to inferring it from the Relation's model.
|
|
180
|
+
def resolve_prefetch_scope(block)
|
|
181
|
+
raise ArgumentError, "prefetch scopes do not accept arguments" if block.arity > 0
|
|
182
|
+
|
|
183
|
+
fallback = block.binding.receiver rescue nil
|
|
184
|
+
queryset, source_key = ScopeResolver.new(model, fallback).__evaluate__(block)
|
|
185
|
+
source_key ? [source_key, queryset] : queryset
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# `[:comments, ->{ where(favorite: true) }]` - evaluate the block against the
|
|
189
|
+
# named association, the same way a Rails association scope is evaluated.
|
|
190
|
+
def prefetch_queryset(relation, queryset)
|
|
191
|
+
return queryset unless queryset.is_a?(Proc)
|
|
192
|
+
|
|
193
|
+
reflection = model.reflections[relation.to_s]
|
|
194
|
+
raise ArgumentError, "#{model} has no association named #{relation}" unless reflection
|
|
195
|
+
|
|
196
|
+
reflection.klass.all.instance_exec(&queryset)
|
|
197
|
+
end
|
|
136
198
|
end
|
|
137
199
|
|
|
138
200
|
module Relation
|
|
@@ -56,7 +56,7 @@ module Miscellany
|
|
|
56
56
|
conn = ActiveRecord::Base.connection
|
|
57
57
|
tbl = "#{self.class.name.split('::').last.underscore}_#{SecureRandom.hex[0..10]}"
|
|
58
58
|
|
|
59
|
-
conn.execute("CREATE TEMP TABLE #{tbl} AS
|
|
59
|
+
conn.execute("CREATE TEMP TABLE #{tbl} AS #{sql}")
|
|
60
60
|
|
|
61
61
|
offset = 0
|
|
62
62
|
loop do
|
|
@@ -64,10 +64,12 @@ module Miscellany
|
|
|
64
64
|
"SELECT * FROM #{tbl} LIMIT #{of} OFFSET #{offset}",
|
|
65
65
|
)
|
|
66
66
|
batch = batch.map(&:with_indifferent_access)
|
|
67
|
+
# Checked before yielding, so consumers never see an empty batch.
|
|
68
|
+
break if batch.empty?
|
|
69
|
+
|
|
67
70
|
augment_batch(batch)
|
|
68
71
|
yield batch
|
|
69
72
|
offset += of
|
|
70
|
-
break if batch.empty?
|
|
71
73
|
end
|
|
72
74
|
ensure
|
|
73
75
|
conn.execute("DROP TABLE IF EXISTS #{tbl}")
|
|
@@ -86,8 +88,9 @@ module Miscellany
|
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
def valid_sort?(sort)
|
|
89
|
-
sort_parser.valid?(sort)
|
|
90
91
|
return false unless sort.present?
|
|
92
|
+
|
|
93
|
+
sort_parser.valid?(sort)
|
|
91
94
|
end
|
|
92
95
|
|
|
93
96
|
protected
|
|
@@ -111,7 +114,10 @@ module Miscellany
|
|
|
111
114
|
end
|
|
112
115
|
|
|
113
116
|
def valid_sorts
|
|
114
|
-
|
|
117
|
+
# SortLang::Parser expects an array of sort specs (strings and/or hashes);
|
|
118
|
+
# wrap the SORTABLE_COLUMNS hash so it is treated as a single hash entry
|
|
119
|
+
# rather than iterated into [key, value] pairs.
|
|
120
|
+
return [self.class::SORTABLE_COLUMNS.with_indifferent_access] if defined?(self.class::SORTABLE_COLUMNS)
|
|
115
121
|
end
|
|
116
122
|
|
|
117
123
|
def sort_parser
|
|
@@ -119,7 +125,9 @@ module Miscellany
|
|
|
119
125
|
end
|
|
120
126
|
|
|
121
127
|
def sanitize_sql(*args)
|
|
122
|
-
ApplicationRecord
|
|
128
|
+
# ActiveRecord::Base rather than ApplicationRecord: the latter is a host-app
|
|
129
|
+
# constant this gem cannot count on existing.
|
|
130
|
+
ActiveRecord::Base.sanitize_sql(args)
|
|
123
131
|
end
|
|
124
132
|
|
|
125
133
|
def filters
|
|
@@ -156,7 +164,8 @@ module Miscellany
|
|
|
156
164
|
end
|
|
157
165
|
|
|
158
166
|
def _parse_datetime_range(range)
|
|
159
|
-
|
|
167
|
+
# A String/Symbol names a pair of "<key>_start"/"<key>_end" filters.
|
|
168
|
+
range = [filters["#{range}_start"], filters["#{range}_end"]] if range.is_a?(String) || range.is_a?(Symbol)
|
|
160
169
|
range = range.map{|v| _parse_datetime(v)}
|
|
161
170
|
range
|
|
162
171
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'csv'
|
|
2
|
+
|
|
1
3
|
module Miscellany
|
|
2
4
|
class BatchingCsvProcessor
|
|
3
5
|
attr_accessor :csv, :file_name
|
|
@@ -45,7 +47,7 @@ module Miscellany
|
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def batch_rows_to_models(rows)
|
|
48
|
-
rows.map { |row| build_model_from_row(row) }.reject
|
|
50
|
+
rows.map { |row| build_model_from_row(row) }.reject { |inst| inst.nil? || !inst.changed? }
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def build_model_from_row(row)
|
|
@@ -59,7 +61,7 @@ module Miscellany
|
|
|
59
61
|
nil
|
|
60
62
|
rescue StandardError => err
|
|
61
63
|
log_line_error('An Internal Error Occurred', row[:line_number], exception: err)
|
|
62
|
-
Raven.capture_exception(err)
|
|
64
|
+
Raven.capture_exception(err) if defined?(Raven)
|
|
63
65
|
nil
|
|
64
66
|
end
|
|
65
67
|
|
|
@@ -52,10 +52,12 @@ module Miscellany
|
|
|
52
52
|
status = err.status if err.is_a?(HttpError) && status.nil?
|
|
53
53
|
status ||= 400
|
|
54
54
|
message ||= err.message
|
|
55
|
-
message
|
|
55
|
+
message = message.call(err) if message.is_a?(Proc)
|
|
56
56
|
response_json = { status: status }
|
|
57
57
|
response_json[:message] = message if message.present?
|
|
58
|
-
|
|
58
|
+
# rescue_with_http_error routes ordinary exceptions here too, and only
|
|
59
|
+
# HttpError carries extra.
|
|
60
|
+
response_json.merge!(err.extra) if err.respond_to?(:extra) && err.extra.present?
|
|
59
61
|
render json: response_json, status: status
|
|
60
62
|
end
|
|
61
63
|
end
|
|
@@ -92,15 +92,24 @@ module Miscellany
|
|
|
92
92
|
elsif arg[:slice].present?
|
|
93
93
|
slice_bounds = arg[:slice].split(':').map(&:to_i)
|
|
94
94
|
else
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
# :default_page_size and :default_size are two names callers use for the
|
|
96
|
+
# same thing; honor either so a caller passing only one isn't left with 0.
|
|
97
|
+
default_page_size = options[:default_page_size] || options[:default_size]
|
|
98
|
+
page_size = slice[:page_size] = (arg[:page_size] || default_page_size).to_i
|
|
99
|
+
if page_size < 1
|
|
100
|
+
raise HttpErrorHandling::HttpError.new(message: "page_size must be at least 1")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Clamped rather than rejected, so that a page past either end of the
|
|
104
|
+
# collection stays a valid (if empty) request. Matches ComplexQuery#page.
|
|
105
|
+
page_number = slice[:page_number] = [(arg[:page] || 1).to_i, 1].max
|
|
97
106
|
slice_bounds = [(page_number - 1) * page_size, page_number * page_size]
|
|
98
107
|
end
|
|
99
108
|
|
|
100
109
|
begin
|
|
101
110
|
slice[:sort] = options[:sort_parser]&.parse(arg[:sort], ignore_errors: true, default: true)
|
|
102
111
|
rescue Miscellany::SortLang::Parser::SortParsingError => e
|
|
103
|
-
raise HttpErrorHandling::HttpError
|
|
112
|
+
raise HttpErrorHandling::HttpError.new(message: e.message)
|
|
104
113
|
end
|
|
105
114
|
end
|
|
106
115
|
|
|
@@ -110,10 +119,10 @@ module Miscellany
|
|
|
110
119
|
end
|
|
111
120
|
|
|
112
121
|
if slice[:slice_end] == -1
|
|
113
|
-
raise HttpErrorHandling::HttpError
|
|
122
|
+
raise HttpErrorHandling::HttpError.new(message: "cannot request whole collection") unless options[:allow_all]
|
|
114
123
|
else
|
|
115
124
|
if options[:max_size] && (slice[:slice_end] - slice[:slice_start]) > [options[:max_size], options[:default_size]].max
|
|
116
|
-
raise HttpErrorHandling::HttpError
|
|
125
|
+
raise HttpErrorHandling::HttpError.new(message: "cannot request more than #{options[:max_size]} objects")
|
|
117
126
|
end
|
|
118
127
|
end
|
|
119
128
|
end
|
|
@@ -6,12 +6,11 @@ module Miscellany
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def max_size=(size)
|
|
9
|
-
raise ArgumentError.new(:max_size) if
|
|
9
|
+
raise ArgumentError.new(:max_size) if size < 1
|
|
10
10
|
@max_size = size
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
11
|
+
# Evict least-recently-used entries (oldest first) until we fit.
|
|
12
|
+
while @data.size > @max_size
|
|
13
|
+
@data.delete(@data.first[0])
|
|
15
14
|
end
|
|
16
15
|
end
|
|
17
16
|
|
|
@@ -43,7 +42,7 @@ module Miscellany
|
|
|
43
42
|
end
|
|
44
43
|
|
|
45
44
|
def each
|
|
46
|
-
|
|
45
|
+
to_a.each do |pair|
|
|
47
46
|
yield pair
|
|
48
47
|
end
|
|
49
48
|
end
|
|
@@ -5,6 +5,20 @@ module Miscellany
|
|
|
5
5
|
|
|
6
6
|
delegate_missing_to :context
|
|
7
7
|
|
|
8
|
+
# Raised when a `type:` has no coercion rule at all. That is a mistake in the
|
|
9
|
+
# validator definition rather than bad input, so it escapes the ArgumentError
|
|
10
|
+
# rescue in `coerce_type` instead of becoming a validation error.
|
|
11
|
+
class UnsupportedTypeError < ArgumentError; end
|
|
12
|
+
|
|
13
|
+
# Returned by `coerce_single_type` when no coercion rule matched. Distinct from
|
|
14
|
+
# nil, which is a legitimate coerced value.
|
|
15
|
+
UNSUPPORTED_TYPE = Object.new.freeze
|
|
16
|
+
|
|
17
|
+
# Significant digits used when coercing a non-String to BigDecimal, where
|
|
18
|
+
# BigDecimal() requires an explicit precision. Strings pass 0 instead and are
|
|
19
|
+
# parsed exactly.
|
|
20
|
+
DEFAULT_PRECISION = Float::DIG + 1
|
|
21
|
+
|
|
8
22
|
TIME_TYPES = [Date, DateTime, Time].freeze
|
|
9
23
|
|
|
10
24
|
CHECKS = %i[type specified present default transform in block items pattern].freeze
|
|
@@ -93,7 +107,9 @@ module Miscellany
|
|
|
93
107
|
next if params[pk].nil?
|
|
94
108
|
|
|
95
109
|
run_check[:pattern] do |pattern|
|
|
96
|
-
return
|
|
110
|
+
# `next`, not `return` - a `return` here would exit `parameter` entirely,
|
|
111
|
+
# skipping the remaining checks and the final `@errors.merge!`.
|
|
112
|
+
next true if params[pk].to_s.match?(pattern)
|
|
97
113
|
|
|
98
114
|
"must match pattern: #{pattern.inspect}"
|
|
99
115
|
end
|
|
@@ -242,9 +258,20 @@ module Miscellany
|
|
|
242
258
|
|
|
243
259
|
types = Array(opts[:type])
|
|
244
260
|
types.each do |t|
|
|
245
|
-
|
|
261
|
+
coerced = begin
|
|
262
|
+
coerce_single_type(value, t, opts)
|
|
263
|
+
rescue ArgumentError, TypeError
|
|
264
|
+
next
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Raised outside the rescue above so it reaches the caller rather than
|
|
268
|
+
# being reported as an ordinary coercion failure.
|
|
269
|
+
if coerced.equal?(UNSUPPORTED_TYPE)
|
|
270
|
+
raise UnsupportedTypeError, "unsupported type #{t.inspect} for #{key.inspect}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
params[key] = coerced
|
|
246
274
|
return true
|
|
247
|
-
rescue ArgumentError, TypeError => err
|
|
248
275
|
end
|
|
249
276
|
|
|
250
277
|
"'#{value}' could not be cast to a #{types.join(' or a ')}"
|
|
@@ -302,10 +329,15 @@ module Miscellany
|
|
|
302
329
|
|
|
303
330
|
# BigDecimals
|
|
304
331
|
if type == BigDecimal
|
|
305
|
-
|
|
306
|
-
|
|
332
|
+
# Strings are handed to BigDecimal directly rather than via to_f, so that
|
|
333
|
+
# digits beyond Float's range survive. A precision of 0 tells BigDecimal to
|
|
334
|
+
# take it from the literal.
|
|
335
|
+
return BigDecimal(param.delete('$,').strip, options[:precision] || 0) if param.is_a?(String)
|
|
336
|
+
|
|
337
|
+
return BigDecimal(param, options[:precision] || DEFAULT_PRECISION)
|
|
307
338
|
end
|
|
308
|
-
|
|
339
|
+
|
|
340
|
+
UNSUPPORTED_TYPE
|
|
309
341
|
end
|
|
310
342
|
|
|
311
343
|
def normalize_opts(*args, **kwargs, &blk)
|
|
@@ -315,7 +347,8 @@ module Miscellany
|
|
|
315
347
|
type = args.delete(:items) ? :all_items : :all_block
|
|
316
348
|
set_hash_key(norm, type, blk)
|
|
317
349
|
end
|
|
318
|
-
|
|
350
|
+
# `args.pop`, not `args.pop(0)` - the latter removes nothing and returns [].
|
|
351
|
+
set_hash_key(norm, :type, args.pop) if args.present?
|
|
319
352
|
|
|
320
353
|
# Stage 2
|
|
321
354
|
norm = convert_flags(norm)
|
|
@@ -324,7 +357,7 @@ module Miscellany
|
|
|
324
357
|
norm = convert_prefixed_keys(norm)
|
|
325
358
|
|
|
326
359
|
extra_kwargs = norm.keys - PREFIXES - NON_PREFIXED
|
|
327
|
-
raise ArgumentError, "Unrecognized
|
|
360
|
+
raise ArgumentError, "Unrecognized positional arguments: #{args.inspect}" if args.present?
|
|
328
361
|
raise ArgumentError, "Unrecognized keyword arguments: #{extra_kwargs.inspect}" if extra_kwargs.present?
|
|
329
362
|
|
|
330
363
|
norm
|
data/lib/miscellany/sort_lang.rb
CHANGED
|
@@ -91,13 +91,15 @@ module Miscellany
|
|
|
91
91
|
|
|
92
92
|
if m.nil?
|
|
93
93
|
next if ignore_errors
|
|
94
|
-
raise
|
|
94
|
+
# Passed positionally - `raise Klass, message: '...'` would hand the Hash
|
|
95
|
+
# itself to the exception and surface it as the message.
|
|
96
|
+
raise SortParsingError, "could not parse sort parameter: #{s.strip.inspect}"
|
|
95
97
|
end
|
|
96
98
|
|
|
97
99
|
resolved_sort = @sorts_map[m[1]]
|
|
98
100
|
unless resolved_sort.present?
|
|
99
101
|
next if ignore_errors
|
|
100
|
-
raise SortParsingError,
|
|
102
|
+
raise SortParsingError, "unknown sort column: #{m[1].inspect}"
|
|
101
103
|
end
|
|
102
104
|
|
|
103
105
|
sort = resolved_sort.dup
|
data/lib/miscellany/version.rb
CHANGED
data/lib/miscellany.rb
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
|
|
2
|
+
# Required explicitly rather than relying on the host app to have loaded them
|
|
3
|
+
# first: the files below reference ActiveRecord and ActiveSupport at load time.
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/core_ext"
|
|
2
6
|
require "active_support/lazy_load_hooks"
|
|
7
|
+
require "active_record"
|
|
8
|
+
require "bigdecimal"
|
|
9
|
+
require "bigdecimal/util"
|
|
3
10
|
|
|
4
|
-
|
|
11
|
+
# Sorted so load order does not depend on the filesystem.
|
|
12
|
+
Dir[File.dirname(__FILE__) + "/miscellany/**/*.rb"].sort.each { |file| require file }
|
|
5
13
|
|
|
6
14
|
module Miscellany
|
|
7
15
|
|
data/miscellany.gemspec
CHANGED
|
@@ -23,13 +23,15 @@ Gem::Specification.new do |spec|
|
|
|
23
23
|
spec.require_paths = ['lib']
|
|
24
24
|
|
|
25
25
|
spec.add_dependency 'rails', '>= 5', '< 9.0'
|
|
26
|
+
# csv stopped being a default gem in Ruby 3.4; BatchingCsvProcessor requires it.
|
|
27
|
+
spec.add_dependency 'csv'
|
|
26
28
|
# spec.add_dependency 'activerecord', '>= 5', '< 6.3'
|
|
27
29
|
# spec.add_dependency 'activesupport', '>= 5', '< 6.3'
|
|
28
30
|
|
|
29
31
|
spec.add_development_dependency 'rake'
|
|
32
|
+
spec.add_development_dependency 'appraisal', '~> 2.4'
|
|
30
33
|
spec.add_development_dependency 'database_cleaner', '>= 1.2'
|
|
31
34
|
spec.add_development_dependency 'rspec', '~> 3'
|
|
32
|
-
spec.add_development_dependency 'sqlite3', '~> 1.3'
|
|
33
35
|
spec.add_development_dependency 'with_model'
|
|
34
36
|
spec.add_development_dependency 'goldiloader'
|
|
35
37
|
end
|
|
@@ -37,6 +37,51 @@ describe Miscellany::ArbitraryPrefetch do
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
context 'given a proc' do
|
|
41
|
+
it 'builds off of the association named in the proc' do
|
|
42
|
+
posts = Post.prefetch(favorite_comment: -> { comments.where(favorite: true) })
|
|
43
|
+
expect(posts[0].favorite_comment).to be_a Comment
|
|
44
|
+
expect(posts[0].favorite_comment.favorite).to eq true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'works with a plural key' do
|
|
48
|
+
posts = Post.prefetch(non_favorite_comments: -> { comments.where(favorite: nil) })
|
|
49
|
+
expect(posts[0].non_favorite_comments.length).to eq 4
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'falls back to the Relation model when no association is named' do
|
|
53
|
+
posts = Post.prefetch(favorite_comment: -> { Comment.where(favorite: true) })
|
|
54
|
+
expect(posts[0].favorite_comment).to be_a Comment
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'resolves non-association methods against the enclosing scope' do
|
|
58
|
+
def favorite_flag = true
|
|
59
|
+
|
|
60
|
+
posts = Post.prefetch(favorite_comment: -> { comments.where(favorite: favorite_flag) })
|
|
61
|
+
expect(posts[0].favorite_comment).to be_a Comment
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'rejects a proc that takes arguments' do
|
|
65
|
+
expect {
|
|
66
|
+
Post.prefetch(favorite_comment: ->(scope) { scope })
|
|
67
|
+
}.to raise_error(ArgumentError, /do not accept arguments/)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
context 'given a tuple with a proc' do
|
|
72
|
+
it 'evaluates the proc against the named association' do
|
|
73
|
+
posts = Post.prefetch(favorite_comment: [:comments, -> { where(favorite: true) }])
|
|
74
|
+
expect(posts[0].favorite_comment).to be_a Comment
|
|
75
|
+
expect(posts[0].favorite_comment.favorite).to eq true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'raises on an unknown association' do
|
|
79
|
+
expect {
|
|
80
|
+
Post.prefetch(favorite_comment: [:nope, -> { where(favorite: true) }])
|
|
81
|
+
}.to raise_error(ArgumentError, /no association named nope/)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
40
85
|
context 'prefetch is plural' do
|
|
41
86
|
it 'returns an Array' do
|
|
42
87
|
posts = Post.prefetch(non_favorite_comments: Comment.where(favorite: nil))
|
|
@@ -136,5 +181,11 @@ describe Miscellany::ArbitraryPrefetch do
|
|
|
136
181
|
end
|
|
137
182
|
|
|
138
183
|
include_examples "general specs"
|
|
184
|
+
|
|
185
|
+
it 'rejects a proc that names two associations' do
|
|
186
|
+
expect {
|
|
187
|
+
Post.prefetch(favorite_comment: -> { comments.where(id: interims.select(:comment_id)) })
|
|
188
|
+
}.to raise_error(ArgumentError, /may only build off of one/)
|
|
189
|
+
end
|
|
139
190
|
end
|
|
140
191
|
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Miscellany::BatchMatcher do
|
|
4
|
+
describe 'simple (single-model) matching' do
|
|
5
|
+
with_model :Rule do
|
|
6
|
+
table do |t|
|
|
7
|
+
t.string :import_id
|
|
8
|
+
t.string :label
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
let!(:rule_a) { Rule.create!(import_id: 'IMP-A', label: 'Alpha') }
|
|
13
|
+
let!(:rule_b) { Rule.create!(import_id: 'IMP-B', label: 'Beta') }
|
|
14
|
+
|
|
15
|
+
# [csv_key, db_key, human_name]; first entry is the primary column.
|
|
16
|
+
let(:columns) { [[:rule_id, :id, 'ID'], [:rule_import_id, :import_id, 'Import ID']] }
|
|
17
|
+
let(:rows) do
|
|
18
|
+
[
|
|
19
|
+
{ rule_id: rule_a.id, rule_import_id: 'IMP-A' },
|
|
20
|
+
{ rule_id: rule_b.id, rule_import_id: 'IMP-B' },
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def matcher(opts = {})
|
|
25
|
+
described_class.new(Rule, rows, **{ columns: columns }.merge(opts))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#get_for_row!' do
|
|
29
|
+
it 'matches on the primary column' do
|
|
30
|
+
expect(matcher.get_for_row!(rule_id: rule_a.id)).to eq rule_a
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'matches on a secondary column' do
|
|
34
|
+
expect(matcher.get_for_row!(rule_import_id: 'IMP-B')).to eq rule_b
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'matches when consistent values are given for multiple columns' do
|
|
38
|
+
expect(matcher.get_for_row!(rule_id: rule_a.id, rule_import_id: 'IMP-A')).to eq rule_a
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'raises RecordNotFound when nothing matches' do
|
|
42
|
+
expect { matcher.get_for_row!(rule_import_id: 'NOPE') }
|
|
43
|
+
.to raise_error(ActiveRecord::RecordNotFound, /Import ID/)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe '#get_for_row' do
|
|
48
|
+
it 'returns nil instead of raising when nothing matches' do
|
|
49
|
+
expect(matcher.get_for_row(rule_import_id: 'NOPE')).to be_nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'returns the record when it matches' do
|
|
53
|
+
expect(matcher.get_for_row(rule_id: rule_b.id)).to eq rule_b
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#get_primary_for_row!' do
|
|
58
|
+
it 'returns the primary key value for a primary-column row' do
|
|
59
|
+
expect(matcher.get_primary_for_row!(rule_id: rule_a.id)).to eq rule_a.id.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'resolves the primary key value from a secondary column' do
|
|
63
|
+
expect(matcher.get_primary_for_row!(rule_import_id: 'IMP-B')).to eq rule_b.id.to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#should_match?' do
|
|
68
|
+
it 'is true when any configured column has a value' do
|
|
69
|
+
expect(matcher.should_match?(rule_import_id: 'IMP-A')).to be true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'is false when no configured column has a value' do
|
|
73
|
+
expect(matcher.should_match?(unrelated: 'x')).to be false
|
|
74
|
+
expect(matcher.should_match?({})).to be false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe 'validate_all' do
|
|
79
|
+
let(:conflicting_row) { { rule_id: rule_a.id, rule_import_id: 'IMP-B' } }
|
|
80
|
+
|
|
81
|
+
it 'raises when columns resolve to different records (default)' do
|
|
82
|
+
expect { matcher.get_for_row!(conflicting_row) }
|
|
83
|
+
.to raise_error(ActiveRecord::RecordNotFound, /resolved to different objects/)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'returns the first match when validation is disabled' do
|
|
87
|
+
expect(matcher(validate_all: false).get_for_row!(conflicting_row)).to eq rule_a
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe 'polymorphic matching' do
|
|
93
|
+
with_model :Account do
|
|
94
|
+
table { |t| t.integer :canvas_id }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
with_model :Course do
|
|
98
|
+
table { |t| t.integer :canvas_id }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
let!(:account) { Account.create!(canvas_id: 100) }
|
|
102
|
+
let!(:course) { Course.create!(canvas_id: 200) }
|
|
103
|
+
|
|
104
|
+
let(:columns) { [[:canvas_context_id, :canvas_id, 'Canvas ID']] }
|
|
105
|
+
let(:rows) do
|
|
106
|
+
[
|
|
107
|
+
{ context_type: 'Account', canvas_context_id: 100 },
|
|
108
|
+
{ context_type: 'Course', canvas_context_id: 200 },
|
|
109
|
+
]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def matcher
|
|
113
|
+
described_class.new(
|
|
114
|
+
[Account, Course], rows,
|
|
115
|
+
polymorphic_on: :context_type,
|
|
116
|
+
columns: columns,
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'resolves rows to the correct model based on the polymorphic type' do
|
|
121
|
+
expect(matcher.get_for_row!(rows[0])).to eq account
|
|
122
|
+
expect(matcher.get_for_row!(rows[1])).to eq course
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'raises for an unknown polymorphic type' do
|
|
126
|
+
expect { matcher.get_for_row!(context_type: 'Widget', canvas_context_id: 1) }
|
|
127
|
+
.to raise_error(ActiveRecord::RecordNotFound)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|