miscellany 0.1.30 → 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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/lib/miscellany/active_record/arbitrary_prefetch.rb +66 -4
  3. data/lib/miscellany/active_record/complex_query.rb +15 -6
  4. data/lib/miscellany/batching_csv_processor.rb +4 -2
  5. data/lib/miscellany/controller/http_error_handling.rb +4 -2
  6. data/lib/miscellany/controller/json_uploads.rb +17 -15
  7. data/lib/miscellany/controller/sliced_response.rb +14 -5
  8. data/lib/miscellany/local_lru_cache.rb +5 -6
  9. data/lib/miscellany/param_validator.rb +41 -8
  10. data/lib/miscellany/sort_lang.rb +4 -2
  11. data/lib/miscellany/version.rb +1 -1
  12. data/lib/miscellany.rb +9 -1
  13. data/miscellany.gemspec +3 -1
  14. data/spec/miscellany/arbitrary_prefetch_spec.rb +51 -0
  15. data/spec/miscellany/batch_matcher_spec.rb +130 -0
  16. data/spec/miscellany/batch_processor_spec.rb +75 -0
  17. data/spec/miscellany/batched_destruction_spec.rb +66 -0
  18. data/spec/miscellany/batching_csv_processor_spec.rb +138 -0
  19. data/spec/miscellany/complex_query_spec.rb +220 -0
  20. data/spec/miscellany/computed_columns_spec.rb +2 -1
  21. data/spec/miscellany/custom_preloaders_spec.rb +63 -0
  22. data/spec/miscellany/http_error_handling_spec.rb +137 -0
  23. data/spec/miscellany/jbuilder_partial_block_spec.rb +41 -0
  24. data/spec/miscellany/json_uploads_spec.rb +54 -0
  25. data/spec/miscellany/local_lru_cache_spec.rb +132 -0
  26. data/spec/miscellany/param_validator_spec.rb +97 -0
  27. data/spec/miscellany/require_spec.rb +34 -0
  28. data/spec/miscellany/sliced_response_spec.rb +31 -2
  29. data/spec/miscellany/sort_lang_spec.rb +33 -0
  30. metadata +48 -13
  31. data/config/initializers/cancancan.rb +0 -34
