module Hoodoo::ActiveRecord::ManuallyDated::ClassMethods
Collection of class methods that get defined on an including class via Hoodoo::ActiveRecord::ManuallyDated::included
.
Public Instance Methods
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.
# 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
If a prior call has been made to manual_dating_enabled
then this method returns true
, else false
.
# 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
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 theHoodoo::Services::Implementation
instance methods that a resource subclass implements.
# 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
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.
# 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
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.
# File lib/hoodoo/active/active_record/manually_dated.rb, line 577 def manually_dated_contemporary where( :effective_end => DATE_MAXIMUM ) end
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 theHoodoo::Services::Implementation
instance methods that a resource subclass implements. This is used to obtain the record’sUUID
unless overridden (see named parameter list).
Additional named parameters are:
ident
-
UUID
(32-digitid
column value) of the record to be updated. If present, thecontext
parameter may benil
. 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.
# 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
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.
# File lib/hoodoo/active/active_record/manually_dated.rb, line 566 def manually_dated_historic where.not( :effective_end => DATE_MAXIMUM ) end
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
, anActiveRecord
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 theHoodoo::Services::Implementation
instance methods that a resource subclass implements. This is used to find the record’sUUID
and new attribute information unless overridden (see named parameter list).
Additional named parameters are:
ident
-
UUID
(32-digitid
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.
# 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