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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54c8662a208b655e434d42348a8ae5ace33251ad3180b39d4a0f1bbf4567c6da
4
- data.tar.gz: 7785ba04dcfa330d838a802d89f56909d799b054264f8085eddefc93b728ab9d
3
+ metadata.gz: 735168167aa19b15e9b56db6a20025dc7374526319744526d08238577caf4983
4
+ data.tar.gz: c4df4a5c364cb04632ab017f2e7393d9f8dc5f5228ba7da41303d8a7595606ea
5
5
  SHA512:
6
- metadata.gz: dd53eac07ade5122cdd43bd866e7bc758c38147e16760d1b6d1fd776807f1db81261069a598bfa3c7a627b81989737eca0798296284f88b36eb1cd3147924f6e
7
- data.tar.gz: 61de91e1e72ea0105b536cd4e8d6c9ccb788f9d16418fc4e1b94af5a56ee3b7aa20e0d4dacd7c87bd6ffa5632a80f62575f473e876928f88d98a25f7f70fc3a0
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
@@ -1,5 +1,5 @@
1
1
  module Miscellany
2
- module BatchedDestruction
2
+ module BatchedDestruction # TODO Write Specs
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
@@ -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
@@ -30,7 +30,7 @@
30
30
  # end
31
31
  #
32
32
  module Miscellany
33
- module CustomPreloaders
33
+ module CustomPreloaders # TODO Write Specs
34
34
  module AssociationBuilderExtension
35
35
  def self.build(model, reflection); end
36
36
 
@@ -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.11".freeze
2
+ VERSION = "0.1.14".freeze
3
3
  end
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', '< 6.3'
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
- # 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.11
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
@@ -19,7 +19,7 @@ dependencies:
19
19
  version: '5'
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
- version: '6.3'
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: '6.3'
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