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,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::Base.verbose_query_logs = true
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