@@ -0,0 +1,63 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Miscellany::CustomPreloaders do
4
+ describe Miscellany::CustomPreloaders::AssociationBuilderExtension do
5
+ it 'registers :preloader as a valid association option' do
6
+ expect(described_class.valid_options).to include(:preloader)
7
+ end
8
+ end
9
+
10
+ describe 'option registration on a real association' do
11
+ with_model :Author do
12
+ table { |t| t.string :name }
13
+ model do
14
+ has_many :books, preloader: 'SomePreloaderClass'
15
+ end
16
+ end
17
+
18
+ with_model :Book do
19
+ table { |t| t.integer :author_id }
20
+ end
21
+
22
+ it 'accepts the :preloader option without raising' do
23
+ # If AssociationBuilderExtension were not installed, ActiveRecord would
24
+ # reject the unknown :preloader option when the association is touched.
25
+ expect { Author.reflect_on_association(:books).options }.not_to raise_error
26
+ expect(Author.reflect_on_association(:books).options[:preloader])
27
+ .to eq 'SomePreloaderClass'
28
+ end
29
+ end
30
+
31
+ describe Miscellany::CustomPreloaders::PreloaderExtension do
32
+ # A minimal stand-in for ActiveRecord's Preloader whose default
33
+ # preloader_for returns :fallback, so we can observe override behavior.
34
+ let(:preloader) do
35
+ base = Class.new do
36
+ def preloader_for(_reflection, _owners)
37
+ :fallback
38
+ end
39
+ end
40
+ base.prepend(Miscellany::CustomPreloaders::PreloaderExtension)
41
+ base.new
42
+ end
43
+
44
+ def reflection_with(options)
45
+ double('reflection', options: options)
46
+ end
47
+
48
+ it 'falls back to the default when no custom preloader is configured' do
49
+ expect(preloader.preloader_for(reflection_with({}), [])).to eq :fallback
50
+ end
51
+
52
+ it 'returns a custom preloader class as-is' do
53
+ custom = Class.new
54
+ expect(preloader.preloader_for(reflection_with(preloader: custom), [])).to eq custom
55
+ end
56
+
57
+ it 'constantizes a string preloader name' do
58
+ stub_const('MyCustomPreloader', Class.new)
59
+ expect(preloader.preloader_for(reflection_with(preloader: 'MyCustomPreloader'), []))
60
+ .to eq MyCustomPreloader
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,137 @@
1
+ require 'spec_helper'
2
+ require 'action_controller'
3
+
4
+ RSpec.describe Miscellany::HttpErrorHandling do
5
+ # Captures what the concern hands to `render` instead of driving a real response.
6
+ controller_class = Class.new(ActionController::Base) do
7
+ include Miscellany::HttpErrorHandling
8
+
9
+ attr_reader :rendered
10
+
11
+ def render(**kwargs)
12
+ @rendered = kwargs
13
+ end
14
+ end
15
+
16
+ let(:controller) { controller_class.new }
17
+
18
+ def rendered_for(err, **kwargs)
19
+ controller.render_http_error(err, **kwargs)
20
+ controller.rendered
21
+ end
22
+
23
+ describe '#render_http_error' do
24
+ it 'renders an HttpError with its own status, message, and extra' do
25
+ err = Miscellany::HttpErrorHandling::HttpError.new('nope', status: 422, code: 'E_NOPE')
26
+ expect(rendered_for(err)).to eq(
27
+ json: { status: 422, message: 'nope', code: 'E_NOPE' },
28
+ status: 422,
29
+ )
30
+ end
31
+
32
+ it 'defaults to 400 when no status is available' do
33
+ expect(rendered_for(Miscellany::HttpErrorHandling::HttpError.new('nope'))[:status]).to eq 400
34
+ end
35
+
36
+ # Reached by `rescue_with_http_error`, which passes ordinary exceptions.
37
+ it 'renders a plain StandardError that carries no extra' do
38
+ expect(rendered_for(StandardError.new('boom'), status: 422)).to eq(
39
+ json: { status: 422, message: 'boom' },
40
+ status: 422,
41
+ )
42
+ end
43
+
44
+ # The idiom ParamValidator's docs recommend: field errors must reach the client
45
+ # as their own key, not folded into the message string.
46
+ it 'carries structured field errors through as extra' do
47
+ errors = { 'search_term' => ['must be at least 3 characters'] }
48
+ err = Miscellany::HttpErrorHandling::HttpError.new(
49
+ 'invalid parameters', status: 422, parameter_errors: errors
50
+ )
51
+ expect(rendered_for(err)).to eq(
52
+ json: { status: 422, message: 'invalid parameters', parameter_errors: errors },
53
+ status: 422,
54
+ )
55
+ end
56
+
57
+ it 'prefers an explicit message over the exception message' do
58
+ expect(rendered_for(StandardError.new('boom'), message: 'friendlier')[:json][:message])
59
+ .to eq 'friendlier'
60
+ end
61
+ end
62
+
63
+ describe '.http_error' do
64
+ it 'resolves a Proc message against the exception' do
65
+ handler = controller_class.http_error(422, ->(err) { "custom: #{err.message}" })
66
+ controller.instance_exec(StandardError.new('boom'), &handler)
67
+ expect(controller.rendered).to eq(
68
+ json: { status: 422, message: 'custom: boom' },
69
+ status: 422,
70
+ )
71
+ end
72
+
73
+ it 'accepts the message as a block' do
74
+ handler = controller_class.http_error(422) { |err| "blocky: #{err.message}" }
75
+ controller.instance_exec(StandardError.new('boom'), &handler)
76
+ expect(controller.rendered[:json][:message]).to eq 'blocky: boom'
77
+ end
78
+
79
+ it 'lets an HttpError keep its own status rather than the handler default' do
80
+ handler = controller_class.http_error(500)
81
+ controller.instance_exec(Miscellany::HttpErrorHandling::HttpError.new('nope', status: 404), &handler)
82
+ expect(controller.rendered[:status]).to eq 404
83
+ end
84
+ end
85
+ end
86
+
87
+ RSpec.describe Miscellany::HttpErrorHandling::HttpError do
88
+ it 'defaults to a blank message, no status, and no extra' do
89
+ err = described_class.new
90
+ expect(err.message).to eq ''
91
+ expect(err.status).to be_nil
92
+ expect(err.extra).to eq({})
93
+ end
94
+
95
+ it 'treats a numeric positional argument as the status' do
96
+ err = described_class.new(404)
97
+ expect(err.status).to eq 404
98
+ expect(err.message).to eq ''
99
+ end
100
+
101
+ it 'treats a non-numeric positional argument as the message' do
102
+ err = described_class.new('boom')
103
+ expect(err.message).to eq 'boom'
104
+ expect(err.status).to be_nil
105
+ end
106
+
107
+ it 'accepts status and message as keywords' do
108
+ err = described_class.new(status: 422, message: 'bad input')
109
+ expect(err.status).to eq 422
110
+ expect(err.message).to eq 'bad input'
111
+ end
112
+
113
+ it 'combines a positional status with a keyword message' do
114
+ err = described_class.new(403, message: 'nope')
115
+ expect(err.status).to eq 403
116
+ expect(err.message).to eq 'nope'
117
+ end
118
+
119
+ it 'captures unknown keywords as extra' do
120
+ err = described_class.new('boom', code: 'E_BOOM', detail: 'context')
121
+ expect(err.extra).to eq(code: 'E_BOOM', detail: 'context')
122
+ end
123
+
124
+ it 'raises when status is given both positionally and as a keyword' do
125
+ expect { described_class.new(400, status: 500) }
126
+ .to raise_error(ArgumentError, /status supplied multiple times/)
127
+ end
128
+
129
+ it 'raises when message is given both positionally and as a keyword' do
130
+ expect { described_class.new('boom', message: 'also boom') }
131
+ .to raise_error(ArgumentError, /message supplied multiple times/)
132
+ end
133
+
134
+ it 'is a StandardError so it can be rescued' do
135
+ expect(described_class.new).to be_a(StandardError)
136
+ end
137
+ end
@@ -0,0 +1,41 @@
1
+ require 'spec_helper'
2
+
3
+ # JbuilderTemplateExt is meant to be prepended onto JbuilderTemplate. Rather than
4
+ # stand up a full jbuilder render, we prepend it onto a stub that records what
5
+ # partial! receives, which is exactly the behavior the extension changes.
6
+ RSpec.describe Miscellany::Extensions::JBuilder::JbuilderTemplateExt do
7
+ let(:recorder_class) do
8
+ Class.new do
9
+ attr_reader :last_args, :last_kwargs
10
+
11
+ def partial!(*args, **kwargs)
12
+ @last_args = args
13
+ @last_kwargs = kwargs
14
+ end
15
+ end
16
+ end
17
+
18
+ let(:instance) do
19
+ klass = recorder_class
20
+ klass.prepend(described_class)
21
+ klass.new
22
+ end
23
+
24
+ it 'injects a given block as the :block keyword' do
25
+ blk = -> { :rendered }
26
+ instance.partial!('shared/thing', foo: 1, &blk)
27
+
28
+ expect(instance.last_kwargs[:block]).to eq blk
29
+ expect(instance.last_kwargs[:foo]).to eq 1
30
+ end
31
+
32
+ it 'does not add a :block keyword when no block is given' do
33
+ instance.partial!('shared/thing', foo: 1)
34
+ expect(instance.last_kwargs).not_to have_key(:block)
35
+ end
36
+
37
+ it 'passes positional arguments through unchanged' do
38
+ instance.partial!('shared/thing')
39
+ expect(instance.last_args).to eq ['shared/thing']
40
+ end
41
+ end
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+
3
+ # The process_action override needs a full controller/request stack to exercise.
4
+ # The recursive parameter-merge logic, which is the substantive part, is unit
5
+ # tested here in isolation.
6
+ RSpec.describe Miscellany::JsonUploads do
7
+ let(:instance) { Class.new { include Miscellany::JsonUploads }.new }
8
+
9
+ # _merge_json_params logs a warning (via Rails.logger) when a value is supplied
10
+ # in both layers; stub Rails since the gem loads without it in the suite.
11
+ before { stub_const('Rails', double('Rails', logger: Logger.new(IO::NULL))) }
12
+
13
+ def merge(base, layer)
14
+ instance.send(:_merge_json_params, base, layer)
15
+ end
16
+
17
+ describe 'scalars' do
18
+ it 'prefers the layer value over the base' do
19
+ expect(merge('base', 'layer')).to eq 'layer'
20
+ end
21
+
22
+ it 'falls back to the base when the layer is blank' do
23
+ expect(merge('base', nil)).to eq 'base'
24
+ expect(merge('base', '')).to eq 'base'
25
+ end
26
+ end
27
+
28
+ describe 'hashes' do
29
+ it 'deep-merges, with the layer winning on overlapping leaves' do
30
+ expect(merge({ 'a' => 1, 'b' => 2 }, { 'b' => 3 })).to eq('a' => 1, 'b' => 3)
31
+ end
32
+
33
+ it 'keeps base-only keys when the layer omits them' do
34
+ expect(merge({ 'a' => 1 }, {})).to eq('a' => 1)
35
+ end
36
+
37
+ it 'merges nested hashes recursively' do
38
+ base = { 'outer' => { 'x' => 1, 'y' => 2 } }
39
+ layer = { 'outer' => { 'y' => 9 } }
40
+ expect(merge(base, layer)).to eq('outer' => { 'x' => 1, 'y' => 9 })
41
+ end
42
+ end
43
+
44
+ describe 'arrays' do
45
+ it 'merges element-wise against a parallel array layer' do
46
+ expect(merge([{ 'x' => 1 }, { 'x' => 2 }], [{ 'x' => 9 }, {}]))
47
+ .to eq([{ 'x' => 9 }, { 'x' => 2 }])
48
+ end
49
+
50
+ it 'merges element-wise against an index-keyed hash layer' do
51
+ expect(merge([10, 20], { '0' => 99 })).to eq([99, 20])
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,132 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Miscellany::LocalLruCache do
4
+ subject(:cache) { described_class.new(3) }
5
+
6
+ describe '#[]= and #[]' do
7
+ it 'stores and retrieves values' do
8
+ cache[:a] = 1
9
+ expect(cache[:a]).to eq 1
10
+ end
11
+
12
+ it 'returns nil for missing keys' do
13
+ expect(cache[:missing]).to be_nil
14
+ end
15
+
16
+ it 'returns the assigned value from []=' do
17
+ expect(cache.send(:[]=, :a, 42)).to eq 42
18
+ end
19
+
20
+ it 'evicts the least-recently-used entry once over capacity' do
21
+ cache[:a] = 1
22
+ cache[:b] = 2
23
+ cache[:c] = 3
24
+ cache[:d] = 4 # pushes out :a, the oldest
25
+
26
+ expect(cache[:a]).to be_nil
27
+ expect(cache[:b]).to eq 2
28
+ expect(cache[:d]).to eq 4
29
+ expect(cache.count).to eq 3
30
+ end
31
+
32
+ it 'treats a read as a use, protecting the entry from eviction' do
33
+ cache[:a] = 1
34
+ cache[:b] = 2
35
+ cache[:c] = 3
36
+ cache[:a] # touch :a so :b becomes least-recently-used
37
+ cache[:d] = 4
38
+
39
+ expect(cache[:a]).to eq 1
40
+ expect(cache[:b]).to be_nil
41
+ end
42
+
43
+ it 'treats re-assignment as a use' do
44
+ cache[:a] = 1
45
+ cache[:b] = 2
46
+ cache[:c] = 3
47
+ cache[:a] = 10 # refresh recency of :a
48
+ cache[:d] = 4
49
+
50
+ expect(cache[:a]).to eq 10
51
+ expect(cache[:b]).to be_nil
52
+ end
53
+ end
54
+
55
+ describe '#fetch' do
56
+ it 'yields and stores on a miss' do
57
+ calls = 0
58
+ result = cache.fetch(:a) { calls += 1; 'computed' }
59
+ expect(result).to eq 'computed'
60
+ expect(calls).to eq 1
61
+ end
62
+
63
+ it 'does not yield on a hit' do
64
+ cache[:a] = 'stored'
65
+ calls = 0
66
+ result = cache.fetch(:a) { calls += 1; 'computed' }
67
+ expect(result).to eq 'stored'
68
+ expect(calls).to eq 0
69
+ end
70
+ end
71
+
72
+ describe '#delete' do
73
+ it 'removes an entry' do
74
+ cache[:a] = 1
75
+ expect(cache.delete(:a)).to eq 1
76
+ expect(cache[:a]).to be_nil
77
+ end
78
+ end
79
+
80
+ describe '#clear' do
81
+ it 'empties the cache' do
82
+ cache[:a] = 1
83
+ cache[:b] = 2
84
+ cache.clear
85
+ expect(cache.count).to eq 0
86
+ end
87
+ end
88
+
89
+ describe '#to_a and #each' do
90
+ it 'orders entries most-recently-used first' do
91
+ cache[:a] = 1
92
+ cache[:b] = 2
93
+ expect(cache.to_a).to eq [[:b, 2], [:a, 1]]
94
+ end
95
+
96
+ it 'iterates most-recently-used first' do
97
+ cache[:a] = 1
98
+ cache[:b] = 2
99
+ seen = []
100
+ cache.each { |pair| seen << pair }
101
+ expect(seen).to eq [[:b, 2], [:a, 1]]
102
+ end
103
+ end
104
+
105
+ describe '#max_size=' do
106
+ it 'rejects a size below one' do
107
+ expect { cache.max_size = 0 }.to raise_error(ArgumentError)
108
+ end
109
+
110
+ it 'evicts down to the new size, dropping the oldest entries' do
111
+ cache[:a] = 1
112
+ cache[:b] = 2
113
+ cache[:c] = 3
114
+ cache.max_size = 1
115
+
116
+ expect(cache.count).to eq 1
117
+ expect(cache[:c]).to eq 3 # newest survives
118
+ expect(cache[:a]).to be_nil
119
+ end
120
+
121
+ it 'keeps every entry when only shrinking by one' do
122
+ cache[:a] = 1
123
+ cache[:b] = 2
124
+ cache[:c] = 3
125
+ cache.max_size = 2
126
+
127
+ expect(cache.count).to eq 2
128
+ expect(cache[:b]).to eq 2
129
+ expect(cache[:c]).to eq 3
130
+ end
131
+ end
132
+ end
@@ -113,6 +113,82 @@ describe Miscellany::ParamValidator do
113
113
  end
