model-api 0.8.5 → 0.8.6

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.
@@ -128,7 +128,7 @@ module ModelApi
128
128
  end
129
129
  value = value.symbolize_keys if value.is_a?(Hash)
130
130
  return value unless transform_method_or_proc.respond_to?(:call)
131
- invoke_callback(transform_method_or_proc, value, opts.freeze)
131
+ invoke_callback(transform_method_or_proc, value, opts)
132
132
  end
133
133
 
134
134
  def http_status_code(status)
@@ -216,7 +216,7 @@ module ModelApi
216
216
  end
217
217
 
218
218
  def format_value(value, attr_metadata, opts)
219
- ModelApi::Utils.transform_value(value, attr_metadata[:render], opts)
219
+ transform_value(value, attr_metadata[:render], opts)
220
220
  rescue Exception => e
221
221
  Rails.logger.warn 'Error encountered formatting API output ' \
222
222
  "(\"#{e.message}\") for value: \"#{value}\"" \
@@ -227,28 +227,33 @@ module ModelApi
227
227
  def not_found_response_body(opts = {})
228
228
  response =
229
229
  {
230
- successful: false,
231
- status: :not_found,
232
- status_code: http_status_code(:not_found),
233
- errors: [{
234
- error: opts[:error] || 'No resource found',
235
- message: opts[:message] || 'No resource found at the path ' \
230
+ successful: false,
231
+ status: :not_found,
232
+ status_code: http_status_code(:not_found),
233
+ errors: [{
234
+ error: opts[:error] || 'No resource found',
235
+ message: opts[:message] || 'No resource found at the path ' \
236
236
  'provided or matching the criteria specified'
237
- }]
237
+ }]
238
238
  }
239
239
  response.to_json(opts)
240
240
  end
241
241
 
242
242
  def invoke_callback(callback, *params)
243
243
  return nil unless callback.respond_to?(:call)
244
+ callback_param_count = callback.parameters.size
245
+ if params.size >= callback_param_count + 1 && (last_param = params.last).is_a?(Hash)
246
+ # Automatically pass duplicate of final hash param (to prevent data corruption)
247
+ params = params[0..-2] + [last_param.dup]
248
+ end
244
249
  callback.send(*(([:call] + params)[0..callback.parameters.size]))
245
250
  end
246
251
 
247
252
  def common_http_headers
248
253
  {
249
- 'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate',
250
- 'Pragma' => 'no-cache',
251
- 'Expires' => 'Fri, 01 Jan 1990 00:00:00 GMT'
254
+ 'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate',
255
+ 'Pragma' => 'no-cache',
256
+ 'Expires' => 'Fri, 01 Jan 1990 00:00:00 GMT'
252
257
  }
253
258
  end
254
259
 
@@ -265,8 +270,158 @@ module ModelApi
265
270
  obj = controller.response_body.first if controller.response_body.is_a?(Array)
266
271
  obj = (JSON.parse(obj) rescue nil) if obj.present?
267
272
  opts = opts.merge(generate_body_only: true)
