Module: Hoodoo::ActiveRecord::ManuallyDated::ClassMethods

Defined in:
lib/hoodoo/active/active_record/manually_dated.rb

Overview

Collection of class methods that get defined on an including class via Hoodoo::ActiveRecord::ManuallyDated::included.

Instance Method Summary collapse

Instance Method Details

#manual_dating_enabledObject

Activate manually-driven historic dating for this model.

See the module documentation for Hoodoo::ActiveRecord::ManuallyDated for full information on dating, column/attribute requirements and so forth.

When dating is enabled, a before_save filter will ensure that the record’s created_at and updated_at fields are manually set to the current time (“now”), if not already set by the time the filter is run. The record’s effective_start time is set to match created_at if not already set and effective_end is set to Hoodoo::ActiveRecord::ManuallyDated::DATE_MAXIMUM if not already set. The record’s uuid resource UUID is set to the value of the id column if not already set, which is useful for new records but should never happen for history-savvy updates performed by this mixin’s code.



436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# File 'lib/hoodoo/active/active_record/manually_dated.rb', line 436

def manual_dating_enabled
  self.nz_co_loyalty_hoodoo_manually_dated = true

  # This is the 'tightest'/innermost callback available for creation.
  # Intentionally have nothing for updates/deletes as the high level
  # API here must be used; we don't want to introduce any more magic.

  before_create do
    now = Time.now.utc.round( SECONDS_DECIMAL_PLACES )

    self.created_at      ||= now
    self.updated_at      ||= now
    self.effective_start ||= self.created_at
    self.effective_end   ||= DATE_MAXIMUM

    %i{ created_at updated_at effective_start effective_end }.each do | attr |
      value   = self.send( attr )
      rounded = value.to_time.round( SECONDS_DECIMAL_PLACES )

      self.send( "#{ attr }=", rounded ) if rounded != value
    end
  end

  # This is very similar to the UUID mixin, but works on the 'uuid'
  # column. With manual dating, ActiveRecord's quirks with changing
  # the primary key column, but still doing weird things with an
  # attribute and accessor called "id", forces us to give up on any
  # notion of changing the primary key. Keep "id" unique. This means
  # the UUID mixin, if in use, is now setting the *real* per row
  # unique key, while the "uuid" contains the UUID that should be
  # rendered for the resource representation and will appear in more
  # than one database row if the record has history entries. Thus,
  # the validation is scoped to be unique only per "effective_end"
  # value.
  #
  # Since the X-Resource-UUID header may be used and result in an
  # attribute "id" being specified inbound for new records, we take
  # any value of "id" if present and use that in preference to a
  # totally new UUID in order to deal with that use case.

  validate( :on => :create ) do
    self.uuid ||= self.id || Hoodoo::UUID.generate()
  end

  validates(
    :uuid,
    {
      :uuid       => true,
      :presence   => true,
      :uniqueness => { :scope => :effective_end },
    }
  )

  # We must specify an acquisition scope that's based on the "uuid"
  # column only and *not* the "id" column.

  acquire_with_id_substitute( :uuid )

  # Finally, enable the monkey patch to the Finder module's
  # '#acquire_in' class method, if need be.

  if self.include?( Hoodoo::ActiveRecord::Finder )
    Hoodoo::Monkey.register(
      target_unit:      self,
      extension_module: Hoodoo::Monkey::Patch::ActiveRecordManuallyDatedFinderAdditions
    )

    Hoodoo::Monkey.enable( extension_module: Hoodoo::Monkey::Patch::ActiveRecordManuallyDatedFinderAdditions )
  end

end

#manual_dating_enabled?Boolean

If a prior call has been made to #manual_dating_enabled then this method returns true, else false.

Returns:

  • (Boolean)


511
512
513
# File 'lib/hoodoo/active/active_record/manually_dated.rb', line 511

def manual_dating_enabled?
  return self.nz_co_loyalty_hoodoo_manually_dated == true
end

#manually_dated(context) ⇒ Object

Return an ActiveRecord::Relation instance which only matches records that are relevant/effective at the date/time in the value of context.request.dated_at within the given context. If this value is nil then the current time in UTC is used.

Manual historic dating must have been previously activated through a call to #dating_enabled, else results will be undefined.

context