114
114
  end
115
115
 
116
+ describe 'an unsupported type' do
117
+ # A type with no coercion rule is a mistake in the validator definition,
118
+ # not bad user input, so it must fail loudly instead of silently nulling.
119
+ it 'raises, naming the type and the key' do
120
+ expect do
121
+ Miscellany::ParamValidator.check({ value: 'x' }) { p :value, type: Symbol }
122
+ end.to raise_error(Miscellany::ParamValidator::UnsupportedTypeError, /Symbol.*:value/)
123
+ end
124
+
125
+ it 'leaves the value untouched' do
126
+ params = { value: 'x' }
127
+ begin
128
+ Miscellany::ParamValidator.check(params) { p :value, type: Symbol }
129
+ rescue Miscellany::ParamValidator::UnsupportedTypeError
130
+ # asserted separately above
131
+ end
132
+ expect(params[:value]).to eq 'x'
133
+ end
134
+
135
+ it 'accepts a value that is already an instance of that type' do
136
+ expect_coercion(:already_a_symbol, Symbol, :already_a_symbol)
137
+ end
138
+ end
139
+
140
+ describe 'given positionally' do
141
+ def assert_positional(raw, expectation, &blk)
142
+ result = Miscellany::ParamValidator.assert({ value: raw }, handle: ->(_v) { raise 'Invalid' }, &blk)
143
+ expect(result[:value]).to eq expectation
144
+ end
145
+
146
+ it 'coerces just as the keyword form does' do
147
+ assert_positional('5', 5) { p :value, Integer }
148
+ end
149
+
150
+ it 'accepts a list of types' do
151
+ assert_positional('5', 5) { p :value, [Integer, String] }
152
+ end
153
+
154
+ it 'combines with flags in any order' do
155
+ assert_positional('5', 5) { p :value, :present, Integer }
156
+ assert_positional('5', 5) { p :value, Integer, :present }
157
+ end
158
+
159
+ it 'still rejects genuinely unrecognized arguments' do
160
+ expect do
161
+ Miscellany::ParamValidator.check({ value: '5' }) { p :value, Integer, String }
162
+ end.to raise_error(ArgumentError, /positional/)
163
+ end
164
+ end
165
+
166
+ describe 'BigDecimal' do
167
+ it 'coerces a decimal string' do
168
+ expect_coercion('12.34', BigDecimal, BigDecimal('12.34'))
169
+ end
170
+
171
+ it 'strips currency formatting' do
172
+ expect_coercion('$1,234.56', BigDecimal, BigDecimal('1234.56'))
173
+ end
174
+
175
+ # Routing through Float would silently truncate here, which defeats the
176
+ # point of asking for a BigDecimal.
177
+ it 'keeps digits beyond what a Float can hold' do
178
+ expect_coercion('1.234567890123456789', BigDecimal, BigDecimal('1.234567890123456789'))
179
+ end
180
+
181
+ it 'coerces a numeric value' do
182
+ expect_coercion(5, BigDecimal, BigDecimal(5))
183
+ end
184
+
185
+ it 'reports a validation error for a non-numeric string' do
186
+ expect_invalid do
187
+ p :some_string, type: BigDecimal
188
+ end
189
+ end
190
+ end
191
+
116
192
  describe ':bool' do
