miscellany 0.1.12 → 0.1.14
Sign up to get free protection for your applications and to get access to all the features.
- 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/complex_query.rb +141 -0
- data/lib/miscellany/active_record/goldiload_value.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 +1 -0
- 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 +24 -2
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
@@ -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
|
@@ -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
|