TemporalTables

Easily recall what your data looked like at any point in the past! TemporalTables sets up and maintains history tables to track all temporal changes to to your data.

Currently tested on Ruby 2.4, Rails 5.2, Postgres, MySQL

Installation

Add this line to your application's Gemfile:

gem 'temporal_tables'

And then execute:

$ bundle

Or install it yourself as:

$ gem install temporal_tables

Usage

Schema

In your rails migration, specify that you want a table to have its history tracked:

create_table :people, :temporal => true do |t|
  ...
end

This will create a history table called "people_h" which will maintain a history of all changes to any records in people with the use of triggers on any create/update/delete operation.

Any subsequent schema changes to people will be reflected automatically in people_h.

# Nothing extra required -- people_h will automatically get an "name" column too!
add_column :people, :name, :string

To track the history of a pre-existing table, just call add_temporal_table:

add_temporal_table :people

Querying

For the below queries, we'll assume the following schema:

class Person < ActiveRecord::Base
  attr_accessible :name
  belongs_to :coven
  has_many :warts

  def label
    "#{name} from #{coven.name}"
  end
end

class Coven < ActiveRecord::Base
  attr_accessible :name
  has_many :members, class_name: "Person"
end

class Wart < ActiveRecord::Base
  attr_accessible :location
  belongs_to :person

  scope :very_hairy, -> { where(arel_table[:num_hairs].gteq(3)) }
end

You can query the history tables by calling history on the class.

Person         #=> Person(id: :integer, name: :string)
Person.history #=> PersonHistory(history_id: :integer, id: :integer, name: :string, eff_from: :datetime, eff_to: :datetime)

You can easily get a history of all changes to a records.

Person.history.where(id: 1).map { |p| "#{p.eff_from}: #{p.name}")
# => [
#  "1974-01-14: Emily",
#  "2003-11-03: Grunthilda"
# ]

You can query for records as they were at any point in the past by calling at.

 Person.history.at(2.years.ago).where(id: 1).first.name #=> "Grunthilda"

Associations work too.

grunthilda = Person.history.at(20.years.ago).find_by_name("Grunthilda")
grunthilda.warts.count            #=> 2

grunthilda = Person.history.at(1.year.ago).find_by_name("Grunthilda")
grunthilda.warts.count            #=> 13

grunthilda.warts.first.class.name #=> "WartHistory"

And scopes also!

grunthilda = Person.history.at(1.year.ago).find_by_name("Grunthilda")
grunthilda.warts.count            #=> 13
grunthilda.warts.very_hairy.count #=> 7

Instance methods are inherited.

grunthilda.label                  #=> "Grunthilda from Delta Gamma Gamma"
grunthilda.class.name             #=> "PersonHistory"
grunthilda.class.superclass.name  #=> "Person"

Config

Create temporal tables for all tables by default

TemporalTables.create_by_default = true

Don't create temporal tables for these tables. Defaults to schema_migrations and sessions tables.

TemporalTables.skip_temporal_table_for :table_one, :table_two

Add an updated_by field to all temporal tables to track who made any changes. Defaults to a :string field. The block is called when records are saved to determine the value to place within the updated_by field.

TemporalTables.add_updated_by_field(:integer) { User.current_user.try(:id) }

See LICENSE for more details.