117
193
  it 'transforms booleans' do
118
194
  expect_coercion('t', :bool, true)
@@ -172,6 +248,27 @@ describe Miscellany::ParamValidator do
172
248
  p :some_string, pattern: /^Steve$/
173
249
  end
174
250
  end
251
+
252
+ # A matching pattern must not short-circuit the rest of the parameter.
253
+ it 'still applies the other checks when the pattern matches' do
254
+ expect_invalid do
255
+ p :some_string, pattern: /^Rob/, in: %w[Steve]
256
+ end
257
+ end
258
+
259
+ it 'reports the failing key when an earlier key matched' do
260
+ result = Miscellany::ParamValidator.check({ matches: 'Robert', fails: 'Steve' }) do
261
+ p %i[matches fails], pattern: /^Rob/
262
+ end
263
+ expect(result.serialize).to eq('fails' => ['must match pattern: /^Rob/'])
264
+ end
265
+
266
+ it 'does not discard errors collected for earlier keys' do
267
+ result = Miscellany::ParamValidator.check({ fails: 'Steve', matches: 'Robert' }) do
268
+ p %i[fails matches], pattern: /^Rob/
269
+ end
270
+ expect(result.serialize).to eq('fails' => ['must match pattern: /^Rob/'])
271
+ end
175
272
  end
