Skip to content

Instantly share code, notes, and snippets.

@reggieb
Last active August 29, 2015 14:08
Show Gist options
  • Select an option

  • Save reggieb/518315ed0cc83c4f6471 to your computer and use it in GitHub Desktop.

Select an option

Save reggieb/518315ed0cc83c4f6471 to your computer and use it in GitHub Desktop.
Paper Trail: restoring associated objects with main object.
class VersionStruct
class << self
def build_from(version, args = {})
object = deserialize(version.object)
args[:changes] = deserialize(version.object_changes)
args[:version] = version
version_struct = new(object, args)
version_struct.mimic
end
def deserialize(text)
PaperTrail.serializer.load(text) if text
end
end
attr_reader :object, :changes, :version, :serialized, :associations, :args, :object_class
def initialize(object, args = {})
@object = ihash(object)
@changes = ihash(args[:changes] || {})
@version = args[:version]
@serialized = args[:serialized] || []
@associations = ihash(args[:associations] || {})
@object_class = self.class.const_get(args[:object_class]) if args[:object_class]
@args = args
end
def mimic
@mimic ||= build_mimic
end
private
class MimicStruct < OpenStruct
def human_attribute_name(name)
object_class.human_attribute_name(name) if object_class
end
end
def empty_item
MimicStruct.new(
object_class: object_class.name
)
end
def build_mimic
MimicStruct.new(
mimic_attributes
)
end
def mimic_attributes
attrs = object_or_before_change
attrs[:changes] = deserialized_changes
attrs[:version] = version if version
add_associations(attrs)
attrs[:object_class] = object_class
attrs
end
def deserialized_changes
return changes if serialized.empty?
deserialized_changes = changes.clone
serialized.each do |attribute|
next unless changes[attribute]
deserialized_changes[attribute] = changes[attribute].collect{|a| YAML.load(a) if a}
end
deserialized_changes
end
def deserialized_object
deserialized_object = object.clone
return deserialized_object if serialized.empty?
serialized.each do |attribute|
deserialized_object[attribute] = YAML.load(object[attribute]) if object[attribute]
end
deserialized_object
end
def object_or_before_change
before_change.merge(deserialized_object)
end
def before_change
deserialized_changes.inject({}) do |hash, key_value|
attribute, changes = key_value
hash[attribute] = changes.first
hash
end
end
def add_associations(attrs)
associations.each do |association, objects|
mimics = objects.keys.collect do |object_id|
version_id = objects[object_id]
object_version = PaperTrail::Version.find_by(id: version_id)
if version_id and object_version
self.class.build_from(object_version, object_class: object_version.item_type)
else
get_item_or_restore_deleted(association, object_id)
end
end
attrs[association] = single_association?(association) ? mimics.first : mimics.compact
end
end
def single_association?(association)
[:belongs_to, :has_one].include? reflection_on(association).try(:macro)
end
def get_item_or_restore_deleted(association, object_id)
get_item(association, object_id) || rebuild_deleted(association, object_id)
end
def get_item(association, object_id)
object_association = version.item.send(association)
return object_association.reify if object_association.respond_to?(:reify)
current = object_association.find_by(id: object_id)
return unless current
at_change = current.version_at(version.created_at)
at_change || current
end
def rebuild_deleted(association, object_id)
reflection = reflection_on(association)
return empty_item unless reflection
version = PaperTrail::Version.order('created_at DESC').find_by(item_type: reflection.klass, item_id: object_id)
return empty_item unless version
version.reify
end
def reflection_on(association)
if version
version.item.class.reflect_on_association(association.to_sym)
else
object_class.reflect_on_association(association.to_sym)
end
end
def ihash(hash = {})
ActiveSupport::HashWithIndifferentAccess.new hash
end
end
@reggieb

reggieb commented Nov 6, 2014

Copy link
Copy Markdown
Author

VersionStruct allows me to recover objects that behave like associated objects as they existed at the time a main object was saved. To store the data used to rebuild these objects, this code is added to the main object:

  after_save :append_associations_versions_to_last_version
  def append_associations_versions_to_last_version
    versions.last.update_attribute(:associations, build_associations_versions) if versions.last
  end

  def build_associations_versions
    self.class.reflect_on_all_associations.collect(&:name).inject({}) do |hash, assoc|
      unless assoc.to_sym == :versions
        objects = send(assoc)
        if objects.respond_to? :each
          objects = objects.inject({}){|h, o| h[o.id] = verison_id_from(o); h}
        else
          objects = {objects.id => verison_id_from(objects)}
        end
        hash[assoc.to_sym] = objects if objects
      end
      hash
    end
  end

  def verison_id_from(object)
    if object.respond_to?(:versions) and object.versions.present?
      version_created_at = PaperTrail::Version.arel_table[:created_at]
      object.versions.find_by(version_created_at.gt(3.seconds.ago)).try(:id)
    end
  end

