miscellany 0.1.11 → 0.1.14
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/app/views/miscellany/_slice.json.jbuilder +5 -0
- data/lib/miscellany/active_record/arbitrary_prefetch.rb +30 -13
- data/lib/miscellany/active_record/batch_matcher.rb +3 -7
- data/lib/miscellany/active_record/batched_destruction.rb +1 -1
- data/lib/miscellany/active_record/complex_query.rb +141 -0
- data/lib/miscellany/active_record/custom_preloaders.rb +1 -1
- data/lib/miscellany/controller/sliced_response.rb +32 -106
- data/lib/miscellany/jbuilder_partial_block.rb +26 -0
- data/lib/miscellany/sort_lang.rb +121 -0
- data/lib/miscellany/version.rb +1 -1
- data/miscellany.gemspec +2 -1
- data/spec/miscellany/arbitrary_prefetch_spec.rb +16 -0
- data/spec/miscellany/goldiload_value_spec.rb +53 -0
- data/spec/miscellany/sliced_response_spec.rb +156 -0
- data/spec/spec_helper.rb +4 -1
- metadata +26 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 735168167aa19b15e9b56db6a20025dc7374526319744526d08238577caf4983
|
4
|
+
data.tar.gz: c4df4a5c364cb04632ab017f2e7393d9f8dc5f5228ba7da41303d8a7595606ea
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 94f572010617e6295e19d0167c05cb59b683496f87502a83bb53f91c3303fb11cfb65a7c7f0a2c85c254896700c22bc9d441e02ebb09a93741cb4867e13596a3
|
7
|
+
data.tar.gz: 8064920b660a82326a0172ac4828c95bf072b65ec3e9e7fe87dcb842ace9ec4a11072e0095eb73e4247c8d83e5e412bd4bd62947b35edefb9b82576b1a5e9423
|
@@ -80,7 +80,7 @@ module Miscellany
|
|
80
80
|
pfc = PrefetcherContext.new(model, opts)
|
81
81
|
pfc.link_models(records)
|
82
82
|
|
83
|
-
unless defined?(Goldiloader)
|
83
|
+
unless defined?(Goldiloader) && Goldiloader.enabled?
|
84
84
|
if PRE_RAILS_6_2
|
85
85
|
::ActiveRecord::Associations::Preloader.new.preload(records, [opts[:attribute]])
|
86
86
|
else
|
@@ -139,18 +139,31 @@ module Miscellany
|
|
139
139
|
end
|
140
140
|
|
141
141
|
module ActiveRecordPreloaderPatch
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
142
|
+
def grouped_records(association, records, polymorphic_parent)
|
143
|
+
h = {}
|
144
|
+
records.each do |record|
|
145
|
+
next unless record
|
146
|
+
reflection = record.class._reflect_on_association(association)
|
147
|
+
reflection ||= record.association(association)&.reflection rescue nil
|
148
|
+
next if polymorphic_parent && !reflection || !record.association(association).klass
|
149
|
+
(h[reflection] ||= []) << record
|
150
|
+
end
|
151
|
+
h
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
module ActiveRecordPreloaderBranchPatch
|
156
|
+
def grouped_records
|
157
|
+
h = {}
|
158
|
+
polymorphic_parent = !root? && parent.polymorphic?
|
159
|
+
source_records.each do |record|
|
160
|
+
next unless record
|
161
|
+
reflection = record.class._reflect_on_association(association)
|
162
|
+
reflection ||= record.association(association)&.reflection rescue nil
|
163
|
+
next if polymorphic_parent && !reflection || !record.association(association).klass
|
164
|
+
(h[reflection] ||= []) << record
|
153
165
|
end
|
166
|
+
h
|
154
167
|
end
|
155
168
|
end
|
156
169
|
|
@@ -167,7 +180,11 @@ module Miscellany
|
|
167
180
|
::ActiveRecord::Relation.prepend(ActiveRecordRelationPatch)
|
168
181
|
::ActiveRecord::Relation::Merger.prepend(ActiveRecordMergerPatch)
|
169
182
|
|
170
|
-
::
|
183
|
+
if ACTIVE_RECORD_VERSION >= ::Gem::Version.new('7.0.0')
|
184
|
+
::ActiveRecord::Associations::Preloader::Branch.prepend(ActiveRecordPreloaderBranchPatch)
|
185
|
+
elsif ACTIVE_RECORD_VERSION >= ::Gem::Version.new('6.0.0')
|
186
|
+
::ActiveRecord::Associations::Preloader.prepend(ActiveRecordPreloaderPatch)
|
187
|
+
end
|
171
188
|
|
172
189
|
::ActiveRecord::Reflection::AssociationReflection.prepend(ActiveRecordReflectionPatch)
|
173
190
|
|
@@ -88,7 +88,6 @@ module Miscellany
|
|
88
88
|
end
|
89
89
|
end
|
90
90
|
|
91
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
92
91
|
def resolve_row_value(row, resolver)
|
93
92
|
found_values = []
|
94
93
|
@columns.each do |c|
|
@@ -103,18 +102,16 @@ module Miscellany
|
|
103
102
|
end
|
104
103
|
|
105
104
|
if @options[:validate_all] && found_values.uniq.count > 1
|
106
|
-
raise ActiveRecord::RecordNotFound, "multiple of [#{@columns.pluck(2).join(', ')}] were supplied, but resolved to different objects"
|
105
|
+
raise ActiveRecord::RecordNotFound, "multiple of [#{@columns.pluck(2).join(', ')}] were supplied, but resolved to different objects"
|
107
106
|
end
|
108
107
|
|
109
108
|
found_values[0]
|
110
109
|
end
|
111
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
112
110
|
|
113
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
114
111
|
def get_column_map(column, row, via_column: nil)
|
115
112
|
base_query = get_base_query(row)
|
116
113
|
clazz = as_class(base_query)
|
117
|
-
raise ActiveRecord::RecordNotFound, "invalid #{@options[:polymorphic_on]}: #{row[@options[:polymorphic_on]]}" if clazz.nil?
|
114
|
+
raise ActiveRecord::RecordNotFound, "invalid #{@options[:polymorphic_on]}: #{row[@options[:polymorphic_on]]}" if clazz.nil?
|
118
115
|
|
119
116
|
load_column(clazz, column, via_column) do
|
120
117
|
relevant_rows = rows
|
@@ -128,10 +125,9 @@ module Miscellany
|
|
128
125
|
loaded_hash = load_column_data(column, base_query, row_keys)
|
129
126
|
|
130
127
|
# In :lazy mode, the corresponding primary_column data is loaded with each column
|
131
|
-
load_column_data(primary_column, base_query, loaded_hash.values) if @options[:mode] == :lazy && column != @primary_column
|
128
|
+
load_column_data(primary_column, base_query, loaded_hash.values) if @options[:mode] == :lazy && column != @primary_column
|
132
129
|
end
|
133
130
|
end
|
134
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
135
131
|
|
136
132
|
def load_column_data(column, base_query, keys)
|
137
133
|
data = if column == @primary_column
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module Miscellany
|
2
|
+
class ComplexQuery
|
3
|
+
attr_reader :options
|
4
|
+
|
5
|
+
def initialize(options)
|
6
|
+
@options = options.with_indifferent_access
|
7
|
+
end
|
8
|
+
|
9
|
+
def count
|
10
|
+
res = ActiveRecord::Base.connection.execute(build_count_query)
|
11
|
+
res[0].to_h.values[0]
|
12
|
+
end
|
13
|
+
|
14
|
+
def page(page, page_size: 40)
|
15
|
+
total_items = count
|
16
|
+
|
17
|
+
page = page.to_i || 1
|
18
|
+
page = 1 if page < 1
|
19
|
+
page_size = page_size.to_i
|
20
|
+
page_size = 10 if page_size < 2
|
21
|
+
offset = (page - 1) * page_size
|
22
|
+
|
23
|
+
psql = sql
|
24
|
+
psql += " LIMIT #{page_size} OFFSET #{offset}"
|
25
|
+
|
26
|
+
records = ActiveRecord::Base.connection.exec_query(psql).to_a
|
27
|
+
augment_batch(records)
|
28
|
+
|
29
|
+
{
|
30
|
+
page: page,
|
31
|
+
total_count: total_items,
|
32
|
+
page_count: (total_items.to_f / page_size).ceil,
|
33
|
+
page_size: page_size,
|
34
|
+
sort: parsed_sort&.map do |s|
|
35
|
+
"#{s[:key] || s[:column]} #{s[:order] || 'ASC'}"
|
36
|
+
end&.join(', '),
|
37
|
+
records: records,
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def slice(start, length, raw_sort: nil)
|
42
|
+
psql = build_query
|
43
|
+
if raw_sort.present?
|
44
|
+
psql += " ORDER BY #{raw_sort}"
|
45
|
+
elsif sort_sql.present?
|
46
|
+
psql += " ORDER BY #{sort_sql}"
|
47
|
+
end
|
48
|
+
psql += " LIMIT #{length} OFFSET #{start}"
|
49
|
+
records = ActiveRecord::Base.connection.exec_query(psql).to_a
|
50
|
+
augment_batch(records)
|
51
|
+
records
|
52
|
+
end
|
53
|
+
|
54
|
+
def in_batches(of: 1000)
|
55
|
+
conn = ActiveRecord::Base.connection
|
56
|
+
tbl = "#{self.class.name.split('::').last.underscore}_#{SecureRandom.hex[0..10]}"
|
57
|
+
|
58
|
+
conn.execute("CREATE TEMP TABLE #{tbl} AS (#{sql})")
|
59
|
+
|
60
|
+
offset = 0
|
61
|
+
loop do
|
62
|
+
batch = ActiveRecord::Base.connection.exec_query(
|
63
|
+
"SELECT * FROM #{tbl} LIMIT #{batch_size} OFFSET #{offset}",
|
64
|
+
)
|
65
|
+
augment_batch(batch)
|
66
|
+
yield batch
|
67
|
+
offset += batch_size
|
68
|
+
break if batch.empty?
|
69
|
+
end
|
70
|
+
ensure
|
71
|
+
conn.execute("DROP TABLE IF EXISTS #{tbl}")
|
72
|
+
end
|
73
|
+
|
74
|
+
def find_each(batch_size: 1000, &block)
|
75
|
+
in_batches(of: batch_size) do |batch|
|
76
|
+
batch.each(&block)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def sql
|
81
|
+
sql = build_query
|
82
|
+
sql += " ORDER BY #{sort_sql}" if sort_sql.present?
|
83
|
+
sql
|
84
|
+
end
|
85
|
+
|
86
|
+
def valid_sort?(sort)
|
87
|
+
sort_parser.valid?(sort)
|
88
|
+
return false unless sort.present?
|
89
|
+
end
|
90
|
+
|
91
|
+
protected
|
92
|
+
|
93
|
+
def augment_batch(records); end
|
94
|
+
|
95
|
+
def build_query; end
|
96
|
+
|
97
|
+
def build_count_query; end
|
98
|
+
|
99
|
+
def sort_sql
|
100
|
+
return options[:raw_sort] if options[:raw_sort].present? && !options[:raw_sort].is_a?(ActionController::Parameters)
|
101
|
+
return nil unless options[:sort].present?
|
102
|
+
|
103
|
+
Miscellany::SortLang.sqlize(parsed_sort)
|
104
|
+
end
|
105
|
+
|
106
|
+
def parsed_sort
|
107
|
+
return nil if options[:raw_sort].present?
|
108
|
+
@parsed_sort ||= sort_parser.parse(options[:sort])
|
109
|
+
end
|
110
|
+
|
111
|
+
def valid_sorts
|
112
|
+
return self.class::SORTABLE_COLUMNS.with_indifferent_access if defined?(self.class::SORTABLE_COLUMNS)
|
113
|
+
end
|
114
|
+
|
115
|
+
def sort_parser
|
116
|
+
@sort_parser ||= Miscellany::SortLang::Parser.new(valid_sorts || {})
|
117
|
+
end
|
118
|
+
|
119
|
+
def sanitize_sql(*args)
|
120
|
+
ApplicationRecord.sanitize_sql(args)
|
121
|
+
end
|
122
|
+
|
123
|
+
def filters
|
124
|
+
options[:filters] || {}
|
125
|
+
end
|
126
|
+
|
127
|
+
def join_filters(*filters)
|
128
|
+
filters.flatten.select(&:present?).map { |q| "(#{q})" }.join(' AND ').presence || '1=1'
|
129
|
+
end
|
130
|
+
|
131
|
+
def date_filter(column, key)
|
132
|
+
if filters["#{key}_end"].present? && filters["#{key}_start"].present?
|
133
|
+
"#{column} BETWEEN '#{DateTime.parse(filters["#{key}_start"]).beginning_of_day}' AND '#{DateTime.parse(filters["#{key}_end"]).end_of_day}'"
|
134
|
+
elsif filters["#{key}_start"].present?
|
135
|
+
"#{column} >= '#{DateTime.parse(filters["#{key}_start"]).beginning_of_day}'"
|
136
|
+
elsif filters["#{key}_end"].present?
|
137
|
+
"#{column} <= '#{DateTime.parse(filters["#{key}_end"]).end_of_day}'"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -6,11 +6,11 @@ module Miscellany
|
|
6
6
|
|
7
7
|
include HttpErrorHandling
|
8
8
|
|
9
|
+
# Deprecated
|
9
10
|
def slice_results(queryset, **kwargs)
|
10
11
|
@sliced_data = sliced_json(queryset, **kwargs) { |x| x }
|
11
12
|
end
|
12
13
|
|
13
|
-
# rubocop:disable Metrics/ParameterLists
|
14
14
|
def sliced_json(
|
15
15
|
queryset, slice_params = params,
|
16
16
|
max_size: 50, default_size: 25, allow_all: false,
|
@@ -19,7 +19,12 @@ module Miscellany
|
|
19
19
|
)
|
20
20
|
valid_sorts ||= queryset.column_names if queryset.respond_to?(:column_names)
|
21
21
|
valid_sorts ||= []
|
22
|
-
|
22
|
+
|
23
|
+
if !valid_sorts.present? && defined?(Miscellany::ComplexQuery) && queryset.is_a?(Miscellany::ComplexQuery)
|
24
|
+
sort_parser = queryset.send(:sort_parser)
|
25
|
+
else
|
26
|
+
sort_parser = Miscellany::SortLang::Parser.new(valid_sorts, default: default_sort)
|
27
|
+
end
|
23
28
|
|
24
29
|
slice = Slice.build(
|
25
30
|
queryset, slice_params,
|
@@ -28,18 +33,22 @@ module Miscellany
|
|
28
33
|
max_size: max_size,
|
29
34
|
default_size: default_size,
|
30
35
|
allow_all: allow_all,
|
31
|
-
|
36
|
+
sort_parser: sort_parser,
|
32
37
|
)
|
33
38
|
|
34
39
|
slice.render_json
|
35
40
|
end
|
36
|
-
# rubocop:enable Metrics/ParameterLists
|
37
41
|
|
42
|
+
# Format the given data as a JSON slice, but doesn't expect slicing parameters
|
38
43
|
def as_sliced_json(queryset, slice: nil, total_count: nil, &blk)
|
39
44
|
slice = Slice.build(queryset, slice, total_count: total_count, item_transformer: blk)
|
40
45
|
slice.render_json
|
41
46
|
end
|
42
47
|
|
48
|
+
# Wrap a Bearcat API instance in a slicing API
|
49
|
+
# bearcat_as_sliced_json() do |params|
|
50
|
+
# bearcat_instance.courses(params)
|
51
|
+
# end
|
43
52
|
def bearcat_as_sliced_json(*args, transform: nil, **kwargs, &blk)
|
44
53
|
bearcat_exec = ->(slice) {
|
45
54
|
response = blk.call({
|
@@ -56,49 +65,6 @@ module Miscellany
|
|
56
65
|
|
57
66
|
private
|
58
67
|
|
59
|
-
def normalize_sort_options(sorts, default: nil)
|
60
|
-
norm_sorts = { }
|
61
|
-
|
62
|
-
sorts.each do |s|
|
63
|
-
if s.is_a?(Hash)
|
64
|
-
s.each do |k,v|
|
65
|
-
sort_hash = normalize_sort(v, key: k)
|
66
|
-
norm_sorts[k] = sort_hash
|
67
|
-
end
|
68
|
-
else
|
69
|
-
sort_hash = normalize_sort(s)
|
70
|
-
norm_sorts[sort_hash[:column]] = sort_hash
|
71
|
-
# default ||= sort_hash
|
72
|
-
end
|
73
|
-
end
|
74
|
-
|
75
|
-
if default.present?
|
76
|
-
norm_default = normalize_sort(default)
|
77
|
-
reference = norm_sorts[norm_default[:key].to_s] || norm_default
|
78
|
-
norm_sorts[:default] = {
|
79
|
-
key: reference[:key],
|
80
|
-
column: reference[:column],
|
81
|
-
order: norm_default[:order] || reference[:order],
|
82
|
-
}
|
83
|
-
end
|
84
|
-
|
85
|
-
norm_sorts
|
86
|
-
end
|
87
|
-
|
88
|
-
def normalize_sort(sort, key: nil)
|
89
|
-
sort = sort.to_s if sort.is_a?(Symbol)
|
90
|
-
if sort.is_a?(Array)
|
91
|
-
sort = { **normalize_sort(sort[0]), **(sort[1] || {}) }
|
92
|
-
elsif sort.is_a?(String)
|
93
|
-
m = sort.match(/^([\w\.]+)(?: (ASC|DESC)(!?))?$/)
|
94
|
-
sort = { column: m[1], order: m[2], force_order: m[3].present? }.compact
|
95
|
-
elsif sort.is_a?(Proc)
|
96
|
-
sort = { column: sort }
|
97
|
-
end
|
98
|
-
sort[:key] = key || sort[:column]
|
99
|
-
sort.compact
|
100
|
-
end
|
101
|
-
|
102
68
|
class Slice
|
103
69
|
attr_accessor :slice_start, :slice_end
|
104
70
|
attr_accessor :page_size, :page_number
|
@@ -112,7 +78,6 @@ module Miscellany
|
|
112
78
|
@options = options
|
113
79
|
end
|
114
80
|
|
115
|
-
# rubocop:disable Metrics/PerceivedComplexity
|
116
81
|
def self.build(queryset, arg, options={})
|
117
82
|
new(queryset, options).tap do |slice|
|
118
83
|
if arg.is_a?(Array)
|
@@ -132,7 +97,11 @@ module Miscellany
|
|
132
97
|
slice_bounds = [(page_number - 1) * page_size, page_number * page_size]
|
133
98
|
end
|
134
99
|
|
135
|
-
|
100
|
+
begin
|
101
|
+
slice[:sort] = options[:sort_parser]&.parse(arg[:sort], ignore_errors: true)
|
102
|
+
rescue Miscellany::SortLang::Parser::SortParsingError => e
|
103
|
+
raise HttpErrorHandling::HttpError, message: e.message
|
104
|
+
end
|
136
105
|
end
|
137
106
|
|
138
107
|
if slice_bounds.present?
|
@@ -149,7 +118,6 @@ module Miscellany
|
|
149
118
|
end
|
150
119
|
end
|
151
120
|
end
|
152
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
153
121
|
|
154
122
|
def [](key)
|
155
123
|
send(key)
|
@@ -194,27 +162,6 @@ module Miscellany
|
|
194
162
|
end
|
195
163
|
def rendered_json; render_json; end
|
196
164
|
|
197
|
-
def self.parse_and_validate_sorts(sortstr, sorts_map, silent_failure: true)
|
198
|
-
(sortstr || '').split(',').map do |s|
|
199
|
-
m = s.strip.match(/^(\w+)(?: (ASC|DESC))?$/)
|
200
|
-
|
201
|
-
if m.nil?
|
202
|
-
next if silent_failure
|
203
|
-
raise HttpErrorHandling::HttpError, message: 'Could not parse sort parameter'
|
204
|
-
end
|
205
|
-
|
206
|
-
resolved_sort = sorts_map[m[1]]
|
207
|
-
unless resolved_sort.present?
|
208
|
-
next if silent_failure
|
209
|
-
raise HttpErrorHandling::HttpError, message: 'Could not parse sort parameter'
|
210
|
-
end
|
211
|
-
|
212
|
-
sort = resolved_sort.dup
|
213
|
-
sort[:order] = m[2] if m[2].present? && !sort[:force_order]
|
214
|
-
sort
|
215
|
-
end.compact.presence || [sorts_map[:default]].compact
|
216
|
-
end
|
217
|
-
|
218
165
|
private
|
219
166
|
|
220
167
|
def rendered_items
|
@@ -229,6 +176,8 @@ module Miscellany
|
|
229
176
|
items.except(:select).count
|
230
177
|
elsif items.respond_to?(:count)
|
231
178
|
items.count
|
179
|
+
elsif defined?(Miscellany::ComplexQuery) && items.is_a?(Miscellany::ComplexQuery)
|
180
|
+
items.count
|
232
181
|
else
|
233
182
|
nil
|
234
183
|
end
|
@@ -249,7 +198,12 @@ module Miscellany
|
|
249
198
|
elsif items.is_a?(ActiveRecord::Relation)
|
250
199
|
offset, limit = slice_bounds
|
251
200
|
limit -= offset unless limit.nil?
|
252
|
-
|
201
|
+
items.order(Arel.sql(sort_sql)).limit(limit).offset(offset).to_a
|
202
|
+
elsif defined?(Miscellany::ComplexQuery) && items.is_a?(Miscellany::ComplexQuery)
|
203
|
+
offset, limit = slice_bounds
|
204
|
+
limit -= offset unless limit.nil?
|
205
|
+
query = items.send(:build_query)
|
206
|
+
items.slice(offset, limit, raw_sort: sort_sql)
|
253
207
|
end
|
254
208
|
end
|
255
209
|
end
|
@@ -258,43 +212,15 @@ module Miscellany
|
|
258
212
|
[slice_start, slice_end == -1 ? nil : slice_end]
|
259
213
|
end
|
260
214
|
|
261
|
-
def
|
262
|
-
|
263
|
-
|
264
|
-
|
215
|
+
def sort_sql
|
216
|
+
sorts = [ *Array(self.sort) ]
|
217
|
+
sorts << options[:sort_parser]&.default
|
218
|
+
sorts.compact!
|
265
219
|
|
266
|
-
|
267
|
-
order = sort[:order] || 'ASC'
|
268
|
-
if sort[:column].is_a?(Proc)
|
269
|
-
sort[:column].call(qset, order)
|
270
|
-
else
|
271
|
-
desired_nulls = (sort[:nulls] || :low).to_s.downcase.to_sym
|
272
|
-
nulls = case desired_nulls
|
273
|
-
when :last
|
274
|
-
'LAST'
|
275
|
-
when :first
|
276
|
-
'FIRST'
|
277
|
-
else
|
278
|
-
(desired_nulls == :high) == (order.to_s.upcase == 'DESC') ? 'FIRST' : 'LAST'
|
279
|
-
end
|
280
|
-
qset.order("#{sort[:column]} #{order} NULLS #{nulls}")
|
281
|
-
end
|
282
|
-
end
|
283
|
-
else
|
284
|
-
qset
|
285
|
-
end
|
286
|
-
end
|
287
|
-
end
|
220
|
+
return nil unless sorts.present?
|
288
221
|
|
289
|
-
|
290
|
-
def partial!(*args, **kwargs, &blk)
|
291
|
-
kwargs[:block] = blk if blk.present?
|
292
|
-
super(*args, **kwargs)
|
222
|
+
Miscellany::SortLang.sqlize(sorts)
|
293
223
|
end
|
294
224
|
end
|
295
|
-
|
296
|
-
def self.install_extensions
|
297
|
-
::JbuilderTemplate.prepend JbuilderTemplateExt
|
298
|
-
end
|
299
225
|
end
|
300
226
|
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Miscellany
|
2
|
+
module Extensions
|
3
|
+
module JBuilder
|
4
|
+
|
5
|
+
# Enables passing blocks to JBuilder `partial!`
|
6
|
+
# When a block is given, it will be made available as `block` in the partial
|
7
|
+
module JbuilderTemplateExt
|
8
|
+
def partial!(*args, **kwargs, &blk)
|
9
|
+
kwargs[:block] = blk if blk.present?
|
10
|
+
super(*args, **kwargs)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.install
|
15
|
+
::JbuilderTemplate.prepend JbuilderTemplateExt
|
16
|
+
end
|
17
|
+
|
18
|
+
begin
|
19
|
+
require 'jbuilder'
|
20
|
+
rescue LoadError
|
21
|
+
end
|
22
|
+
|
23
|
+
install if defined?(::JbuilderTemplate)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module Miscellany
|
2
|
+
module SortLang
|
3
|
+
|
4
|
+
# Normalized Format: {
|
5
|
+
# key: string,
|
6
|
+
# column: string,
|
7
|
+
# order: 'DESC' | 'ASC',
|
8
|
+
# force_order?: boolean, # Prevent overriding order
|
9
|
+
# nulls?: 'high' | 'low' | 'first' | 'last',
|
10
|
+
# }
|
11
|
+
def self.normalize_sort(sort, key: nil)
|
12
|
+
sort = sort.to_s if sort.is_a?(Symbol)
|
13
|
+
if sort.is_a?(Array)
|
14
|
+
sort = { **normalize_sort(sort[0]), **(sort[1] || {}) }
|
15
|
+
elsif sort.is_a?(String)
|
16
|
+
m = sort.match(/^([\w\.]+)(?: (ASC|DESC)(!?))?$/)
|
17
|
+
sort = { column: m[1], order: m[2], force_order: m[3].present? }.compact
|
18
|
+
elsif sort.is_a?(Proc)
|
19
|
+
sort = { column: sort }
|
20
|
+
end
|
21
|
+
sort[:key] = key || sort[:column]
|
22
|
+
sort.compact
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.sqlize(sorts)
|
26
|
+
sorts.map do |sort|
|
27
|
+
order = sort[:order] || 'ASC'
|
28
|
+
if sort[:column].is_a?(Proc)
|
29
|
+
sort[:column].call(qset, order)
|
30
|
+
else
|
31
|
+
desired_nulls = (sort[:nulls] || :low).to_s.downcase.to_sym
|
32
|
+
nulls = case desired_nulls
|
33
|
+
when :last
|
34
|
+
'LAST'
|
35
|
+
when :first
|
36
|
+
'FIRST'
|
37
|
+
else
|
38
|
+
(desired_nulls == :high) == (order.to_s.upcase == 'DESC') ? 'FIRST' : 'LAST'
|
39
|
+
end
|
40
|
+
"#{sort[:column]} #{order} NULLS #{nulls}"
|
41
|
+
end
|
42
|
+
end.join(', ')
|
43
|
+
end
|
44
|
+
|
45
|
+
class Parser
|
46
|
+
class SortParsingError < StandardError; end
|
47
|
+
|
48
|
+
def initialize(valid_sorts, default: nil)
|
49
|
+
@sorts_map = normalize_sort_options(valid_sorts, default: default)
|
50
|
+
end
|
51
|
+
|
52
|
+
def default
|
53
|
+
@sorts_map[:default]
|
54
|
+
end
|
55
|
+
|
56
|
+
def valid?(sortstr)
|
57
|
+
parse(sortstr, ignore_errors: false)
|
58
|
+
true
|
59
|
+
rescue SortParsingError
|
60
|
+
false
|
61
|
+
end
|
62
|
+
|
63
|
+
def parse(sortstr, ignore_errors: true)
|
64
|
+
(sortstr || '').split(',').map do |s|
|
65
|
+
m = s.strip.match(/^(\w+)(?: (ASC|DESC))?$/)
|
66
|
+
|
67
|
+
if m.nil?
|
68
|
+
next if ignore_errors
|
69
|
+
raise SortParsingError, message: 'Could not parse sort parameter'
|
70
|
+
end
|
71
|
+
|
72
|
+
resolved_sort = @sorts_map[m[1]]
|
73
|
+
unless resolved_sort.present?
|
74
|
+
next if ignore_errors
|
75
|
+
raise SortParsingError, message: 'Could not parse sort parameter'
|
76
|
+
end
|
77
|
+
|
78
|
+
sort = resolved_sort.dup
|
79
|
+
sort[:order] = m[2] if m[2].present? && !sort[:force_order]
|
80
|
+
sort
|
81
|
+
end.compact.presence
|
82
|
+
end
|
83
|
+
|
84
|
+
protected
|
85
|
+
|
86
|
+
def normalize_sort_options(sorts, default: nil)
|
87
|
+
norm_sorts = { }
|
88
|
+
|
89
|
+
sorts.each do |s|
|
90
|
+
if s.is_a?(Hash)
|
91
|
+
s.each do |k,v|
|
92
|
+
sort_hash = normalize_sort(v, key: k)
|
93
|
+
norm_sorts[k] = sort_hash
|
94
|
+
end
|
95
|
+
else
|
96
|
+
sort_hash = normalize_sort(s)
|
97
|
+
norm_sorts[sort_hash[:column]] = sort_hash
|
98
|
+
# default ||= sort_hash
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
if default.present?
|
103
|
+
norm_default = normalize_sort(default)
|
104
|
+
reference = norm_sorts[norm_default[:key].to_s] || norm_default
|
105
|
+
norm_sorts[:default] = {
|
106
|
+
key: reference[:key],
|
107
|
+
column: reference[:column],
|
108
|
+
order: norm_default[:order] || reference[:order],
|
109
|
+
}
|
110
|
+
end
|
111
|
+
|
112
|
+
norm_sorts
|
113
|
+
end
|
114
|
+
|
115
|
+
def normalize_sort(*args, **kwargs)
|
116
|
+
SortLang.normalize_sort(*args, **kwargs)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
end
|
data/lib/miscellany/version.rb
CHANGED
data/miscellany.gemspec
CHANGED
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
|
|
22
22
|
spec.test_files = Dir["spec/**/*"]
|
23
23
|
spec.require_paths = ['lib']
|
24
24
|
|
25
|
-
spec.add_dependency 'rails', '>= 5', '<
|
25
|
+
spec.add_dependency 'rails', '>= 5', '< 8.0'
|
26
26
|
# spec.add_dependency 'activerecord', '>= 5', '< 6.3'
|
27
27
|
# spec.add_dependency 'activesupport', '>= 5', '< 6.3'
|
28
28
|
|
@@ -31,4 +31,5 @@ Gem::Specification.new do |spec|
|
|
31
31
|
spec.add_development_dependency 'rspec', '~> 3'
|
32
32
|
spec.add_development_dependency 'sqlite3', '~> 1.3'
|
33
33
|
spec.add_development_dependency 'with_model'
|
34
|
+
spec.add_development_dependency 'goldiloader'
|
34
35
|
end
|
@@ -41,6 +41,22 @@ describe Miscellany::ArbitraryPrefetch do
|
|
41
41
|
expect(posts[0].favorite_comment).to be
|
42
42
|
end
|
43
43
|
|
44
|
+
it 'works with Goldiloader active' do
|
45
|
+
Goldiloader.enabled do
|
46
|
+
posts = Post.prefetch(favorite_comment: Comment.where(favorite: true))
|
47
|
+
expect(posts.count).to eq 10
|
48
|
+
expect(posts[0].favorite_comment).to be
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'works with Goldiloader disabled' do
|
53
|
+
Goldiloader.disabled do
|
54
|
+
posts = Post.prefetch(favorite_comment: Comment.where(favorite: true))
|
55
|
+
expect(posts.count).to eq 10
|
56
|
+
expect(posts[0].favorite_comment).to be
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
44
60
|
context 'prefetch is singluar' do
|
45
61
|
it 'returns a single object' do
|
46
62
|
posts = Post.prefetch(favorite_comment: Comment.where(favorite: true))
|
@@ -0,0 +1,53 @@
|
|
1
|
+
|
2
|
+
require 'spec_helper'
|
3
|
+
require 'goldiloader'
|
4
|
+
|
5
|
+
describe Miscellany::GoldiloadValue do
|
6
|
+
with_model :Post do
|
7
|
+
table do |t|
|
8
|
+
t.string :title
|
9
|
+
t.timestamps null: false
|
10
|
+
end
|
11
|
+
|
12
|
+
model do
|
13
|
+
attr_accessor :read_count
|
14
|
+
|
15
|
+
after_initialize { self.read_count = 0 }
|
16
|
+
|
17
|
+
def gvalue
|
18
|
+
goldiload_value([:key]) do |models|
|
19
|
+
self.read_count += 1
|
20
|
+
models.map{|m| [m.id, 123] }.to_h
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
let!(:posts) { 3.times.map{|i| Post.create!(title: "Post #{i}") } }
|
27
|
+
|
28
|
+
it 'generally works' do
|
29
|
+
posts = Post.limit(2).to_a
|
30
|
+
|
31
|
+
expect(posts[0].goldi_values).to eql nil
|
32
|
+
expect(posts[1].goldi_values).to eql nil
|
33
|
+
|
34
|
+
expect(posts[0].gvalue).to eql 123
|
35
|
+
|
36
|
+
expect(posts[0].goldi_values).to eql ({[:key]=>123})
|
37
|
+
expect(posts[1].goldi_values).to eql ({[:key]=>123})
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'only calculates once per batch' do
|
41
|
+
posts = Post.limit(2).to_a
|
42
|
+
|
43
|
+
expect(posts[0].read_count).to eql 0
|
44
|
+
expect(posts[1].read_count).to eql 0
|
45
|
+
|
46
|
+
expect(posts[0].gvalue).to eql 123
|
47
|
+
expect(posts[0].gvalue).to eql 123
|
48
|
+
expect(posts[1].gvalue).to eql 123
|
49
|
+
|
50
|
+
expect(posts[0].read_count).to eql 1
|
51
|
+
expect(posts[1].read_count).to eql 0
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
|
2
|
+
require 'spec_helper'
|
3
|
+
require 'action_controller'
|
4
|
+
|
5
|
+
describe Miscellany::SlicedResponse do
|
6
|
+
with_model :ARModel do
|
7
|
+
table do |t|
|
8
|
+
t.string :title
|
9
|
+
t.timestamps null: false
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
subject_class = Class.new(ActionController::Base) do
|
14
|
+
include Miscellany::SlicedResponse
|
15
|
+
end
|
16
|
+
|
17
|
+
complex_query_class = Class.new(Miscellany::ComplexQuery) do
|
18
|
+
def build_query
|
19
|
+
"SELECT * FROM #{ARModel.table_name}"
|
20
|
+
end
|
21
|
+
|
22
|
+
def build_count_query
|
23
|
+
"SELECT COUNT(*) FROM #{ARModel.table_name}"
|
24
|
+
end
|
25
|
+
|
26
|
+
def valid_sorts
|
27
|
+
["title"]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
before do
|
32
|
+
10.times do |i|
|
33
|
+
ARModel.create!(title: i)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
let(:subject) { subject_class.new }
|
38
|
+
|
39
|
+
let(:slice_params) { { } }
|
40
|
+
let(:slice_config) { {
|
41
|
+
default_size: 3,
|
42
|
+
default_sort: "title",
|
43
|
+
valid_sorts: ["title", "created_at"],
|
44
|
+
} }
|
45
|
+
|
46
|
+
def expect_items(slice, expected)
|
47
|
+
expected = JSON.parse(expected.to_json).map{|i| i.without("created_at", "updated_at")}
|
48
|
+
slice = JSON.parse(slice.to_json)
|
49
|
+
expect(slice).to be_a Hash
|
50
|
+
expect(slice["items"].map{|i| i.without("created_at", "updated_at")}).to eql expected
|
51
|
+
end
|
52
|
+
|
53
|
+
shared_examples "basic functionality" do
|
54
|
+
it "returns the first page" do
|
55
|
+
r = subject.sliced_json(source, slice_params, **slice_config)
|
56
|
+
expect_items(r, ARModel.all.limit(3))
|
57
|
+
end
|
58
|
+
|
59
|
+
it "returns the second page" do
|
60
|
+
r = subject.sliced_json(source, { **slice_params, page: 2 }, **slice_config)
|
61
|
+
expect_items(r, ARModel.all.offset(3).limit(3))
|
62
|
+
end
|
63
|
+
|
64
|
+
it "allows changing page sizes" do
|
65
|
+
r = subject.sliced_json(source, { **slice_params, page: 2, page_size: 4 }, **slice_config)
|
66
|
+
expect_items(r, ARModel.all.offset(4).limit(4))
|
67
|
+
end
|
68
|
+
|
69
|
+
it "works with a slice" do
|
70
|
+
r = subject.sliced_json(source, { **slice_params, slice: "2:4" }, **slice_config)
|
71
|
+
expect_items(r, ARModel.all.offset(2).limit(2))
|
72
|
+
end
|
73
|
+
|
74
|
+
it "includes metadata" do
|
75
|
+
r = subject.sliced_json(source, slice_params, **slice_config)
|
76
|
+
expect(r[:total_count]).to eql 10
|
77
|
+
expect(r[:page]).to eql 1
|
78
|
+
expect(r[:page_size]).to eql 3
|
79
|
+
expect(r[:page_count]).to eql 4
|
80
|
+
expect(r[:slice_start]).to eql 0
|
81
|
+
expect(r[:slice_end]).to eql 3
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
shared_examples "sortable" do
|
86
|
+
it "is sortable" do
|
87
|
+
expect_items(subject.sliced_json(source, { **slice_params, sort: "title DESC" }, **slice_config), ARModel.all.order("title DESC").limit(3))
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
describe "#sliced_json" do
|
92
|
+
context "with an Array source" do
|
93
|
+
let(:source) { ARModel.all.to_a }
|
94
|
+
|
95
|
+
include_examples "basic functionality"
|
96
|
+
end
|
97
|
+
|
98
|
+
context "with an ActiveRecord::Relation source" do
|
99
|
+
let(:source) { ARModel.all }
|
100
|
+
|
101
|
+
include_examples "basic functionality"
|
102
|
+
include_examples "sortable"
|
103
|
+
end
|
104
|
+
|
105
|
+
context "with a ComplexQuery source" do
|
106
|
+
let(:source) { complex_query_class.new({}) }
|
107
|
+
|
108
|
+
include_examples "basic functionality"
|
109
|
+
include_examples "sortable"
|
110
|
+
|
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" }, {})
|
113
|
+
expect(r[:sort]).to eql nil
|
114
|
+
|
115
|
+
r = subject.sliced_json(source, { **slice_params, sort: "title" }, {})
|
116
|
+
expect(r[:sort]).to eql "title ASC"
|
117
|
+
end
|
118
|
+
|
119
|
+
it "does not reference the ComplexQuery sort_parser if valid_sorts is given" do
|
120
|
+
expect(source).not_to receive(:sort_parser)
|
121
|
+
subject.sliced_json(source, { **slice_params, sort: "title" }, **slice_config)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
it "enforces allow_all" do
|
126
|
+
expect do
|
127
|
+
subject.sliced_json(ARModel.all, { page: "all" }, **slice_config)
|
128
|
+
end.to raise_error("cannot request whole collection")
|
129
|
+
|
130
|
+
expect(subject.sliced_json(ARModel.all, { page: "all" }, allow_all: true, **slice_config)).to be_a Hash
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
describe "#bearcat_as_sliced_json" do
|
135
|
+
# TODO
|
136
|
+
end
|
137
|
+
|
138
|
+
describe Miscellany::SlicedResponse::Slice do
|
139
|
+
let(:sort_parser) { Miscellany::SortLang::Parser.new(slice_config[:valid_sorts], default: slice_config[:default_sort]) }
|
140
|
+
let(:slice) do
|
141
|
+
Miscellany::SlicedResponse::Slice.build(ARModel.all, slice_params, sort_parser: sort_parser, **slice_config)
|
142
|
+
end
|
143
|
+
|
144
|
+
describe "#sort_sql" do
|
145
|
+
it "always includes the default sort" do
|
146
|
+
slice_params[:sort] = "created_at"
|
147
|
+
expect(slice.send(:sort_sql)).to include "created_at ASC NULLS FIRST"
|
148
|
+
end
|
149
|
+
|
150
|
+
it "excludes unknown sorts" do
|
151
|
+
slice_params[:sort] = "updated_at"
|
152
|
+
expect(slice.send(:sort_sql)).not_to include "updated_at"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -4,6 +4,7 @@ require 'logger'
|
|
4
4
|
require 'yaml'
|
5
5
|
require 'database_cleaner'
|
6
6
|
require 'with_model'
|
7
|
+
require 'goldiloader'
|
7
8
|
|
8
9
|
require 'miscellany'
|
9
10
|
|
@@ -17,10 +18,12 @@ db_adapter = ENV.fetch('ADAPTER', 'sqlite3')
|
|
17
18
|
db_config = YAML.safe_load(File.read('spec/db/database.yml'))
|
18
19
|
ActiveRecord::Base.establish_connection(db_config[db_adapter])
|
19
20
|
|
21
|
+
Goldiloader.globally_enabled = false
|
22
|
+
|
20
23
|
RSpec.configure do |config|
|
21
24
|
config.extend WithModel
|
22
25
|
|
23
|
-
|
26
|
+
config.order = 'random'
|
24
27
|
|
25
28
|
config.before(:suite) do
|
26
29
|
DatabaseCleaner.clean_with(:truncation)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: miscellany
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.14
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ethan Knapp
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-09-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -19,7 +19,7 @@ dependencies:
|
|
19
19
|
version: '5'
|
20
20
|
- - "<"
|
21
21
|
- !ruby/object:Gem::Version
|
22
|
-
version: '
|
22
|
+
version: '8.0'
|
23
23
|
type: :runtime
|
24
24
|
prerelease: false
|
25
25
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -29,7 +29,7 @@ dependencies:
|
|
29
29
|
version: '5'
|
30
30
|
- - "<"
|
31
31
|
- !ruby/object:Gem::Version
|
32
|
-
version: '
|
32
|
+
version: '8.0'
|
33
33
|
- !ruby/object:Gem::Dependency
|
34
34
|
name: rake
|
35
35
|
requirement: !ruby/object:Gem::Requirement
|
@@ -100,6 +100,20 @@ dependencies:
|
|
100
100
|
- - ">="
|
101
101
|
- !ruby/object:Gem::Version
|
102
102
|
version: '0'
|
103
|
+
- !ruby/object:Gem::Dependency
|
104
|
+
name: goldiloader
|
105
|
+
requirement: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - ">="
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
type: :development
|
111
|
+
prerelease: false
|
112
|
+
version_requirements: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - ">="
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: '0'
|
103
117
|
description:
|
104
118
|
email:
|
105
119
|
- eknapp@instructure.com
|
@@ -108,11 +122,13 @@ extensions: []
|
|
108
122
|
extra_rdoc_files: []
|
109
123
|
files:
|
110
124
|
- README.md
|
125
|
+
- app/views/miscellany/_slice.json.jbuilder
|
111
126
|
- config/initializers/cancancan.rb
|
112
127
|
- lib/miscellany.rb
|
113
128
|
- lib/miscellany/active_record/arbitrary_prefetch.rb
|
114
129
|
- lib/miscellany/active_record/batch_matcher.rb
|
115
130
|
- lib/miscellany/active_record/batched_destruction.rb
|
131
|
+
- lib/miscellany/active_record/complex_query.rb
|
116
132
|
- lib/miscellany/active_record/computed_columns.rb
|
117
133
|
- lib/miscellany/active_record/custom_preloaders.rb
|
118
134
|
- lib/miscellany/active_record/goldiload_value.rb
|
@@ -121,14 +137,18 @@ files:
|
|
121
137
|
- lib/miscellany/controller/http_error_handling.rb
|
122
138
|
- lib/miscellany/controller/json_uploads.rb
|
123
139
|
- lib/miscellany/controller/sliced_response.rb
|
140
|
+
- lib/miscellany/jbuilder_partial_block.rb
|
124
141
|
- lib/miscellany/local_lru_cache.rb
|
125
142
|
- lib/miscellany/param_validator.rb
|
143
|
+
- lib/miscellany/sort_lang.rb
|
126
144
|
- lib/miscellany/version.rb
|
127
145
|
- miscellany.gemspec
|
128
146
|
- spec/db/database.yml
|
129
147
|
- spec/miscellany/arbitrary_prefetch_spec.rb
|
130
148
|
- spec/miscellany/computed_columns_spec.rb
|
149
|
+
- spec/miscellany/goldiload_value_spec.rb
|
131
150
|
- spec/miscellany/param_validator_spec.rb
|
151
|
+
- spec/miscellany/sliced_response_spec.rb
|
132
152
|
- spec/spec_helper.rb
|
133
153
|
homepage: https://instructure.com
|
134
154
|
licenses: []
|
@@ -155,6 +175,8 @@ summary: Gem for a bunch of random, re-usable Rails Concerns & Helpers
|
|
155
175
|
test_files:
|
156
176
|
- spec/db/database.yml
|
157
177
|
- spec/miscellany/arbitrary_prefetch_spec.rb
|
178
|
+
- spec/miscellany/sliced_response_spec.rb
|
158
179
|
- spec/miscellany/param_validator_spec.rb
|
159
180
|
- spec/miscellany/computed_columns_spec.rb
|
181
|
+
- spec/miscellany/goldiload_value_spec.rb
|
160
182
|
- spec/spec_helper.rb
|