miscellany 0.1.2 → 0.1.5

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: ee3173a46bc8dfb9c07bc8e6533776c332b6267e8d2465bada2e9d395192f199
4
- data.tar.gz: cfef42a0855f7fe772e0ae50d08aaaca4fa6dba6a69caf189c1fbea1d076a168
3
+ metadata.gz: 0c92b625438d9027f73a5cd7b5f4ef168e1ed3e083b0c13924cb9f3f0e2db6bd
4
+ data.tar.gz: 0a52d0877c7eea6a0c2189efdd4c30cb65a3419abf149a8ae0ce4e63467741de
5
5
  SHA512:
6
- metadata.gz: 1035b6798d130d137a6feef1892dde982f04394fbdc228a63ab275685ba9cf460b84f909bdcae6bb5f41a78e952c237d331aaaf94ba4241d0c10bf984e24c3fe
7
- data.tar.gz: d07f4e9beacb29caa4d5ca6e22e265240dc87857bab04b5165ed8d1587090811495095ba0a95579bb8ceac2fc25e7fec2c7af1c04ce06ea370fa5137d30c5ea9
6
+ metadata.gz: 9cbc439200d8a6c1604aa0153cbed6cfeead8636eb7330880371d2e8c6d2d412739e8a2d302c5dfd1cd9b038bec9f6de663f8fc7ddfd1fb1d8c99afe029e0306
7
+ data.tar.gz: d4bea4b5fd11c1830a8f18302c7f6c95d6f4c3fc147faf715f675850e7dee4e02d08632b05aa3e61ce4277de494a9784e6dc3ef55c8e92f99a0a22ba9a9d4fbc
@@ -4,18 +4,11 @@
4
4
  # The values of the Hash may be an array of [Symbol, Relation] or another (filtered) Relation.
5
5
  # Objects are queried from an existing Association on the model. This Association is detemrined
6
6
  # by either the Symbol when an array is passed, or by finding an Assoication for the passed Relation's model
7
- #
8
- # NOTICE: This implementation is NOT COMPLETE by itself - it depends on Goldiloader
9
- # to detect the use of the virtual associations and prevent N+1s. We were already using
10
- # Goldiloader, so this made sense. If this module is ever needed stand-alone,
11
- # the following options have been identified:
12
- # 1. Extend ActiveRecordRelationPatch#exec_queries to execute an ActiveRecord::Associations::Preloader
13
- # that will load the related objects
14
- # 2. Duplicates the relevant snippets from Goldiloader into this module. See Goldiloader::AutoIncludeContext
15
- # The current Goldiloader implementation uses Option 1 internally, but also makes the relations lazy - even
16
- # if you define a prefetch, it won't actually be loaded until you attempt to access it on one of the models.
17
7
  module Miscellany
18
8
  module ArbitraryPrefetch
9
+ ACTIVE_RECORD_VERSION = ::Gem::Version.new(::ActiveRecord::VERSION::STRING).release
10
+ PRE_RAILS_6_2 = ACTIVE_RECORD_VERSION < ::Gem::Version.new('6.2.0')
11
+
19
12
  class PrefetcherContext
20
13
  attr_accessor :model, :target_attribute
21
14
  attr_reader :options
@@ -49,7 +42,7 @@ module Miscellany
49
42
  @reflection ||= begin
50
43
  queryset = @queryset
51
44
  source_refl = model.reflections[@source_key.to_s]
