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.
- 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/json_uploads.rb +17 -15
- 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
|
@@ -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
|