268
- controller.response_body = [ModelApi::Renderer.render(controller,
269
- ModelApi::Utils.ext_value(obj), opts)]
273
+ controller.response_body = [ModelApi::Renderer.render(controller, ext_value(obj), opts)]
274
+ end
275
+
276
+ def resolve_assoc_obj(parent_obj, assoc, assoc_payload, opts = {})
277
+ klass = parent_obj.class
278
+ assoc = klass.reflect_on_association(assoc) if assoc.is_a?(Symbol) || assoc.is_a?(String)
279
+ fail "Unrecognized association '#{assoc}' on class '#{klass.name}'" if assoc.nil?
280
+ assoc_class = assoc.class_name.constantize
281
+ model_metadata = model_metadata(assoc_class)
282
+ do_resolve_assoc_obj(model_metadata, assoc, assoc_class, assoc_payload, parent_obj, opts)
283
+ end
284
+
285
+ def update_api_attr(obj, attr, value, opts = {})
286
+ attr = attr.to_sym
287
+ attr_metadata = get_attr_metadata(obj, attr, opts)
288
+ begin
289
+ value = transform_value(value, attr_metadata[:parse], opts)
290
+ rescue Exception => e
291
+ Rails.logger.warn "Error encountered parsing API input for attribute \"#{attr}\" " \
292
+ "(\"#{e.message}\"): \"#{value.to_s.first(1000)}\" ... using raw value instead."
293
+ end
294
+ begin
295
+ if attr_metadata[:type] == :association && attr_metadata[:parse].blank?
296
+ attr_metadata = opts[:attr_metadata]
297
+ assoc = attr_metadata[:association]
298
+ if assoc.macro == :has_many
299
+ update_has_many_assoc(obj, attr, value, opts)
300
+ elsif assoc.macro == :belongs_to
301
+ update_belongs_to_assoc(obj, attr, value, opts)
302
+ else
303
+ add_ignored_field(opts[:ignored_fields], attr, value, attr_metadata)
304
+ end
305
+ else
306
+ set_api_attr(obj, attr, value, opts)
307
+ end
308
+ rescue Exception => e
309
+ handle_api_setter_exception(e, obj, attr_metadata, opts)
310
+ end
311
+ end
312
+
313
+ def find_by_id_attrs(id_attributes, assoc_class, assoc_payload)
314
+ return nil unless id_attributes.present?
315
+ id_attributes.each do |id_attr_set|
316
+ query = nil
317
+ id_attr_set.each do |id_attr|
318
+ unless assoc_payload.include?(id_attr.to_s)
319
+ query = nil
320
+ break
321
+ end
322
+ query = (query || assoc_class).where(id_attr => assoc_payload[id_attr.to_s])
323
+ end
324
+ return query unless query.nil?
325
+ end
326
+ nil
327
+ end
328
+
329
+ def add_ignored_field(ignored_fields, attr, value, attr_metadata)
330
+ return unless ignored_fields.is_a?(Array)
331
+ attr_metadata ||= {}
332
+ external_attr = ext_attr(attr, attr_metadata)
333
+ return unless external_attr.present?
334
+ ignored_fields << { external_attr => value }
335
+ end
336
+
337
+ def apply_updates(obj, req_obj, operation, opts = {})
338
+ opts = opts.merge(object: opts[:object] || obj)
339
+ metadata = filtered_ext_attrs(opts[:api_attr_metadata] ||
340
+ filtered_attrs(obj, operation, opts), operation, opts)
341
+ set_context_attrs(obj, opts)
342
+ req_obj.each do |attr, value|
343
+ attr = attr.to_sym
344
+ attr_metadata = metadata[attr]
345
+ unless attr_metadata.present?
346
+ add_ignored_field(opts[:ignored_fields], attr, value, attr_metadata)
347
+ next
348
+ end
349
+ update_api_attr(obj, attr, value, opts.merge(attr_metadata: attr_metadata))
350
+ end
351
+ end
352
+
353
+ def extract_error_msgs(obj, opts = {})
354
+ object_errors = []
355
+ attr_prefix = opts[:attr_prefix] || ''
356
+ api_metadata = opts[:api_attr_metadata] || api_attrs(obj.class)
357
+ obj.errors.each do |attr, attr_errors|
358
+ attr_errors = [attr_errors] unless attr_errors.is_a?(Array)
359
+ attr_errors.each do |error|
360
+ attr_metadata = api_metadata[attr] || {}
361
+ qualified_attr = "#{attr_prefix}#{ext_attr(attr, attr_metadata)}"
362
+ assoc_errors = nil
363
+ if attr_metadata[:type] == :association
364
+ assoc_errors = extract_assoc_error_msgs(obj, attr, opts.merge(
365
+ attr_metadata: attr_metadata))
366
+ end
367
+ if assoc_errors.present?
368
+ object_errors += assoc_errors
369
+ else
370
+ error_hash = {}
371
+ error_hash[:object] = attr_prefix if attr_prefix.present?
372
+ error_hash[:attribute] = qualified_attr unless attr == :base
373
+ object_errors << error_hash.merge(error: error,
374
+ message: (attr == :base ? error : "#{qualified_attr} #{error}"))
375
+ end
376
+ end
377
+ end
378
+ object_errors
379
+ end
380
+
381
+ def save_obj(obj, opts = {})
382
+ operation = opts[:operation] || (obj.new_record? ? :create : :update)
383
+ model_metadata = opts.delete(:model_metadata) || model_metadata(obj.class)
384
+ before_validate_callbacks(model_metadata, obj, opts)
385
+ validate_operation(obj, operation, opts.merge(model_metadata: model_metadata))
386
+ validate_preserving_existing_errors(obj)
387
+ new_obj = obj.new_record?
388
+ before_save_callbacks(model_metadata, obj, new_obj, opts)
389
+ obj.instance_variable_set(:@readonly, false) if obj.instance_variable_get(:@readonly)
390
+ successful = obj.save unless obj.errors.present?
391
+ after_save_callbacks(model_metadata, obj, new_obj, opts) if successful
392
+ successful
393
+ end
394
+
395
+ def validate_operation(obj, operation, opts = {})
396
+ klass = find_class(obj, opts)
397
+ model_metadata = opts[:model_metadata] || model_metadata(klass)
398
+ return nil unless operation.present?
399
+ if obj.nil?
400
+ invoke_callback(model_metadata[:"validate_#{operation}"], opts)
401
+ else
402
+ invoke_callback(model_metadata[:"validate_#{operation}"], obj, opts)
403
+ end
404
+ end
405
+
406
+ def process_collection_includes(collection, opts = {})
407
+ klass = find_class(collection, opts)
408
+ metadata = filtered_ext_attrs(klass, opts[:operation] || :index, opts)
409
+ model_metadata = opts[:model_metadata] || model_metadata(klass)
410
+ includes = []
411
+ if (metadata_includes = model_metadata[:collection_includes]).is_a?(Array)
412
+ includes += metadata_includes.map(&:to_sym)
413
+ end
414
+ metadata.each do |_attr, attr_metadata|
415
+ includes << attr_metadata[:key] if attr_metadata[:type] == :association
416
+ end
417
+ includes = includes.compact.uniq
418
+ collection = collection.includes(includes) if includes.present?
419
+ collection
420
+ end
421
+
422
+ def find_class(obj, opts = {})
423
+ return nil if obj.nil?
424
+ opts[:class] || (obj.respond_to?(:klass) ? obj.klass : obj.class)
270
425
  end