52
- scope = lambda { |*_args|
45
+ scope = lambda {|*_args|
53
46
  qs = queryset
54
47
  qs = qs.merge(source_refl.scope_for(model.unscoped)) if source_refl.scope
55
48
  qs
@@ -83,14 +76,16 @@ module Miscellany
83
76
  return super if loaded?
84
77
 
85
78
  records = super
86
- preloader = nil
87
79
  (@values[:prefetches] || {}).each do |_key, opts|
88
80
  pfc = PrefetcherContext.new(model, opts)
89
81
  pfc.link_models(records)
90
82
 
91
83
  unless defined?(Goldiloader)
92
- preloader ||= build_preloader
93
- preloader.preload(records, opts[:attribute])
84
+ if PRE_RAILS_6_2
85
+ ::ActiveRecord::Associations::Preloader.new.preload(records, [opts[:attribute]])
86
+ else
87
+ ::ActiveRecord::Associations::Preloader.new(records: records, associations: [opts[:attribute]]).call
88
+ end
94
89
  end
95
90
  end
96
91
  records
@@ -106,12 +101,12 @@ module Miscellany
106
101
  assert_mutability!
107
102
  @values[:prefetches] ||= {}
108
103
  kwargs.each do |attr, opts|
109
- @values[:prefetches][attr] = normalize_options(attr, opts)
104
+ @values[:prefetches][attr] = normalize_prefetch_options(attr, opts)
110
105
  end
111
106
  self
112
107
  end
113
108
 
114
- def normalize_options(attr, opts)
109
+ def normalize_prefetch_options(attr, opts)
115
110
  norm = if opts.is_a?(Array)
116
111
  { relation: opts[0], queryset: opts[1] }
117
112
  elsif opts.is_a?(ActiveRecord::Relation)
@@ -143,15 +138,43 @@ module Miscellany
143
138
  end
144
139
  end
145
140
 
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
153
+ end
154
+ end
155
+ end
156
+
157
+ module ActiveRecordReflectionPatch
158
+ def check_preloadable!
159
+ return if scope && scope.arity < 0
160
+ super
161
+ end
162
+ end
163
+
146
164
  def self.install
147
165
  ::ActiveRecord::Base.include(ActiveRecordBasePatch)
166
+
148
167
  ::ActiveRecord::Relation.prepend(ActiveRecordRelationPatch)
149
168
  ::ActiveRecord::Relation::Merger.prepend(ActiveRecordMergerPatch)
150
169
 
170
+ ::ActiveRecord::Associations::Preloader.prepend(ActiveRecordPreloaderPatch)
171
+
172
+ ::ActiveRecord::Reflection::AssociationReflection.prepend(ActiveRecordReflectionPatch)
173
+
151
174
  return unless defined? ::Goldiloader
152
175
 
153
176
  ::Goldiloader::AssociationLoader.module_eval do
154
- def self.has_association?(model, association_name) # rubocop:disable Naming/PredicateName
177
+ def self.has_association?(model, association_name)
155
178
  model.association(association_name)
156
179
  true
157
180
  rescue ::ActiveRecord::AssociationNotFoundError => _err
@@ -16,7 +16,6 @@ module Miscellany
16
16
 
17
17
  class_methods do
18
18
  def bulk_destroy(**kwargs)
19
- return to_sql
20
19
  bulk_destroy_internal(self, **kwargs)
21
20
  end
22
21
 
@@ -0,0 +1,107 @@
1
+
2
+ module Miscellany
3
+ module ComputedColumns
4
+ module ActiveRecordBasePatch
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class << self
9
+ delegate :with_computed, to: :all
10
+ end
11
+ end
12
+
13
+ class_methods do
14
+ def define_computed(key, dirblk = nil, &blk)
15
+ blk ||= dirblk
16
+ @defined_computeds ||= {}
17
+ @defined_computeds[key] = blk
18
+ end
19
+
20
+ def get_defined_computed(key)
21
+ @defined_computeds ||= {}
22
+ @defined_computeds[key] || superclass.try(:get_defined_computed, key)
23
+ end
24
+ end
25
+ end
26
+
27
+ module ActiveRecordRelationPatch
28
+ def with_computed(*args, **kwargs)
29
+ entries = { **kwargs }
30
+ args.each do |k|
31
+ entries[k] = []
32
+ end
33
+
34
+ entries.reduce(self) do |query, (k, v)|
35
+ comp = model.get_defined_computed(k)
36
+ raise "Undefined ComputedColum :#{k}" if comp.nil?
37
+
38
+ builder = ComputedBuilder.new(k, v, &comp)
39
+ builder.apply(query)
40
+ end
41
+ end
42
+ end
43
+
44
+ class ComputedBuilder
45
+ def initialize(key, args, &blk)
46
+ @key = key
47
+ @args = args.is_a?(Array) ? args : [args]
48
+ @block = blk
49
+ @compiled = {}
50
+ end
51
+
52
+ %i[select join_condition query].each do |m|
53
+ define_method(m) do |arg=:not_given, &blk|
54
+ raise "Must provide either a value or a block" if arg != :not_given && blk
55
+ raise "Must provide either a value or a block" if arg == :not_given && !blk
56
+
57
+ if arg == :not_given
58
+ arg = blk.call
59
+ end
60
+
61
+ @compiled[m] = arg
62
+ end
63
+ end
64
+
65
+ def apply(q)
66
+ instance_exec(*@args, &@block)
67
+
68
+ c = @compiled
69
+ raise "defined_computed: query must be provided" unless c[:query]
70
+
71
+ join_name = @key.to_s
72
+ base_table_name = current_table_from_scope(q)
73
+ c[:join_condition] ||= "COMPUTED.id = #{base_table_name}.id"
74
+ c[:select] ||= "#{join_name}.value AS #{@key}"
75
+
76
+ q = q.select("#{base_table_name}.*") if !q.values[:select].present?
77
+
78
+ select_statement = c[:select].gsub('COMPUTED', join_name)
79
+ join_condition = c[:join_condition].gsub('COMPUTED', join_name)
80
+ join_query = c[:query]
81
+ join_query = join_query.to_sql if join_query.respond_to?(:to_sql)
82
+
83
+ q.select(select_statement).joins("LEFT OUTER JOIN (#{join_query}) #{join_name} ON #{join_condition}")
84
+ end
85
+
86
+ protected
87
+
88
+ def current_table_from_scope(q)
89
+ current_table = q.current_scope.arel.source.left
90
+
91
+ case current_table
92
+ when Arel::Table
93
+ current_table.name
94
+ when Arel::Nodes::TableAlias
95
+ current_table.right
96
+ else
97
+ fail
98
+ end
99
+ end
100
+ end
101
+
102
+ def self.install
103
+ ::ActiveRecord::Base.include(ActiveRecordBasePatch)
104
+ ::ActiveRecord::Relation.prepend(ActiveRecordRelationPatch)
105
+ end
106
+ end
107
+ end
@@ -8,12 +8,15 @@ module Miscellany
8
8
  # enumerator_of_some_kind.each { |item| batches << item }
9
9
  # batches.flush
10
10
  class BatchProcessor
11
- attr_reader :batch_size
11
+ attr_reader :batch_size, :ensure_once
12
12
 
13
- def initialize(of: 1000, &blk)
13
+ def initialize(of: 1000, ensure_once: false, &blk)
14
14
  @batch_size = of
15
15
  @block = blk
16
+ @ensure_once = ensure_once
16
17
  @current_batch = []
18
+
19
+ @flush_count = 0
17
20
  end
18
21
 
19
22
  def <<(item)
@@ -28,7 +31,7 @@ module Miscellany
28
31
  end
29
32
 
30
33
  def flush
31
- process_batch if @current_batch.present?
34
+ process_batch if @current_batch.present? || (@flush_count.zero? && ensure_once)
32
35
  end
33
36
 
34
37
  protected
@@ -36,6 +39,7 @@ module Miscellany
36
39
  def process_batch
37
40
  @block.call(@current_batch)
38
41
  @current_batch = []
42
+ @flush_count += 1
39
43
  end
40
44
  end
41
45
  end
@@ -17,7 +17,9 @@ module Miscellany
17
17
  default_sort: nil, valid_sorts: nil,
18
18
  &blk
19
19
  )
20
- normalized_sorts = normalize_sort_options(valid_sorts || queryset.column_names, default: default_sort)
20
+ valid_sorts ||= queryset.column_names if queryset.respond_to?(:column_names)
21
+ valid_sorts ||= []
22
+ normalized_sorts = normalize_sort_options(valid_sorts, default: default_sort)
21
23
 
22
24
  slice = Slice.build(
23
25
  queryset, slice_params,
@@ -85,8 +87,10 @@ module Miscellany
85
87
 
86
88
  def normalize_sort(sort, key: nil)
87
89
  sort = sort.to_s if sort.is_a?(Symbol)
88
- if sort.is_a?(String)
89
- m = sort.match(/(\w+)(?: (ASC|DESC)(!?))?/)
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)(!?))?$/)
90
94
  sort = { column: m[1], order: m[2], force_order: m[3].present? }.compact