Hoodoo::Services::Context instance describing a call context. This is typically a value passed to one of the Hoodoo::Services::Implementation instance methods that a resource subclass implements.



528
529
530
531
# File 'lib/hoodoo/active/active_record/manually_dated.rb', line 528

def manually_dated( context )
  date_time = context.request.dated_at || Time.now
  return self.manually_dated_at( date_time )
end

#manually_dated_at(date_time = Time.now) ⇒ Object

Return an ActiveRecord::Relation instance which only matches records that are relevant/effective at the given date/time. If this value is nil then the current time in UTC is used.

Manual historic dating must have been previously activated through a call to #dating_enabled, else results will be undefined.

date_time

(Optional) A Time or DateTime instance, or a String that can be converted to a DateTime instance, for which the “effective dated” scope is to be constructed.



544
545
546
547
548
549
550
551
552
553
554
555
556
557
# File 'lib/hoodoo/active/active_record/manually_dated.rb', line 544

def manually_dated_at( date_time = Time.now )
  date_time  = date_time.to_time.utc.round( SECONDS_DECIMAL_PLACES )

  arel_table = self.arel_table()
  arel_query = arel_table[ :effective_start ].lteq( date_time ).
               and(
                 arel_table[ :effective_end ].gt( date_time )
                 # .or(
                 #   arel_table[ :effective_end ].eq( nil )
                 # )
               )

  where( arel_query )
end

#manually_dated_contemporaryObject

Return an ActiveRecord::Relation instance which only matches records that are ‘current’. The historic/past records for any given UUID will never be included in the scope.

Manual historic dating must have been previously activated through a call to #dating_enabled, else results will be undefined.



577
578
579
# File 'lib/hoodoo/active/active_record/manually_dated.rb', line 577

def manually_dated_contemporary
  where( :effective_end => DATE_MAXIMUM )
end

#manually_dated_destruction_in(context, ident: context.request.ident, scope: all()) ⇒ Object

Analogous to #manually_dated_update_in and with the same return value and exception generation semantics, so see that method for those details.

This particular method soft-deletes a record. It moves the ‘current’ entry to being an ‘historic’ entry as in #manually_dated_update_in, but does not then generate any new ‘current’ record. Returns nil if the record couldn’t be found to start with, else returns the found and soft-deleted / now-historic model instance.

