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,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
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Miscellany::BatchProcessor do
|
|
4
|
+
it 'processes a batch as soon as it reaches the batch size' do
|
|
5
|
+
batches = []
|
|
6
|
+
processor = described_class.new(of: 2) { |batch| batches << batch.dup }
|
|
7
|
+
|
|
8
|
+
processor << 1
|
|
9
|
+
expect(batches).to be_empty # not yet full
|
|
10
|
+
|
|
11
|
+
processor << 2
|
|
12
|
+
expect(batches).to eq [[1, 2]] # flushed at size 2
|
|
13
|
+
|
|
14
|
+
processor << 3
|
|
15
|
+
expect(batches).to eq [[1, 2]] # partial batch held back
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe '#flush' do
|
|
19
|
+
it 'processes the remaining partial batch' do
|
|
20
|
+
batches = []
|
|
21
|
+
processor = described_class.new(of: 5) { |batch| batches << batch.dup }
|
|
22
|
+
|
|
23
|
+
processor << 1
|
|
24
|
+
processor << 2
|
|
25
|
+
processor.flush
|
|
26
|
+
|
|
27
|
+
expect(batches).to eq [[1, 2]]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'does nothing when there is nothing buffered' do
|
|
31
|
+
calls = 0
|
|
32
|
+
processor = described_class.new(of: 5) { |_batch| calls += 1 }
|
|
33
|
+
processor.flush
|
|
34
|
+
expect(calls).to eq 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'does not re-run on a second flush' do
|
|
38
|
+
batches = []
|
|
39
|
+
processor = described_class.new(of: 5) { |batch| batches << batch.dup }
|
|
40
|
+
processor << 1
|
|
41
|
+
processor.flush
|
|
42
|
+
processor.flush
|
|
43
|
+
expect(batches).to eq [[1]]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe '#add_all' do
|
|
48
|
+
it 'enqueues each item, flushing full batches along the way' do
|
|
49
|
+
batches = []
|
|
50
|
+
processor = described_class.new(of: 2) { |batch| batches << batch.dup }
|
|
51
|
+
processor.add_all([1, 2, 3, 4, 5])
|
|
52
|
+
expect(batches).to eq [[1, 2], [3, 4]]
|
|
53
|
+
processor.flush
|
|
54
|
+
expect(batches).to eq [[1, 2], [3, 4], [5]]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe 'ensure_once' do
|
|
59
|
+
it 'invokes the block once on flush even with no items' do
|
|
60
|
+
batches = []
|
|
61
|
+
processor = described_class.new(of: 5, ensure_once: true) { |batch| batches << batch.dup }
|
|
62
|
+
processor.flush
|
|
63
|
+
expect(batches).to eq [[]]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'does not invoke the block an extra time when items were already processed' do
|
|
67
|
+
batches = []
|
|
68
|
+
processor = described_class.new(of: 2, ensure_once: true) { |batch| batches << batch.dup }
|
|
69
|
+
processor << 1
|
|
70
|
+
processor << 2 # full batch flushes here
|
|
71
|
+
processor.flush # nothing buffered, already flushed once
|
|
72
|
+
expect(batches).to eq [[1, 2]]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Miscellany::BatchedDestruction do
|
|
4
|
+
with_model :Widget do
|
|
5
|
+
table do |t|
|
|
6
|
+
t.string :name
|
|
7
|
+
t.boolean :archived, default: false
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
model do
|
|
11
|
+
include Miscellany::BatchedDestruction
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
before do
|
|
16
|
+
5.times { |i| Widget.create!(name: "w#{i}") }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
describe '.bulk_destroy' do
|
|
20
|
+
it 'deletes every matching record' do
|
|
21
|
+
expect { Widget.bulk_destroy }.to change(Widget, :count).from(5).to(0)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'works against a scoped relation, deleting only the scope' do
|
|
25
|
+
Widget.where(name: 'w0').update_all(archived: true)
|
|
26
|
+
expect { Widget.where(archived: true).bulk_destroy }
|
|
27
|
+
.to change(Widget, :count).from(5).to(4)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'runs the bulk_destroy callbacks around the deletion' do
|
|
31
|
+
ran = []
|
|
32
|
+
Widget.set_callback(:bulk_destroy, :before) { ran << :before }
|
|
33
|
+
Widget.set_callback(:bulk_destroy, :after) { ran << :after }
|
|
34
|
+
|
|
35
|
+
Widget.bulk_destroy
|
|
36
|
+
expect(ran).to eq [:before, :after]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'runs the per-batch callbacks' do
|
|
40
|
+
seen_batches = 0
|
|
41
|
+
Widget.set_callback(:destroy_batch, :before) { seen_batches += 1 }
|
|
42
|
+
|
|
43
|
+
Widget.bulk_destroy
|
|
44
|
+
expect(seen_batches).to be >= 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '.destroy_bulk_batch override (soft deletion)' do
|
|
49
|
+
it 'lets a model swap hard deletion for a custom strategy' do
|
|
50
|
+
def Widget.destroy_bulk_batch(batch, _options)
|
|
51
|
+
where(id: batch.map(&:id)).update_all(archived: true)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
expect { Widget.bulk_destroy }.not_to change(Widget, :count)
|
|
55
|
+
expect(Widget.where(archived: false)).to be_empty
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe '#destroy' do
|
|
60
|
+
it 'routes a single record through the bulk path by default' do
|
|
61
|
+
widget = Widget.first
|
|
62
|
+
expect { widget.destroy }.to change(Widget, :count).from(5).to(4)
|
|
63
|
+
expect(Widget.exists?(widget.id)).to be false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Miscellany::BatchingCsvProcessor do
|
|
4
|
+
with_model :Widget do
|
|
5
|
+
table do |t|
|
|
6
|
+
t.string :name
|
|
7
|
+
t.string :size
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Concrete processor wiring the abstract hooks to a real model.
|
|
12
|
+
let(:processor_class) do
|
|
13
|
+
klass = Class.new(described_class) do
|
|
14
|
+
attr_reader :logged_errors
|
|
15
|
+
|
|
16
|
+
def initialize(*)
|
|
17
|
+
super
|
|
18
|
+
@logged_errors = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get_row_errors(row)
|
|
22
|
+
row[:name].blank? ? ['name is required'] : []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def log_line_error(message, line_number, **_kwargs)
|
|
26
|
+
@logged_errors << [line_number, message]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find_or_init(row)
|
|
30
|
+
Widget.find_or_initialize_by(name: row[:name])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apply_row_to_model(row, instance)
|
|
34
|
+
raise Miscellany::BatchingCsvProcessor::RowError, 'bad size' if row[:size] == 'bad'
|
|
35
|
+
|
|
36
|
+
instance.size = row[:size]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
klass.const_set(:HEADERS, %i[name size])
|
|
40
|
+
klass
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
let(:csv) { "name,size\nwidget_a,small\nwidget_b,large\n" }
|
|
44
|
+
|
|
45
|
+
def processor(data = csv)
|
|
46
|
+
processor_class.new(data)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe '#process_in_batches' do
|
|
50
|
+
it 'yields the validated rows, tagged with line numbers' do
|
|
51
|
+
seen = []
|
|
52
|
+
processor.process_in_batches { |batch| seen.concat(batch) }
|
|
53
|
+
|
|
54
|
+
expect(seen.map { |r| r[:name] }).to eq %w[widget_a widget_b]
|
|
55
|
+
expect(seen.map { |r| r[:line_number] }).to eq [1, 2]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'skips invalid rows and logs an error for each' do
|
|
59
|
+
proc = processor("name,size\n,small\nwidget_b,large\n")
|
|
60
|
+
seen = []
|
|
61
|
+
proc.process_in_batches { |batch| seen.concat(batch) }
|
|
62
|
+
|
|
63
|
+
expect(seen.map { |r| r[:name] }).to eq %w[widget_b]
|
|
64
|
+
expect(proc.logged_errors).to eq [[1, 'name is required']]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '#build_model_from_row' do
|
|
69
|
+
it 'returns a populated, changed model for a valid row' do
|
|
70
|
+
model = processor.build_model_from_row(name: 'widget_a', size: 'small', line_number: 1)
|
|
71
|
+
expect(model).to be_a(Widget)
|
|
72
|
+
expect(model.size).to eq 'small'
|
|
73
|
+
expect(model).to be_changed
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'logs and swallows a RowError, returning nil' do
|
|
77
|
+
proc = processor
|
|
78
|
+
result = proc.build_model_from_row(name: 'widget_a', size: 'bad', line_number: 4)
|
|
79
|
+
expect(result).to be_nil
|
|
80
|
+
expect(proc.logged_errors).to eq [[4, 'bad size']]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe '#batch_rows_to_models' do
|
|
85
|
+
it 'returns the changed models built from the rows' do
|
|
86
|
+
rows = [
|
|
87
|
+
{ name: 'widget_a', size: 'small', line_number: 1 },
|
|
88
|
+
{ name: 'widget_b', size: 'large', line_number: 2 },
|
|
89
|
+
]
|
|
90
|
+
models = processor.batch_rows_to_models(rows)
|
|
91
|
+
expect(models.map(&:name)).to eq %w[widget_a widget_b]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe '#map_defined_columns' do
|
|
96
|
+
it 'remaps only the keys present in the row' do
|
|
97
|
+
row = { old_name: 'a', old_size: 'b' }
|
|
98
|
+
mapped = processor.map_defined_columns(row, name: :old_name, size: :old_size, missing: :nope)
|
|
99
|
+
expect(mapped).to eq(name: 'a', size: 'b')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe '.headers_match?' do
|
|
104
|
+
it 'is true when every required header is present' do
|
|
105
|
+
expect(processor_class.headers_match?(%i[name size extra])).to be true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'is false when a required header is missing' do
|
|
109
|
+
expect(processor_class.headers_match?(%i[name])).to be false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
describe '.file_matches?' do
|
|
114
|
+
# file_matches? parses the raw header line into strings, so a class using
|
|
115
|
+
# string HEADERS is what the method is designed to compare against.
|
|
116
|
+
let(:string_header_class) do
|
|
117
|
+
klass = Class.new(described_class)
|
|
118
|
+
klass.const_set(:HEADERS, %w[name size])
|
|
119
|
+
klass
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'is true when the header line contains the required columns' do
|
|
123
|
+
file = StringIO.new("name,size\nwidget_a,small\n")
|
|
124
|
+
expect(string_header_class.file_matches?(file)).to be true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'is false when a required column is missing' do
|
|
128
|
+
file = StringIO.new("name\nwidget_a\n")
|
|
129
|
+
expect(string_header_class.file_matches?(file)).to be false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'rewinds the file so it can be read again afterward' do
|
|
133
|
+
file = StringIO.new("name,size\nwidget_a,small\n")
|
|
134
|
+
string_header_class.file_matches?(file)
|
|
135
|
+
expect(file.readline).to eq "name,size\n"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
# Concrete subclass exercising the abstract ComplexQuery template. A named
|
|
4
|
+
# (non-anonymous) class is required: #in_batches derives a temp-table name from
|
|
5
|
+
# self.class.name, and #valid_sorts reads self.class::SORTABLE_COLUMNS.
|
|
6
|
+
class WidgetReportQuery < Miscellany::ComplexQuery
|
|
7
|
+
SORTABLE_COLUMNS = { 'name' => 'name' }.freeze
|
|
8
|
+
|
|
9
|
+
def build_query
|
|
10
|
+
"SELECT id, name FROM #{options[:table]}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build_count_query
|
|
14
|
+
"SELECT COUNT(*) AS count FROM #{options[:table]}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Subclasses are how the protected filter helpers are actually reached, so the
|
|
18
|
+
# specs exercise them the same way.
|
|
19
|
+
def date_filter_for(*args, **kwargs)
|
|
20
|
+
date_filter(*args, **kwargs)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sanitize_sql_for(*args)
|
|
24
|
+
sanitize_sql(*args)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
RSpec.describe Miscellany::ComplexQuery do
|
|
29
|
+
with_model :Widget do
|
|
30
|
+
table { |t| t.string :name }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
let(:table) { Widget.table_name }
|
|
34
|
+
|
|
35
|
+
before do
|
|
36
|
+
%w[apple banana cherry].each { |n| Widget.create!(name: n) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def query(opts = {})
|
|
40
|
+
WidgetReportQuery.new({ table: table }.merge(opts))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe '#count' do
|
|
44
|
+
it 'returns the scalar count from build_count_query' do
|
|
45
|
+
expect(query.count).to eq 3
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe '#page' do
|
|
50
|
+
it 'returns a page of records with pagination metadata' do
|
|
51
|
+
result = query(sort: 'name').page(1, page_size: 2)
|
|
52
|
+
|
|
53
|
+
expect(result[:page]).to eq 1
|
|
54
|
+
expect(result[:total_count]).to eq 3
|
|
55
|
+
expect(result[:page_count]).to eq 2 # ceil(3 / 2)
|
|
56
|
+
expect(result[:page_size]).to eq 2
|
|
57
|
+
expect(result[:records].map { |r| r['name'] }).to eq %w[apple banana]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'returns the second page' do
|
|
61
|
+
result = query(sort: 'name').page(2, page_size: 2)
|
|
62
|
+
expect(result[:records].map { |r| r['name'] }).to eq %w[cherry]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'clamps a page below one up to one' do
|
|
66
|
+
expect(query.page(0, page_size: 2)[:page]).to eq 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'floors a page size below two to ten' do
|
|
70
|
+
expect(query.page(1, page_size: 1)[:page_size]).to eq 10
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'reports the sort string when sorting' do
|
|
74
|
+
expect(query(sort: 'name').page(1)[:sort]).to include('name')
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe '#slice' do
|
|
79
|
+
it 'returns records at the given offset and length, honoring raw_sort' do
|
|
80
|
+
records = query.slice(1, 1, raw_sort: 'name ASC')
|
|
81
|
+
expect(records.map { |r| r[:name] }).to eq %w[banana]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'exposes records with indifferent access' do
|
|
85
|
+
record = query.slice(0, 1, raw_sort: 'name ASC').first
|
|
86
|
+
expect(record[:name]).to eq 'apple'
|
|
87
|
+
expect(record['name']).to eq 'apple'
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe '#sql' do
|
|
92
|
+
it 'appends ORDER BY when a sort is configured' do
|
|
93
|
+
expect(query(sort: 'name').sql).to match(/ORDER BY/i)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'omits ORDER BY when no sort is configured' do
|
|
97
|
+
expect(query.sql).not_to match(/ORDER BY/i)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe '#in_batches' do
|
|
102
|
+
it 'yields every record across batches and cleans up the temp table' do
|
|
103
|
+
seen = []
|
|
104
|
+
query.in_batches(of: 2) { |batch| seen.concat(batch.map { |r| r[:name] }) }
|
|
105
|
+
expect(seen.sort).to eq %w[apple banana cherry]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'can be run repeatedly (temp table is dropped each time)' do
|
|
109
|
+
expect { 2.times { query.in_batches(of: 2) { |_b| } } }.not_to raise_error
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Rails' in_batches never hands the block an empty batch; neither should this.
|
|
113
|
+
it 'does not yield a trailing empty batch' do
|
|
114
|
+
sizes = []
|
|
115
|
+
query.in_batches(of: 2) { |batch| sizes << batch.size }
|
|
116
|
+
expect(sizes).to eq [2, 1]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'does not yield at all when there are no records' do
|
|
120
|
+
Widget.delete_all
|
|
121
|
+
sizes = []
|
|
122
|
+
query.in_batches(of: 2) { |batch| sizes << batch.size }
|
|
123
|
+
expect(sizes).to eq []
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe '#find_each' do
|
|
128
|
+
it 'yields each record as a hash' do
|
|
129
|
+
names = []
|
|
130
|
+
query.find_each(batch_size: 2) { |r| names << r[:name] }
|
|
131
|
+
expect(names.sort).to eq %w[apple banana cherry]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
describe '#valid_sort?' do
|
|
136
|
+
it 'returns false for a blank sort' do
|
|
137
|
+
expect(query.valid_sort?(nil)).to be false
|
|
138
|
+
expect(query.valid_sort?('')).to be false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'returns true for a sort over a sortable column' do
|
|
142
|
+
expect(query.valid_sort?('name')).to be true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'returns false for a sort over an unknown column' do
|
|
146
|
+
expect(query.valid_sort?('not_a_column')).to be false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
describe '#join_filters (protected helper)' do
|
|
151
|
+
subject(:q) { query }
|
|
152
|
+
|
|
153
|
+
it 'ANDs present clauses together, wrapping each in parentheses' do
|
|
154
|
+
expect(q.send(:join_filters, 'a = 1', 'b = 2')).to eq '(a = 1) AND (b = 2)'
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'drops blank clauses' do
|
|
158
|
+
expect(q.send(:join_filters, 'a = 1', nil, '', false)).to eq '(a = 1)'
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'falls back to 1=1 when nothing is present' do
|
|
162
|
+
expect(q.send(:join_filters, nil, false)).to eq '1=1'
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe '#date_filter' do
|
|
167
|
+
let(:dated) { query(filters: { 'created_start' => '2024-01-01', 'created_end' => '2024-01-31' }) }
|
|
168
|
+
|
|
169
|
+
# The Symbol/String shorthand looks the range up in `filters` by prefix.
|
|
170
|
+
it 'resolves a Symbol key against filters' do
|
|
171
|
+
expect(dated.date_filter_for('created_at', :created))
|
|
172
|
+
.to match(/created_at BETWEEN '2024-01-01T00:00:00.*' AND '2024-01-31T23:59:59/)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'resolves a String key against filters' do
|
|
176
|
+
expect(dated.date_filter_for('created_at', 'created'))
|
|
177
|
+
.to match(/created_at BETWEEN '2024-01-01T00:00:00.*' AND '2024-01-31T23:59:59/)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it 'accepts an explicit range, expanding it to whole days' do
|
|
181
|
+
expect(query.date_filter_for('created_at', ['2024-01-01', '2024-01-31']))
|
|
182
|
+
.to match(/created_at BETWEEN '2024-01-01T00:00:00.*' AND '2024-01-31T23:59:59/)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'builds a one-sided clause when the filter only has a start' do
|
|
186
|
+
q = query(filters: { 'created_start' => '2024-01-01' })
|
|
187
|
+
expect(q.date_filter_for('created_at', :created)).to match(/created_at >= '2024-01-01T00:00:00/)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
describe '#sanitize_sql' do
|
|
192
|
+
it 'quotes a bind value' do
|
|
193
|
+
expect(query.sanitize_sql_for('name = ?', "O'Brien")).to eq %(name = 'O''Brien')
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'expands an IN list' do
|
|
197
|
+
expect(query.sanitize_sql_for('name IN (?)', %w[apple banana]))
|
|
198
|
+
.to eq "name IN ('apple','banana')"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
describe '#datetime_filter (protected helper)' do
|
|
203
|
+
subject(:q) { query }
|
|
204
|
+
|
|
205
|
+
it 'builds a BETWEEN clause when both bounds are present' do
|
|
206
|
+
sql = q.send(:datetime_filter, 'created_at', ['2024-01-01', '2024-01-31'])
|
|
207
|
+
expect(sql).to match(/created_at BETWEEN '2024-01-01.*' AND '2024-01-31/)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'builds a lower-bound clause when only the start is present' do
|
|
211
|
+
sql = q.send(:datetime_filter, 'created_at', ['2024-01-01', nil])
|
|
212
|
+
expect(sql).to match(/created_at >= '2024-01-01/)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it 'builds an upper-bound clause when only the end is present' do
|
|
216
|
+
sql = q.send(:datetime_filter, 'created_at', [nil, '2024-01-31'])
|
|
217
|
+
expect(sql).to match(/created_at <= '2024-01-31/)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -49,7 +49,8 @@ describe Miscellany::ComputedColumns do
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
it 'generally works' do
|
|
52
|
-
ActiveRecord
|
|
52
|
+
# Debug aid only; removed from ActiveRecord in Rails 7.1.
|
|
53
|
+
ActiveRecord::Base.verbose_query_logs = true if ActiveRecord::Base.respond_to?(:verbose_query_logs=)
|
|
53
54
|
posts = Post.with_computed(:favorite_comments_count)
|
|
54
55
|
expect(posts.except(:select).count).to eq 3
|
|
55
56
|
expect(posts[0].favorite_comments_count).to eq 1
|