91
95
  elsif sort.is_a?(Proc)
92
96
  sort = { column: sort }
@@ -215,18 +219,31 @@ module Miscellany
215
219
 
216
220
  def rendered_items
217
221
  ritems = sliced_items
218
- ritems = ritems.map(&options[:item_transformer]) if options[:item_transformer]
222
+ ritems = ritems.to_a.map(&options[:item_transformer]) if options[:item_transformer]
219
223
  ritems
220
224
  end
221
225
 
222
226
  def total_item_count
223
- @total_item_count ||= options[:total_count] || (items.respond_to?(:count) && items.count) || nil
227
+ @total_item_count ||= options[:total_count] || begin
228
+ if items.is_a?(ActiveRecord::Relation)
229
+ items.except(:select).count
230
+ elsif items.respond_to?(:count)
231
+ items.count
232
+ else
233
+ nil
234
+ end
235
+ end
224
236
  end
225
237
 
226
238
  def sliced_items
227
239
  @sliced_items ||= begin
228
240
  if items.is_a?(Array)
229
- items
241
+ start, finish = slice_bounds
242
+ if start && finish
243
+ items[start...finish]
244
+ else
245
+ items
246
+ end
230
247
  elsif items.is_a?(Proc)
231
248
  items.call(self)
232
249
  elsif items.is_a?(ActiveRecord::Relation)