271
426
 
272
427
  private
@@ -279,8 +434,7 @@ module ModelApi
279
434
  test_value = test_value[filter_value]
280
435
  end
281
436
  if test_value.respond_to?(:call)
282
- return ModelApi::Utils.invoke_callback(test_value, klass,
283
- opts.merge(filter_type => filter_value).freeze)
437
+ return invoke_callback(test_value, klass, opts.merge(filter_type => filter_value))
284
438
  end
285
439
  filter_value == test_value
286
440
  end
@@ -303,7 +457,7 @@ module ModelApi
303
457
 
304
458
  def include_item?(metadata, obj, operation, opts = {})
305
459
  return false unless metadata.is_a?(Hash)
306
- return false unless include_item_meets_admin_criteria?(metadata, obj, opts)
460
+ return false unless include_item_meets_admin_criteria?(metadata, obj, operation, opts)
307
461
  # Stop here re: filter/sort params, as following checks involve payloads/responses only
308
462
  return eval_bool(obj, metadata[:filter], opts) if operation == :filter
309
463
  return eval_bool(obj, metadata[:sort], opts) if operation == :sort
@@ -350,13 +504,11 @@ module ModelApi
350
504
  end]
351
505
  end
352
506
 
353
- def include_item_meets_admin_criteria?(metadata, obj, opts = {})
507
+ def include_item_meets_admin_criteria?(metadata, obj, operation, opts = {})
354
508
  if eval_bool(obj, metadata[:admin_only], opts)
355
- if opts.include?(:admin)
356
- return false unless opts[:admin]
357
- else
358
- return false unless opts[:user].try(:admin_api_user?)
359
- end
509
+ return true if opts[:admin]
510
+ return false unless [:create, :update, :patch].include?(operation)
511
+ return opts[:admin_user] ? true : false
360
512
  end
361
513
  return false if eval_bool(obj, metadata[:admin_content], opts) && !opts[:admin_content]
362
514
  true
@@ -387,6 +539,256 @@ module ModelApi
387
539
  end
388
540
  true
389
541
  end
