paper_trail icon indicating copy to clipboard operation
paper_trail copied to clipboard

How to revert back to anytime in history? How to merge versions?

Open jesseshieh opened this issue 7 years ago • 9 comments

Hi, The documentation mentions that paper_trail can "revert back to anytime in history", but I can't figure out exactly how to revert or reconstitute an older version of a model. Is there documentation somewhere that describes this?

Also, it's mentioned that you "don't delete your paper_trail versions, instead you can merge them", but I can't figure out exactly how to merge them. Is there documentation somewhere that describes this?

jesseshieh avatar Apr 20 '17 21:04 jesseshieh

Hi @jesseshieh,

Yes PaperTrail can revert back to anytime in history because the data is already there. item_changes column holds all the changes that occured in a single version. In order revert back in history you need to start from the beginning(because we dont want to store/duplicate data in each version), the initial version. You should also need to account relationships(related model changes) and create an additional version once your operation is complete.

If you want to merge versions you should pay attention to your version meta_tags, ideally you wouldn't want to lose them. Our focus has been on building the foundation(the initial data storage) so far.

Currently there isn't a single function for these that ships with the library. All features are heavily tested before release. Also I'm not reverting or merging versions in any of my applications currently. Therefore If you are willing to contribute by adding these functionalities I'll be happily review and collaborate.

Thanks for your interest in the project!

izelnakri avatar Apr 22 '17 14:04 izelnakri

This is one way of doing it:

  def travel(post, time) do
    post
    |> PaperTrail.get_versions()
    |> Enum.sort_by(& &1.id)
    |> Enum.take_while(& &1.inserted < ^time)
    |> Enum.map(& &1.item_changes)
    |> Enum.reduce(%{}, &Map.merge(&2, &1))
  end

The result is a map with the attributes post had at a given time. This could be further piped into Ecto.Changeset.cast and Ecto.Changeset.apply_changes to get actual struct:

  def travel(post, time) do
    attrs =
      post
      |> PaperTrail.get_versions()
      |> Enum.sort_by(& &1.id)
      |> Enum.take_while(& &1.inserted < ^time)
      |> Enum.map(& &1.item_changes)
      |> Enum.reduce(%{}, &Map.merge(&2, &1))

    %Post{}
    |> Ecto.Changeset.cast(attrs, [Map.keys(attrs)])
    |> Ecto.Changeset.apply_changes()
  end

ku1ik avatar Aug 30 '18 12:08 ku1ik

Is there a story for tracking how the shape of a model might change over its lifetime? For example, if a field name is changed, or a new (required) field is added. Or a more complex example, a column is extracted to a new table, and replaced with a reference?

In these cases, it seems difficult to revert back to an old version. Are there any examples of this?

tomconroy avatar Sep 15 '18 14:09 tomconroy

Yes, I thought of implementing something like this @tomconroy, then thought it should be left to the developer during their migration instead. Because checking such changes during each migration with PaperTrail would take too much time and work from PaperTrail side.

izelnakri avatar Jul 01 '19 09:07 izelnakri

Thanks @izelnakri, so do you imagine the developer would migrate the historical versions, rather than implement the different ways a historical version might be restored to the latest schema? Seems like either path could have many complications

tomconroy avatar Jul 01 '19 14:07 tomconroy

For the benefit of others; something I've been doing is stuffing a serialized representation of an object into the "meta" field on every update. That way, I've got the struct at a given point-in-time and don't have to reconstitute it. This is a naive approach, and I like it because its simple. I cheat a little by updating the record in the Versions table. Something like:

      def insert(changeset, %User{} = user) do
        pt_result = PaperTrail.insert(changeset, originator: user)

        case pt_result do
          {:ok, model_and_version} ->
            model = model_and_version.model
            version = model_and_version.version
            PaperTrail.Version.changeset(version, %{meta: model}) |> Repo.update!()

          {:error, error} ->
            IO.inspect(error)
            raise "Error inserting: #{error}"

          _ ->
            nil
        end

tensiondriven avatar Oct 13 '19 02:10 tensiondriven

I'm considering to cut a v1 release after new years. So far I think it would be great if we describe this in the README.md, however if someone wants to create a PR for it, I'm willing to review it.

izelnakri avatar Nov 01 '21 23:11 izelnakri

@izelnakri What aspect of this would you like to see described? If its the naive approach I posted above, I'd be happy to whip up a PR. For the other approach of building a representation from history, I'm less stoked as any missing rows from the papertrail_versions table would result in an incorrect representation.

tensiondriven avatar Nov 03 '21 19:11 tensiondriven

I think naive approach could add to the complexity in terms of maintainance, however I dont mind having a PapertTrail.revert($paperTrailStruct) that can fetch all the versions and assign them one by one to the latest one in the database, maybe 2 function, one to view the built struct, the other to replace existing record with the built struct. Yes I'm aware missing row would result in incorrect representation however we warn the developers in the README.md asking them to merge versions instead of deleting them. Thus I think this would be the best way forward, also ideally we should do this in SQL.

izelnakri avatar Nov 03 '21 20:11 izelnakri