@@ -247,10 +264,20 @@ module Miscellany
247
264
  sorts << options[:valid_sorts][:default] if options.dig(:valid_sorts, :default).present?
248
265
 
249
266
  sorts.reduce(qset) do |qset, sort|
267
+ order = sort[:order] || 'ASC'
250
268
  if sort[:column].is_a?(Proc)
251
- sort[:column].call(qset, sort[:order] || 'ASC')
269
+ sort[:column].call(qset, order)
252
270
  else
253
- qset.order(sort[:column] => sort[:order] || 'ASC')
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}")
254
281
  end
255
282
  end
256
283
  else
@@ -1,6 +1,7 @@
1
1
  module Miscellany
2
2
  class ParamValidator
3
- attr_accessor :context, :options, :errors
3
+ attr_accessor :context, :errors
4
+ attr_reader :params
4
5
 
5
6
  delegate_missing_to :context
6
7
 
@@ -9,7 +10,7 @@ module Miscellany
9
10
  CHECKS = %i[type specified present default transform in block items pattern].freeze
10
11
  NON_PREFIXED = %i[default transform type message timezone].freeze
11
12
  PREFIXES = %i[all onem onep one none].freeze
12
- PREFIX_ALIASES = { any: :onep, not: :none }.freeze
13
+ PREFIX_ALIASES = { any: :onep, not: :none, one_or_less: :onem, one_or_more: :onem }.freeze
13
14
  ALL_PREFIXES = (PREFIXES + PREFIX_ALIASES.keys).freeze
14
15
  VALID_FLAGS = %i[present specified].freeze
15
16
 
@@ -21,14 +22,11 @@ module Miscellany
21
22
  end
22
23
  end
23
24
 
24
- def initialize(block, context, parameters = nil, options = nil)
25
+ def initialize(block, context, parameters = nil)
25
26
  @block = block
26
27
  @context = context
27
28
  @params = parameters || context.params
28
- @subkeys = []
29
- @options = options || {}
30
- @errors = {}
31
- @explicit_parameters = []
29
+ @errors = ErrorStore.new
32
30
  end
33
31
 
34
32
  def self.check(params, context: nil, &blk)
@@ -48,8 +46,13 @@ module Miscellany
48
46
 
49
47
  def apply_checks(&blk)
50
48
  blk ||= @block
51
- args = trim_arguments(blk, [params, @subkeys[-1]])
52
- instance_exec(*args, &blk)
49
+ args = trim_arguments(blk, [@params, :TODO])
50
+
51
+ dresult = instance_exec(*args, &blk)
52
+ dresult = "failed validation #{check}" if dresult == false
53
+ if dresult.present? && dresult != true
54
+ @errors.push(dresult)
55
+ end
53
56
  end
54
57
 
55
58
  def parameter(param_keys, *args, **kwargs, &blk)
@@ -107,45 +110,57 @@ module Miscellany
107
110
 
108
111
  # Nested check
109
112
  run_check[:block] do |blk|
110
- iterate_array = false # TODO
111
- sub_parameter(pk) do
112
- if params.is_a?(Array) && iterate_array
113
- params.each_with_index do |v, i|
114
- sub_parameter(i) { apply_checks(&blk) }
115
- end
116
- else
117
- apply_checks(&blk)
118
- end
119
- end
113
+ ParamValidator.check(params[pk], context: context, &blk)
120
114
  end
121
115
 
122
- # Nested check
116
+ # Array Items check
123
117
  run_check[:items] do |blk|