176
273
 
177
274
  describe 'items:' do
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+ require 'open3'
3
+
4
+ # These run in a fresh process on purpose: by the time the rest of the suite
5
+ # loads, ActiveRecord is already in memory, which is exactly what masks a missing
6
+ # require in lib/miscellany.rb.
7
+ RSpec.describe 'requiring miscellany' do
8
+ LIB_PATH = File.expand_path('../../lib', __dir__)
9
+
10
+ # stdout and stderr are kept apart deliberately: Ruby writes unrelated
11
+ # deprecation warnings to stderr, which would otherwise corrupt the result.
12
+ def ruby(source)
13
+ out, err, status = Open3.capture3(
14
+ Gem.ruby, '-rbundler/setup', "-I#{LIB_PATH}", '-e', source
15
+ )
16
+ raise "subprocess exited #{status.exitstatus}:\n#{err}" unless status.success?
17
+
18
+ out
19
+ end
20
+
21
+ it 'succeeds without ActiveRecord having been loaded first' do
22
+ expect(ruby('require "miscellany"; print "OK"')).to eq 'OK'
23
+ end
24
+
25
+ it 'installs the ActiveRecord extensions' do
26
+ expect(ruby('require "miscellany"; print ActiveRecord::Base.respond_to?(:prefetch)')).to eq 'true'
27
+ expect(ruby('require "miscellany"; print ActiveRecord::Base.respond_to?(:with_computed)')).to eq 'true'
28
+ end
29
+
30
+ it 'exposes the plain-Ruby helpers' do
31
+ expect(ruby('require "miscellany"; print Miscellany::LocalLruCache.new(2)[:missing].inspect')).to eq 'nil'
32
+ expect(ruby('require "miscellany"; print Miscellany::ParamValidator.check({}) { }.serialize.inspect')).to eq 'nil'
33
+ end
34
+ end
@@ -109,10 +109,10 @@ describe Miscellany::SlicedResponse do
109
109
  include_examples "sortable"