Since no actual “hard” record deletion takes place, traditional ActiveRecord concerns of delete versus destroy or of dependency chain destruction do not apply. No callbacks or validations are run when the record is updated (via ActiveRecord’s #update_column). A failure to update the record will result in an unhandled exception. No change is made to the updated_at column value.

Unnamed parameters are:

context

Hoodoo::Services::Context instance describing a call context. This is typically a value passed to one of the Hoodoo::Services::Implementation instance methods that a resource subclass implements. This is used to obtain the record’s UUID unless overridden (see named parameter list).

Additional named parameters are:

ident

UUID (32-digit id column value) of the record to be updated. If present, the context parameter may be nil. If omitted, context.request.ident is used.

scope

ActiveRecord::Relation instance providing the scope to use for database locks and acquiring the record to update. Defaults to #acquisition_scope for the prevailing ident value.



756
757
758
759
760
761
762
763
764
765
766
767
768
769
# File 'lib/hoodoo/active/active_record/manually_dated.rb', line 756

def manually_dated_destruction_in( context,
                                   ident: context.request.ident,
                                   scope: all() )

  # See #manually_dated_update_in implementation for rationale.
  #
  return self.transaction do

    record = scope.manually_dated_contemporary().lock( true ).acquire( ident )
    record.update_column( :effective_end, Time.now.utc ) unless record.nil?
    record

  end
end

#manually_dated_historicObject

Return an ActiveRecord::Relation instance which only matches records that are from the past. The ‘current’ record for any given UUID will never be included by the scope.

Manual historic dating must have been previously activated through a call to #dating_enabled, else results will be undefined.



566
567
568
# File 'lib/hoodoo/active/active_record/manually_dated.rb', line 566

def manually_dated_historic
  where.not( :effective_end => DATE_MAXIMUM )
end

#manually_dated_update_in(context, ident: context.request.ident, attributes: context.request.body, scope: all()) ⇒ Object

Update a record with manual historic dating. This means that the ‘current’ / most recent record is turned into a historic entry via setting its effective_end date, a duplicate is made and any new attribute values are set in this duplicate. This new record is then saved as the ‘current’ version. A transaction containing a database lock over all history rows for the record via its UUID (id column) is used to provide concurrent access safety.

The return value is complex:

  • If nil, the record that was to be updated could not be found.

  • If not nil, an ActiveRecord model instance is returned. This is the new ‘current’ record, but it might not be saved; validation errors may have happened. You need to check for this before proceeding. This will not be the same model instance found for the original, most recent / current record.

If attempts to update the previous, now-historic record’s effective end date fail, an exception may be thrown as the failure condition is unexpected (it will almost certainly be because of a database connection failure). You might need to call this method from a block with a rescue clause if you wish to handle those elegantly, but it is probably a serious failure and the generally recommended behaviour is to just let Hoodoo’s default exception handler catch the exception and return an HTTP 500 response to the API caller.

Unnamed parameters are:

context

Hoodoo::Services::Context instance describing a call context. This is typically a value passed to one of the Hoodoo::Services::Implementation instance methods that a resource subclass implements. This is used to find the record’s UUID and new attribute information unless overridden (see named parameter list).

Additional named parameters are:

ident

UUID (32-digit id column value) of the record to be updated. If omitted, context.request.ident is used.

attributes

Hash of attributes to write (via ActiveRecord’s assign_attributes method) in order to perform the update. If omitted, context.request.body is used.

If both ident and attributes are supplied then the first parameter providing context may be nil.

scope

ActiveRecord::Relation instance providing the scope to use for database locks and acquiring the record to update. Defaults to #acquisition_scope for the prevailing ident value.



633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
# File 'lib/hoodoo/active/active_record/manually_dated.rb', line 633

def manually_dated_update_in( context,
                              ident:      context.request.ident,
                              attributes: context.request.body,
                              scope:      all() )

  new_record        = nil
  retried_operation = false

  begin

    # 'requires_new' => exceptions in nested transactions will cause
    # rollback; see the comment documentation for the Writer module's
    # "persist_in" method for details.
    #
    self.transaction( :requires_new => true ) do

      lock_scope = scope.acquisition_scope( ident ).lock( true )
      self.connection.execute( lock_scope.to_sql )

      original = scope.manually_dated_contemporary().acquire( ident )
      break if original.nil?

      # The only way this can fail is by throwing an exception.
      #
      original.update_column( :effective_end, Time.now.utc.round( SECONDS_DECIMAL_PLACES ) )

      # When you 'dup' a live model, ActiveRecord clears the 'created_at'
      # and 'updated_at' values, and the 'id' column - even if you set
      # the "primary_key=..." value on the model to something else. Put
      # it all back together again.
      #
      # Duplicate, apply attributes, then overwrite anything that is
      # vital for dating so that the inbound attributes hash can't cause
      # any inconsistencies.
      #
      new_record = original.dup
      new_record.assign_attributes( attributes )

      new_record.id              = nil
      new_record.uuid            = original.uuid
      new_record.created_at      = original.created_at
      new_record.updated_at      = original.effective_end # (sic.)
      new_record.effective_start = original.effective_end # (sic.)
      new_record.effective_end   = DATE_MAXIMUM

      # Save with validation but no exceptions. The caller examines the
      # returned object to see if there were any validation errors.
      #
      new_record.save()

      # Must roll back if the new record didn't save, to undo the
      # 'effective_end' column update on 'original' earlier.
      #
      raise ::ActiveRecord::Rollback if new_record.errors.present?
    end

  rescue ::ActiveRecord::StatementInvalid => exception

    # By observation, PostgreSQL can start worrying about deadlocks
    # with the above. Leading theory is that it's "half way through"
    # inserting the new row when someone else comes along and waits
    # on the lock, but that new waiting thread has also ended up
    # capturing a lock on the half-inserted row (since inserting
    # involves lots of internal steps and locks).
    #
    # In such a case, retry. But only do so once; then give up.
    #
    if retried_operation == false && exception.message.downcase.include?( 'deadlock' )
      retried_operation = true

      # Give other Threads time to run, maximising chance of deadlock
      # being resolved before retry.
      #
      sleep( 0.1 )
      retry

    else
      raise exception

    end

  end # "begin"..."rescue"..."end"

  return new_record
end