124
- sub_parameter(pk) do
125
- if params.is_a?(Array)
126
- params.each_with_index do |v, i|
127
- sub_parameter(i) { apply_checks(&blk) }
118
+ if params[pk].is_a?(Array)
119
+ error_items = 0
120
+ astore = ErrorStore.new
121
+
122
+ params[pk].each_with_index do |v, i|
123
+ errs = nil
124
+ if blk.is_a?(Hash)
125
+ pv = ParamValidator.new(nil, self.context, params[pk])
126
+ pv.parameter(i, **blk)
127
+ errs = pv.errors.store_for(i)
128
+ else
129
+ errs = ParamValidator.check(params[pk][i], context: context, &blk)
130
+ end
131
+
132
+ if errs.present?
133
+ error_items += 1
134
+ if error_items > 5
135
+ check_results[:items]
136
+ astore.push("Too Many Errors")
137
+ break
138
+ else
139
+ astore.push_to(i, errs)
140
+ end
128
141
  end
129
- else
130
- raise "items: validator can only be used with Arrays"
131
142
  end
143
+
144
+ astore
145
+ else
146
+ raise "items: validator can only be used with Arrays"
132
147
  end
133
148
  end
134
149
  end
135
150
 
136
- final_errors = {}
151
+ final_errors = ErrorStore.new
137
152
  checks.each do |check, check_prefix|
138
153
  if check_prefix == :all || check_prefix == nil
139
154
  all_results.each do |field, err_map|
140
155
  errs = err_map[check]
141
156
  next unless errs.present?
142
157
 
143
- final_errors[field] = merge_error_hashes(final_errors[field], errs)
158
+ final_errors.push_to(field, errs)
144
159
  end
145
160
  elsif check_prefix == :none
146
161
  all_results.each do |field, err_map|
147
162
  errs = err_map[check]
148
- final_errors[field] = merge_error_hashes(final_errors[field], "must NOT be #{check}") unless errs.present?
163
+ final_errors.push_to(field, "must NOT be #{check}") unless errs.present?
149
164
  end
150
165
  else
151
166
  counts = check_pass_count(check, all_results)
@@ -160,13 +175,14 @@ module Miscellany
160
175
  (counts[:passed] > 1 && check_prefix == :onem) ||
161
176
  (counts[:passed] < 1 && check_prefix == :onep)
162
177
 
163
- final_errors = merge_error_hashes(final_errors, "#{string_prefixes[check_prefix]} #{field_key} #{check}")
178
+ final_errors.push("#{string_prefixes[check_prefix]} #{field_key} must be #{check}")
164
179
  end
165
180
  end
166
181
  end
167
182
 
168
- @errors = merge_error_hashes(@errors, final_errors)
169
- final_errors
183
+ @errors.merge!(final_errors)
184
+
185
+ nil
170
186
  end
171
187
 
172
188
  alias p parameter
@@ -197,27 +213,27 @@ module Miscellany
197
213
  check_prefixes = NON_PREFIXED.include?(check) ? [nil] : Array(checks_to_run&.[](check))
198
214
  return true unless check_prefixes.present?
199
215
 
216
+ encountered_errors = false
217
+
200
218
  check_prefixes.each do |check_prefix|
201
- initial_errors = @errors
202
- @errors = []
219
+ prefixed_check = [check_prefix, check].compact.join('_')
203
220
  prefix_options = (check_prefix.nil? ? options : options&.[](check_prefix)) || {}
204
- args = trim_arguments(blk, [prefix_options[check]])
205
221
 
206
- result = yield(*args)
222
+ result = blk.call(*trim_arguments(blk, [prefix_options[check]]))
207
223
  result = "failed validation #{check}" if result == false
208
224
 
225
+ store = state[check] ||= ErrorStore.new
226
+
209
227
  if result.present? && result != true
210
228
  result = options[:message] if options&.[](:message).present?
211
- Array(result).each do |e|
212
- @errors << e
213
- end
214
- end
215
229
 
216
- state[check] = merge_error_hashes(state[check], @errors)
217
- @errors = initial_errors
230
+ store.push(result)
231
+
232
+ encountered_errors = true
233
+ end
218
234
  end
219
235
 
220
- !state[check].present?
236
+ !encountered_errors
221
237
  end
222
238
 
223
239
  def coerce_type(params, key, opts)
@@ -243,10 +259,11 @@ module Miscellany
243
259
 
244
260
  return type.call(param, options) if type.is_a?(Proc)
245
261
 
246
- if (param.is_a?(Array) && type != Array) || ((param.is_a?(Hash) || param.is_a?(ActionController::Parameters)) && type != Hash)
262
+ is_rails_parameters = defined?(ActionController::Parameters) && param.is_a?(ActionController::Parameters)
263
+ if (param.is_a?(Array) && type != Array) || ((param.is_a?(Hash) || is_rails_parameters) && type != Hash)
247
264
  raise ArgumentError