110
110
 
111
111
  it "uses the ComplexQuery sort_parser if valid_sorts is not given" do
112
- r = subject.sliced_json(source, { **slice_params, sort: "created_at" }, {})
112
+ r = subject.sliced_json(source, { **slice_params, sort: "created_at" })
113
113
  expect(r[:sort]).to eql nil
114
114
 
115
- r = subject.sliced_json(source, { **slice_params, sort: "title" }, {})
115
+ r = subject.sliced_json(source, { **slice_params, sort: "title" })
116
116
  expect(r[:sort]).to eql "title ASC"
117
117
  end
118
118
 
@@ -122,6 +122,35 @@ describe Miscellany::SlicedResponse do
122
122
  end
123
123
  end
124
124
 
125
+ context "with out-of-range paging" do
126
+ let(:source) { ARModel.all }
127
+
128
+ # A page_size of 0 used to divide by zero while computing page_count.
129
+ it "rejects a page_size below 1" do
130
+ [0, -5].each do |bad|
131
+ expect do
132
+ subject.sliced_json(source, { page_size: bad }, **slice_config)
133
+ end.to raise_error(Miscellany::HttpErrorHandling::HttpError, /page_size must be at least 1/)
134
+ end
135
+ end
136
+
137
+ # A page below 1 used to reach SQL as a negative OFFSET. ComplexQuery#page
138
+ # already clamps, so the slice does too.
139
+ it "clamps a page below 1 to the first page" do
140
+ [0, -1].each do |bad|
141
+ r = subject.sliced_json(source, { page: bad }, **slice_config)
142
+ expect(r[:page]).to eql 1
143
+ expect(r[:slice_start]).to eql 0
144
+ expect_items(r, ARModel.all.limit(3))
145
+ end
146
+ end
147
+
148
+ it "still reports page_count for a valid page_size" do
149
+ r = subject.sliced_json(source, { page_size: 4 }, **slice_config)
150
+ expect(r[:page_count]).to eql 3
151
+ end
152
+ end
153
+
125
154
  it "enforces allow_all" do
