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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bbb29d549980dccc437f08518643092d0f77cbac6cbfa73f0f4dc728cd9586a
4
- data.tar.gz: b8fe8463a2ffa603036803d25a34a815087a52829ae5a2e9e1f0360c27e6f04b
3
+ metadata.gz: 735168167aa19b15e9b56db6a20025dc7374526319744526d08238577caf4983
4
+ data.tar.gz: c4df4a5c364cb04632ab017f2e7393d9f8dc5f5228ba7da41303d8a7595606ea
5
5
  SHA512:
6
- metadata.gz: 5b0fb52043a3d4bbfc1bf503661f1162b370b19a6b03b9fcca642656ac1008b2920b3858b680023c0ce986b10c9646d5ee1d419e2244f337811c2dc9ec1c9233
7
- data.tar.gz: f634b8330eb9f290cc5e76eeebf14e3cf2178a046ddade0230c2399642528e8976cdb8b6abf6e998a199e2f6696db4849ec66e72124dcd0b9f960199d89c85cc
6
+ metadata.gz: 94f572010617e6295e19d0167c05cb59b683496f87502a83bb53f91c3303fb11cfb65a7c7f0a2c85c254896700c22bc9d441e02ebb09a93741cb4867e13596a3
7
+ data.tar.gz: 8064920b660a82326a0172ac4828c95bf072b65ec3e9e7fe87dcb842ace9ec4a11072e0095eb73e4247c8d83e5e412bd4bd62947b35edefb9b82576b1a5e9423
@@ -0,0 +1,5 @@
1
+ json.merge!(slice)
2
+
3
+ json.items slice[:items] do |item|
4
+ block.call(item)
5
+ end
@@ -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
- if ACTIVE_RECORD_VERSION >= ::Gem::Version.new('6.0.0')
143
- def grouped_records(association, records, polymorphic_parent)
144
- h = {}
145
- records.each do |record|
146
- next unless record
147
- reflection = record.class._reflect_on_association(association)
148
- reflection ||= record.association(association)&.reflection rescue nil
149
- next if polymorphic_parent && !reflection || !record.association(association).klass
150
- (h[reflection] ||= []) << record
151
- end
152
- h
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
- ::ActiveRecord::Associations::Preloader.prepend(ActiveRecordPreloaderPatch)
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" # rubocop:disable Metrics/LineLength
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? # rubocop:disable Metrics/LineLength
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 # rubocop:disable Metrics/LineLength
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
@@ -1,5 +1,5 @@
1
1
  module Miscellany
2
- module GoldiloadValue # TODO Write Specs
2
+ module GoldiloadValue
3
3
  attr_accessor :goldi_values
4
4
 
5
5
  def goldiload_value(key, &blk)
@@ -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
- normalized_sorts = normalize_sort_options(valid_sorts, default: default_sort)
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
- valid_sorts: normalized_sorts,
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
- slice[:sort] = parse_and_validate_sorts(arg[:sort], options[:valid_sorts])
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
- apply_ar_sort(items).limit(limit).offset(offset)
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 apply_ar_sort(qset)
262
- if sort.present?
263
- sorts = [ *Array(self.sort) ]
264
- sorts << options[:valid_sorts][:default] if options.dig(:valid_sorts, :default).present?
215
+ def sort_sql
216
+ sorts = [ *Array(self.sort) ]
217
+ sorts << options[:sort_parser]&.default
218
+ sorts.compact!
265
219
 
266
- sorts.reduce(qset) do |qset, sort|
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
- module JbuilderTemplateExt
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
@@ -1,3 +1,3 @@
1
1
  module Miscellany
2
- VERSION = "0.1.12".freeze
2
+ VERSION = "0.1.14".freeze
3
3
  end
data/miscellany.gemspec CHANGED
@@ -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
- # config.order = 'random'
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.12
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-08-15 00:00:00.000000000 Z
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