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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e8daf2119169fb42523d39ed55e69d90dc43db0bed0178971b60aa279797a3e
4
- data.tar.gz: 96716ff17fbc11304ead76e6c294888db6b48fad5f3fa7ea214b6fb445870260
3
+ metadata.gz: 1c17779818cbaa02c273b128b603a78ffd7326ddd11fd5128aa44e67186be998
4
+ data.tar.gz: f0955c93b216e1e4916c20675868a9232d8b2aa53d019f512a29d3015b71ba80
5
5
  SHA512:
6
- metadata.gz: c0320bcd305fbbaa8588c705293ae81be13cd20e2b41cad37aaa56e4fc84c1a7414a79bc15f3d22337bc376307a56064df26d238a979f4a0da5edfd1567dfd4f
7
- data.tar.gz: 6f6f45648681860132544efc225e35d0575c7cf6f8a0b478ade5fe4defab8be9a498dd26d4af3f4159c5737146cee5332c31d7c912b10a1c083c126205f87423
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, or by finding an Assoication for the passed Relation's model
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 Rails.version < "7.2"
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 (#{sql})")
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
- return self.class::SORTABLE_COLUMNS.with_indifferent_access if defined?(self.class::SORTABLE_COLUMNS)
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.sanitize_sql(args)
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
- range = [filters["#{key}_start"], filters["#{key}_end"]] if range.is_a?(String) || range.is_a?(Symbol)
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! { |inst| inst.nil? || !inst.changed? }
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.message.call(err) if message.is_a?(Proc)
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
- response_json.merge!(err.extra)
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
- page_size = slice[:page_size] = (arg[:page_size] || options[:default_page_size]).to_i
96
- page_number = slice[:page_number] = (arg[:page] || 1).to_i
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, message: e.message
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, message: "cannot request whole collection" unless options[:allow_all]
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, message: "cannot request more than #{options[:max_size]} objects"
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 @max_size < 1
9
+ raise ArgumentError.new(:max_size) if size < 1
10
10
  @max_size = size
11
- if @max_size < @data.size
12
- @data.keys[0..@max_size-@data.size].each do |k|
13
- @data.delete(k)
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
- @data.reverse.each do |pair|
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 true if params[pk].to_s.match?(pattern)
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
- params[key] = coerce_single_type(value, t, opts)
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
- param = param.delete('$,').strip.to_f if param.is_a?(String)
306
- return BigDecimal(param, (options[:precision] || DEFAULT_PRECISION))
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
- nil
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
- set_hash_key(norm, :type, args.pop(0)) if args.present?
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 postitional arguments: #{args.inspect}" if args.present?
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
@@ -91,13 +91,15 @@ module Miscellany
91
91
 
92
92
  if m.nil?
93
93
  next if ignore_errors
94
- raise SortParsingError, message: 'Could not parse sort parameter'
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, message: 'Could not parse sort parameter'
102
+ raise SortParsingError, "unknown sort column: #{m[1].inspect}"
101
103
  end
102
104
 
103
105
  sort = resolved_sort.dup
@@ -1,3 +1,3 @@
1
1
  module Miscellany
2
- VERSION = "0.1.31".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
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
- Dir[File.dirname(__FILE__) + "/miscellany/**/*.rb"].each { |file| require file }
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