248
265
  end
249
- return param if (param.is_a?(ActionController::Parameters) && type == Hash rescue false)
266
+ return param if (is_rails_parameters && type == Hash rescue false)
250
267
 
251
268
  # Primitives
252
269
  return Integer(param) if type == Integer
@@ -374,53 +391,13 @@ module Miscellany
374
391
  skey = key.to_s
375
392
  ALL_PREFIXES.each do |pfx|
376
393
  spfx = pfx.to_s
377
- next unless skey.starts_with?("#{spfx}_")
394
+ next unless skey.start_with?("#{spfx}_")
378
395
 
379
396
  return [skey[(spfx.length + 1)..-1].to_sym, PREFIX_ALIASES[pfx] || pfx]
380
397
  end
381
398
  [key, nil]
382
399
  end
383
400
 
384
- def sub_parameter(k)
385
- @subkeys.push(k)
386
- yield
387
- ensure
388
- @subkeys.pop
389
- end
390
-
391
- def params
392
- p = @params
393
- @subkeys.each { |k| p = p[k] }
394
- p
395
- end
396
-
397
- def merge_error_hashes(target, from)
398
- target ||= []
399
- if target.is_a?(Hash)
400
- ta = []
401
- th = target
402
- else
403
- ta = target
404
- th = target[-1].is_a?(Hash) ? ta.pop : {}
405
- end
406
-
407
- if from.is_a?(Hash)
408
- from.each_pair do |k, v|
409
- th[k] = merge_error_hashes(th[k], v)
410
- end
411
- elsif from.is_a?(Array)
412
- merge_error_hashes(th, from.pop) if from[-1].is_a?(Hash)
413
- from.each { |f| ta << f }
414
- else
415
- ta << from
416
- end
417
-
418
- return th if !ta.present? && th.present?
419
-
420
- ta << th if th.present?
421
- ta
422
- end
423
-
424
401
  def merge_hashes(h1, h2)
425
402
  h2.each do |k, v|
426
403
  set_hash_key(h1, k, v)
@@ -438,5 +415,75 @@ module Miscellany
438
415
  return args if blk.arity.negative?
439
416
  args[0..(blk.arity.abs - 1)]
440
417
  end
418
+
419
+ class ErrorStore
420
+ attr_reader :errors, :fields
421
+
422
+ def initialize
423
+ @errors = []
424
+ @fields = {}
425
+ end
426
+
427
+ def push(error)
428
+ if error.is_a?(ErrorStore)
429
+ merge!(error)
430
+ elsif error.is_a?(Array)
431
+ error.each{|e| push(e) }
432
+ else
433
+ @errors << error
434
+ end
435
+ end
436
+
437
+ def push_to(field, error)
438
+ store_for(field).push(error)
439
+ end
440
+
441
+ def present?
442
+ @errors.present? || @fields.values.any?(&:present?)
443
+ end
444
+
445
+ def serialize
446
+ if @fields.values.any?(&:present?)
447
+ h = { }
448
+ h[:_SELF_] = @errors if @errors.present?
449
+ @fields.each do |k, v|
450
+ s = v.serialize
451
+ next unless s.present?
452
+ h[k] = s
453
+ end
454
+ h
455
+ elsif @errors.present?
456
+ @errors
457
+ else
458
+ nil
459
+ end
460
+ end
461
+
462
+ def merge!(other_store)
463
+ return self if other_store == self
464
+
465
+ @errors |= other_store.errors
466
+ other_store.fields.each do |k, v|
467
+ store_for(k).merge!(v)
468
+ end
469
+
470
+ self
471
+ end
472
+
473
+ def store_for(field)
474
+ if field.is_a?(Symbol) || field.is_a?(Numeric)
475
+ field = [field]
476
+ elsif field.is_a?(String)
477
+ field = field.split('.')
478
+ elsif field.is_a?(Array)
479
+ field = [*field]
480
+ end
481
+
482
+ sfield = field.shift
483
+ store = @fields[sfield.to_s] ||= ErrorStore.new
484
+ return store if field.count == 0
485
+ return store.store_for(field)
486
+ end
487
+ end
441
488
  end
442
489
  end
@@ -1,3 +1,3 @@
1
1
  module Miscellany
2
- VERSION = "0.1.2".freeze
2
+ VERSION = "0.1.5".freeze
3
3
  end