126
155
  expect do
127
156
  subject.sliced_json(ARModel.all, { page: "all" }, **slice_config)
@@ -122,6 +122,39 @@ describe Miscellany::SortLang do
122
122
  {:column=>"created_at", :force_order=>false, :key=>"created_at"},
123
123
  ]
124
124
  end
125
+
126
+ # SlicedResponse surfaces these straight to API clients, so the message has
127
+ # to read as a sentence rather than a stringified Hash.
128
+ context "with ignore_errors: false" do
129
+ it "raises naming the unparseable sort" do
130
+ expect { subject.parse("not a sort!", ignore_errors: false) }
131
+ .to raise_error(Miscellany::SortLang::Parser::SortParsingError, /could not parse.*"not a sort!"/i)
132
+ end
133
+
134
+ it "raises naming the unknown column" do
135
+ expect { subject.parse("updated_at", ignore_errors: false) }
136
+ .to raise_error(Miscellany::SortLang::Parser::SortParsingError, /unknown sort column.*"updated_at"/i)
137
+ end
138
+
139
+ it "does not raise for a valid sort" do
140
+ expect { subject.parse("title DESC", ignore_errors: false) }.not_to raise_error
141
+ end
142
+ end
143
+ end
144
+
145
+ describe "#valid?" do
146
+ it "is true for a known column" do
147
+ expect(subject.valid?("title")).to be true
148
+ expect(subject.valid?("created_at DESC")).to be true
149
+ end
150
+
151
+ it "is false for an unknown column" do
152
+ expect(subject.valid?("updated_at")).to be false
153
+ end
154
+
155
+ it "is false for an unparseable sort" do
156
+ expect(subject.valid?("not a sort!")).to be false
157
+ end
125
158
  end
126
159
  end
127
160
  end