542
+
543
+ def update_has_many_assoc(obj, attr, value, opts = {})
544
+ attr_metadata = opts[:attr_metadata]
545
+ assoc = attr_metadata[:association]
546
+ assoc_class = assoc.class_name.constantize
547
+ model_metadata = model_metadata(assoc_class)
548
+ value_array = value.to_a rescue nil
549
+ unless value_array.is_a?(Array)
550
+ obj.errors.add(attr, 'must be supplied as an array of objects')
551
+ return
552
+ end
553
+ opts = opts.merge(model_metadata: model_metadata)
554
+ opts[:ignored_fields] = [] if opts.include?(:ignored_fields)
555
+ assoc_objs = []
556
+ value_array.each_with_index do |assoc_payload, index|
557
+ opts[:ignored_fields].clear if opts.include?(:ignored_fields)
558
+ assoc_objs << update_has_many_assoc_obj(obj, assoc, assoc_class, assoc_payload,
559
+ opts.merge(model_metadata: model_metadata))
560
+ if opts[:ignored_fields].present?
561
+ external_attr = ext_attr(attr, attr_metadata)
562
+ opts[:ignored_fields] << { "#{external_attr}[#{index}]" => opts[:ignored_fields] }
563
+ end
564
+ end
565
+ set_api_attr(obj, attr, assoc_objs, opts)
566
+ end
567
+
568
+ def update_has_many_assoc_obj(parent_obj, assoc, assoc_class, assoc_payload, opts = {})
569
+ model_metadata = opts[:model_metadata] || model_metadata(assoc_class)
570
+ assoc_obj, assoc_oper, assoc_opts = resolve_has_many_assoc_obj(model_metadata, assoc,
571
+ assoc_class, assoc_payload, parent_obj, opts)
572
+ if (inverse_assoc = assoc.options[:inverse_of]).present? &&
573
+ assoc_obj.respond_to?("#{inverse_assoc}=")
574
+ assoc_obj.send("#{inverse_assoc}=", parent_obj)
575
+ elsif !parent_obj.new_record? && assoc_obj.respond_to?("#{assoc.foreign_key}=")
576
+ assoc_obj.send("#{assoc.foreign_key}=", obj.id)
577
+ end
578
+ apply_updates(assoc_obj, assoc_payload, assoc_oper, assoc_opts)
579
+ invoke_callback(model_metadata[:after_initialize], assoc_obj,
580
+ assoc_opts.merge(operation: assoc_oper))
581
+ assoc_obj
582
+ end
583
+
584
+ def resolve_has_many_assoc_obj(model_metadata, assoc, assoc_class, assoc_payload,
585
+ parent_obj, opts = {})
586
+ assoc_obj = do_resolve_assoc_obj(model_metadata, assoc, assoc_class, assoc_payload,
587
+ parent_obj, opts.merge(auto_create: true))
588
+ if assoc_obj.new_record?
589
+ assoc_oper = :create
590
+ opts[:create_opts] ||= opts.merge(api_attr_metadata: filtered_attrs(
591
+ assoc_class, :create, opts))
592
+ assoc_opts = opts[:create_opts]
593
+ else
594
+ assoc_oper = :update
595
+ opts[:update_opts] ||= opts.merge(api_attr_metadata: filtered_attrs(
596
+ assoc_class, :update, opts))
597
+
598
+ assoc_opts = opts[:update_opts]
599
+ end
600
+ [assoc_obj, assoc_oper, assoc_opts]
601
+ end
602
+
603
+ def update_belongs_to_assoc(parent_obj, attr, assoc_payload, opts = {})
604
+ unless assoc_payload.is_a?(Hash)
605
+ parent_obj.errors.add(attr, 'must be supplied as an object')
606
+ return
607
+ end
608
+ attr_metadata = opts[:attr_metadata]
609
+ assoc = attr_metadata[:association]
610
+ assoc_class = assoc.class_name.constantize
611
+ model_metadata = model_metadata(assoc_class)
612
+ assoc_obj, assoc_oper, assoc_opts = resolve_belongs_to_assoc_obj(model_metadata, assoc,
613
+ assoc_class, assoc_payload, parent_obj, opts)
614
+ apply_updates(assoc_obj, assoc_payload, assoc_oper, assoc_opts)
615
+ invoke_callback(model_metadata[:after_initialize], assoc_obj,
616
+ opts.merge(operation: assoc_oper))
617
+ if assoc_opts[:ignored_fields].present?
618
+ external_attr = ext_attr(attr, attr_metadata)
619
+ opts[:ignored_fields] << { external_attr.to_s => assoc_opts[:ignored_fields] }
620
+ end
621
+ set_api_attr(parent_obj, attr, assoc_obj, opts)
622
+ end
623
+
624
+ def resolve_belongs_to_assoc_obj(model_metadata, assoc, assoc_class, assoc_payload,
625
+ parent_obj, opts = {})
626
+ assoc_opts = opts[:ignored_fields].is_a?(Array) ? opts.merge(ignored_fields: []) : opts
627
+ assoc_obj = do_resolve_assoc_obj(model_metadata, assoc, assoc_class, assoc_payload,
628
+ parent_obj, opts.merge(auto_create: true))
629
+ assoc_oper = assoc_obj.new_record? ? :create : :update
630
+ assoc_opts = assoc_opts.merge(
631
+ api_attr_metadata: filtered_attrs(assoc_class, assoc_oper, opts))
632
+ return [assoc_obj, assoc_oper, assoc_opts]
633
+ end
634
+
635
+ def do_resolve_assoc_obj(model_metadata, assoc, assoc_class, assoc_payload, parent_obj,
636
+ opts = {})
637
+ if opts[:resolve].try(:respond_to?, :call)
638
+ assoc_obj = invoke_callback(opts[:resolve], assoc_payload, opts.merge(
639
+ parent: parent_obj, association: assoc, association_metadata: model_metadata))
640
+ else
641
+ assoc_obj = find_by_id_attrs(model_metadata[:id_attributes], assoc_class, assoc_payload)
642
+ assoc_obj = assoc_obj.first unless assoc_obj.nil? || assoc_obj.count != 1
643
+ assoc_obj ||= assoc_class.new if opts[:auto_create]
644
+ end
645
+ assoc_obj
646
+ end
647
+
648
+ def set_api_attr(obj, attr, value, opts)
649
+ attr = attr.to_sym
650
+ attr_metadata = get_attr_metadata(obj, attr, opts)
651
+ internal_field = attr_metadata[:key] || attr
652
+ setter = attr_metadata[:setter] || "#{(internal_field)}="
653
+ unless obj.respond_to?(setter)
654
+ Rails.logger.warn "Error encountered assigning API input for attribute \"#{attr}\" " \
655
+ '(setter not found): skipping.'
656
+ add_ignored_field(opts[:ignored_fields], attr, value, attr_metadata)
657
+ return
658
+ end
659
+ obj.send(setter, value)
660
+ end
661
+
662
+ def handle_api_setter_exception(e, obj, attr_metadata, opts = {})
663
+ return unless attr_metadata.is_a?(Hash)
664
+ on_exception = attr_metadata[:on_exception]
665
+ fail e unless on_exception.present?
666
+ on_exception = { Exception => on_exception } unless on_exception.is_a?(Hash)
667
+ on_exception.each do |klass, handler|
668
+ klass = klass.to_s.constantize rescue nil unless klass.is_a?(Class)
669
+ next unless klass.is_a?(Class) && e.is_a?(klass)
670
+ if handler.respond_to?(:call)
671
+ invoke_callback(handler, obj, e, opts)
672
+ elsif handler.present?
673
+ # Presume handler is an error message in this case
674
+ obj.errors.add(attr_metadata[:key], handler.to_s)
675
+ else
676
+ add_ignored_field(opts[:ignored_fields], nil, opts[:value],
677
+ attr_metadata)
678
+ end
679
+ break
680
+ end
681
+ end
682
+
683
+ def get_attr_metadata(obj, attr, opts)
684
+ attr_metadata = opts[:attr_metadata]
685
+ return attr_metadata unless attr_metadata.nil?
686
+ operation = opts[:operation] || :update
687
+ metadata = filtered_ext_attrs(opts[:api_attr_metadata] ||
688
+ filtered_attrs(obj, operation, opts), operation, opts)
689
+ metadata[attr] || {}
690
+ end
691
+
692
+ def set_context_attrs(obj, opts = {})
693
+ klass = (obj.class < ActiveRecord::Base ? obj.class : nil)
694
+ (opts[:context] || {}).each do |key, value|
695
+ begin
696
+ setter = "#{key}="
697
+ next unless obj.respond_to?(setter)
698
+ if (column = klass.try(:columns_hash).try(:[], key.to_s)).present?
699
+ case column.type
700
+ when :integer, :primary_key then
701
+ obj.send("#{key}=", value.to_i)
702
+ when :decimal, :float then
703
+ obj.send("#{key}=", value.to_f)
704
+ else
705
+ obj.send(setter, value.to_s)
706
+ end
707
+ else
708
+ obj.send(setter, value.to_s)
709
+ end
710
+ rescue Exception => e
711
+ Rails.logger.warn "Error encountered assigning context parameter #{key} to " \
712
+ "'#{value}' (skipping): \"#{e.message}\")."
713
+ end
714
+ end
715
+ end
716
+
717
+ # rubocop:disable Metrics/MethodLength
718
+ def extract_assoc_error_msgs(obj, attr, opts)
719
+ object_errors = []
720
+ attr_metadata = opts[:attr_metadata] || {}
721
+ processed_assoc_objects = {}
722
+ assoc = attr_metadata[:association]
723
+ assoc_class = assoc.class_name.constantize
724
+ external_attr = ext_attr(attr, attr_metadata)
725
+ attr_metadata_create = attr_metadata_update = nil
726
+ if assoc.macro == :has_many
727
+ obj.send(attr).each_with_index do |assoc_obj, index|
728
+ next if processed_assoc_objects[assoc_obj]
729
+ processed_assoc_objects[assoc_obj] = true
730
+ attr_prefix = "#{external_attr}[#{index}]."
731
+ if assoc_obj.new_record?
732
+ attr_metadata_create ||= filtered_attrs(assoc_class, :create, opts)
733
+ object_errors += extract_error_msgs(assoc_obj, opts.merge(
734
+ attr_prefix: attr_prefix, api_attr_metadata: attr_metadata_create))
735
+ else
736
+ attr_metadata_update ||= filtered_attrs(assoc_class, :update, opts)
737
+ object_errors += extract_error_msgs(assoc_obj, opts.merge(
738
+ attr_prefix: attr_prefix, api_attr_metadata: attr_metadata_update))
739
+ end
740
+ end
741
+ else
742
+ assoc_obj = obj.send(attr)
743
+ return object_errors unless assoc_obj.present? && !processed_assoc_objects[assoc_obj]
744
+ processed_assoc_objects[assoc_obj] = true
745
+ attr_prefix = "#{external_attr}->"
746
+ if assoc_obj.new_record?
747
+ attr_metadata_create ||= filtered_attrs(assoc_class, :create, opts)
748
+ object_errors += extract_error_msgs(assoc_obj, opts.merge(
749
+ attr_prefix: attr_prefix, api_attr_metadata: attr_metadata_create))
750
+ else
751
+ attr_metadata_update ||= filtered_attrs(assoc_class, :update, opts)
752
+ object_errors += extract_error_msgs(assoc_obj, opts.merge(
753
+ attr_prefix: attr_prefix, api_attr_metadata: attr_metadata_update))
754
+ end
755
+ end
756
+ object_errors
757
+ end
758
+
759
+ # rubocop:enable Metrics/MethodLength
760
+
761
+ def before_validate_callbacks(model_metadata, obj, opts)
762
+
763
+ invoke_callback(model_metadata[:before_validate], obj, opts)
764
+ invoke_callback(opts[:before_validate], obj, opts)
765
+ end
766
+
767
+ def before_save_callbacks(model_metadata, obj, new_obj, opts)
768
+ invoke_callback(model_metadata[:before_create], obj, opts) if new_obj
769
+ invoke_callback(opts[:before_create], obj, opts) if new_obj
770
+ invoke_callback(model_metadata[:before_save], obj, opts)
771
+ invoke_callback(opts[:before_save], obj, opts)
772
+ end
773
+
774
+ def after_save_callbacks(model_metadata, obj, new_obj, opts)
775
+ invoke_callback(model_metadata[:after_create], obj, opts) if new_obj
776
+ invoke_callback(opts[:after_create], obj, opts) if new_obj
777
+ invoke_callback(model_metadata[:after_save], obj, opts)
778
+ invoke_callback(opts[:after_save], obj, opts)
779
+ end
780
+
781
+ def validate_preserving_existing_errors(obj)
782
+ if obj.errors.present?
783
+ errors = obj.errors.messages.dup
784
+ obj.valid?
785
+ errors = obj.errors.messages.merge(errors)
786
+ obj.errors.clear
787
+ errors.each { |field, error| obj.errors.add(field, error) }
788
+ else
789
+ obj.valid?
790
+ end
791
+ end
390
792
  end
391
793
  end
392
794
  end