All the models that I want to version, inherit from this abstract class (including the core class):

class VersionedBase < ActiveRecord::Base
  self.abstract_class = true

  has_paper_trail

  def last_version_id
    versions.last.try(:id)
  end

  def reify(version_id = last_version_id)
    return self unless version_id
    version = versions.find(version_id)
    VersionStruct.build_from(
      version,
      object_class: self.class.name
    )
  end

  delegate :human_attribute_name, to: :class

end

Then on the main object, I redefine reify like this:

  def reify(version_id = last_version_id)
    version = versions.find(version_id)
    VersionStruct.build_from(
      version, 
      serialized: [:stakeholders], 
      associations: version.associations,
      object_class: self.class.name
    )
  end

The stakeholders field is serialized, and therefore needs some extra processing.

With this in place, reify generates a modified OpenStruct object, with methods that match the original objects attributes, and return the values matching those attributes at the time the version was saved. A method also exists that matches each association, and this holds (for has_many) an array of OpenStruct objects that match the associated objects at the version time. There is also a changed method, that contains [before, after] pairs for any OpenStuct object where the relevant version changed.

@reggieb

reggieb commented Nov 6, 2014

Copy link
Copy Markdown
Author

The human_attribute_name methods is called on any object, to get the matching i18n translation for an attribute, so that I can easily apply labels and place holders in a consistent way from both original objects and OpenStruct versions.

@reggieb

reggieb commented Nov 6, 2014

Copy link
Copy Markdown
Author

Here is are my tests for VersionStruct:

require 'test_helper'

class VersionStructTest < ActiveSupport::TestCase

  def test_initialize
    assert_equal ihash(object), version_struct.object
    assert_equal ihash(changes), version_struct.changes
  end

  def test_mimic
    object.each do |method, value|
      assert_equal value, mimic.send(method), "mimic.#{method} should equal #{value}"
    end
  end

  def test_mimic_without_change
    @version_struct = VersionStruct.new(object)
    test_mimic
    assert_equal({}, mimic.changes)
  end  

  def test_change
    assert_equal changes[:foo], mimic.changes[:foo]
  end

  def test_change_with_serialized_attribute
    @version_struct = VersionStruct.new(object, changes: changes, serialized: [:bar])
    assert_equal [], mimic.changes[:bar].first
    assert_equal [], mimic.bar
  end

  def test_build_from
    @mimic = VersionStruct.build_from(version)
    assert_equal before, mimic.title
    assert_equal [before, after], mimic.changes[:title]
    assert_equal version, mimic.version
  end

  def test_build_from_with_associations
    @version_struct = VersionStruct.new(object, associations: associations)
    assert_equal before, mimic.work_requests.first.title
  end

  def test_human_attribute_name
    @version_struct = VersionStruct.new(object, object_class: 'WorkRequest')
    assert_equal(
      WorkRequest.human_attribute_name(:title), 
      mimic.human_attribute_name(:title)
    )
  end

  def version_struct
    @version_struct ||= VersionStruct.new(object, changes: changes)
  end

  def mimic
    @mimic ||= version_struct.mimic
  end

  def object
    @object ||= {
      foo: after,
      'this' => 'that',
      number: 1,
      bar: "--- []\n"
    }
  end

  def changes
    @changes ||= {
      foo: [before, after],
      bar: ["--- []\n", "---\n- One\n"]
    }
  end

  def before
    'before'
  end

  def after
    'after'
  end

  def version
    @version ||= build_version
  end

  def build_version
    work_request.update_attribute(:title, before)
    work_request.update_attribute(:title, after)
    work_request.versions.last
  end

  def associations
    @associations ||= build_associations
  end

  def build_associations
    build_version
    {work_requests: {work_request.id => work_request.versions.last.id}}
  end

  def ihash(hash = {})
    ActiveSupport::HashWithIndifferentAccess.new hash
  end

end

Some of the functionality is tested via the main host object tests, so aren't shown here.

@reggieb

reggieb commented Nov 6, 2014

Copy link
Copy Markdown
Author

This work started live via paper-trail-gem/paper_trail#433

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment