temporal_tables 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.editorconfig +4 -0
- data/.gitignore +19 -0
- data/.ruby-version +1 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +73 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +139 -0
- data/Rakefile +9 -0
- data/config.ru +7 -0
- data/gemfiles/Gemfile.5.2.mysql +16 -0
- data/gemfiles/Gemfile.5.2.mysql.lock +155 -0
- data/gemfiles/Gemfile.5.2.pg +16 -0
- data/gemfiles/Gemfile.5.2.pg.lock +155 -0
- data/lib/temporal_tables.rb +63 -0
- data/lib/temporal_tables/connection_adapters/mysql_adapter.rb +56 -0
- data/lib/temporal_tables/connection_adapters/postgresql_adapter.rb +81 -0
- data/lib/temporal_tables/history_hook.rb +50 -0
- data/lib/temporal_tables/relation_extensions.rb +125 -0
- data/lib/temporal_tables/scope_extensions.rb +13 -0
- data/lib/temporal_tables/temporal_adapter.rb +159 -0
- data/lib/temporal_tables/temporal_class.rb +91 -0
- data/lib/temporal_tables/version.rb +3 -0
- data/lib/temporal_tables/whodunnit.rb +19 -0
- data/spec/basic_history_spec.rb +91 -0
- data/spec/extensions/combustion.rb +9 -0
- data/spec/internal/app/models/coven.rb +3 -0
- data/spec/internal/app/models/person.rb +4 -0
- data/spec/internal/app/models/wart.rb +7 -0
- data/spec/internal/config/database.sample.yml +12 -0
- data/spec/internal/config/routes.rb +3 -0
- data/spec/internal/db/schema.rb +16 -0
- data/spec/internal/log/.gitignore +1 -0
- data/spec/internal/public/favicon.ico +0 -0
- data/spec/spec_helper.rb +17 -0
- data/spec/support/database.rb +9 -0
- data/temporal_tables.gemspec +24 -0
- metadata +149 -0
@@ -0,0 +1,13 @@
|
|
1
|
+
module TemporalTables
|
2
|
+
module NamedExtensionsWithHistory
|
3
|
+
def scope(name, body, &block)
|
4
|
+
if history
|
5
|
+
history_body = -> { history.instance_exec &body }
|
6
|
+
history.scope_without_history name, history_body, &block
|
7
|
+
end
|
8
|
+
super name, body, &block
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
ActiveRecord::Scoping::Named::ClassMethods.send :prepend, TemporalTables::NamedExtensionsWithHistory
|
@@ -0,0 +1,159 @@
|
|
1
|
+
module TemporalTables
|
2
|
+
module TemporalAdapter
|
3
|
+
def create_table(table_name, options = {}, &block)
|
4
|
+
if options[:temporal_bypass]
|
5
|
+
super table_name, options, &block
|
6
|
+
else
|
7
|
+
skip_table = TemporalTables.skipped_temporal_tables.include?(table_name.to_sym) || table_name.to_s =~ /_h$/
|
8
|
+
|
9
|
+
super table_name, options do |t|
|
10
|
+
block.call t
|
11
|
+
|
12
|
+
if TemporalTables.add_updated_by_field && !skip_table
|
13
|
+
t.column :updated_by, TemporalTables.updated_by_type
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
if options[:temporal] || (TemporalTables.create_by_default && !skip_table)
|
18
|
+
add_temporal_table table_name, options
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def add_temporal_table(table_name, options = {})
|
24
|
+
create_table temporal_name(table_name), options.merge(id: false, primary_key: "history_id", temporal_bypass: true) do |t|
|
25
|
+
t.integer :id
|
26
|
+
t.datetime :eff_from, :null => false, limit: 6
|
27
|
+
t.datetime :eff_to, :null => false, limit: 6, :default => "9999-12-31"
|
28
|
+
|
29
|
+
for c in columns(table_name)
|
30
|
+
t.send c.type, c.name, :limit => c.limit
|
31
|
+
end
|
32
|
+
end
|
33
|
+
add_index temporal_name(table_name), [:id, :eff_to]
|
34
|
+
create_temporal_triggers table_name
|
35
|
+
create_temporal_indexes table_name
|
36
|
+
end
|
37
|
+
|
38
|
+
def remove_temporal_table(table_name)
|
39
|
+
if table_exists?(temporal_name(table_name))
|
40
|
+
drop_temporal_triggers table_name
|
41
|
+
drop_table_without_temporal temporal_name(table_name)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def drop_table(table_name, options = {})
|
46
|
+
super table_name, options
|
47
|
+
|
48
|
+
if table_exists?(temporal_name(table_name))
|
49
|
+
super temporal_name(table_name), options
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def rename_table(name, new_name)
|
54
|
+
if table_exists?(temporal_name(name))
|
55
|
+
drop_temporal_triggers name
|
56
|
+
end
|
57
|
+
|
58
|
+
super name, new_name
|
59
|
+
|
60
|
+
if table_exists?(temporal_name(name))
|
61
|
+
super temporal_name(name), temporal_name(new_name)
|
62
|
+
create_temporal_triggers new_name
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def add_column(table_name, column_name, type, options = {})
|
67
|
+
super table_name, column_name, type, options
|
68
|
+
|
69
|
+
if table_exists?(temporal_name(table_name))
|
70
|
+
super temporal_name(table_name), column_name, type, options
|
71
|
+
create_temporal_triggers table_name
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def remove_column(table_name, *column_names)
|
76
|
+
super table_name, *column_names
|
77
|
+
|
78
|
+
if table_exists?(temporal_name(table_name))
|
79
|
+
super temporal_name(table_name), *column_names
|
80
|
+
create_temporal_triggers table_name
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def change_column(table_name, column_name, type, options = {})
|
85
|
+
super table_name, column_name, type, options
|
86
|
+
|
87
|
+
if table_exists?(temporal_name(table_name))
|
88
|
+
super temporal_name(table_name), column_name, type, options
|
89
|
+
# Don't need to update triggers here...
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def rename_column(table_name, column_name, new_column_name)
|
94
|
+
super table_name, column_name, new_column_name
|
95
|
+
|
96
|
+
if table_exists?(temporal_name(table_name))
|
97
|
+
super temporal_name(table_name), column_name, new_column_name
|
98
|
+
create_temporal_triggers table_name
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def add_index(table_name, column_name, options = {})
|
103
|
+
super table_name, column_name, options
|
104
|
+
|
105
|
+
if table_exists?(temporal_name(table_name))
|
106
|
+
column_names = Array.wrap(column_name)
|
107
|
+
idx_name = temporal_index_name(options[:name] || index_name(table_name, :column => column_names))
|
108
|
+
|
109
|
+
super temporal_name(table_name), column_name, options.except(:unique).merge(name: idx_name)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def remove_index(table_name, options = {})
|
114
|
+
super table_name, options
|
115
|
+
|
116
|
+
if table_exists?(temporal_name(table_name))
|
117
|
+
idx_name = temporal_index_name(index_name(table_name, options))
|
118
|
+
|
119
|
+
super temporal_name(table_name), :name => idx_name
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def create_temporal_indexes(table_name)
|
124
|
+
indexes = ActiveRecord::Base.connection.indexes(table_name)
|
125
|
+
|
126
|
+
indexes.each do |index|
|
127
|
+
index_name = temporal_index_name(index.name)
|
128
|
+
|
129
|
+
unless index_name_exists?(temporal_name(table_name), index_name)
|
130
|
+
add_index(
|
131
|
+
temporal_name(table_name),
|
132
|
+
index.columns, {
|
133
|
+
# exclude unique constraints for temporal tables
|
134
|
+
:name => index_name,
|
135
|
+
:length => index.lengths,
|
136
|
+
:order => index.orders
|
137
|
+
})
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def temporal_name(table_name)
|
143
|
+
"#{table_name}_h"
|
144
|
+
end
|
145
|
+
|
146
|
+
def create_temporal_triggers(table_name)
|
147
|
+
raise NotImplementedError, "create_temporal_triggers is not implemented"
|
148
|
+
end
|
149
|
+
|
150
|
+
def drop_temporal_triggers(table_name)
|
151
|
+
raise NotImplementedError, "drop_temporal_triggers is not implemented"
|
152
|
+
end
|
153
|
+
|
154
|
+
# It's important not to increase the length of the returned string.
|
155
|
+
def temporal_index_name(index_name)
|
156
|
+
index_name.to_s.sub(/^index/, "ind_h").sub(/_ix(\d+)$/, '_hi\1')
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module TemporalTables
|
2
|
+
# This is mixed into all History classes.
|
3
|
+
module TemporalClass
|
4
|
+
def self.included(base)
|
5
|
+
base.class_eval do
|
6
|
+
base.extend ClassMethods
|
7
|
+
|
8
|
+
self.table_name += "_h"
|
9
|
+
|
10
|
+
cattr_accessor :visited_associations
|
11
|
+
@@visited_associations = []
|
12
|
+
|
13
|
+
# The at_value field stores the time from the query that yielded
|
14
|
+
# this record.
|
15
|
+
attr_accessor :at_value
|
16
|
+
|
17
|
+
class << self
|
18
|
+
prepend STIWithHistory
|
19
|
+
end
|
20
|
+
|
21
|
+
# Iterates all associations, makes sure their history classes are
|
22
|
+
# created and initialized, and modifies the associations to point
|
23
|
+
# to the target classes' history classes.
|
24
|
+
def self.temporalize_associations!
|
25
|
+
reflect_on_all_associations.dup.each do |association|
|
26
|
+
unless @@visited_associations.include?(association.name) || association.options[:polymorphic]
|
27
|
+
@@visited_associations << association.name
|
28
|
+
|
29
|
+
# Calling .history here will ensure that the history class
|
30
|
+
# for this association is created and initialized
|
31
|
+
clazz = association.class_name.constantize.history
|
32
|
+
|
33
|
+
# Recreate the association, updating it to point at the
|
34
|
+
# history class. The foreign key is explicitly set since it's
|
35
|
+
# inferred from the class_name, but shouldn't be in this case.
|
36
|
+
send(association.macro, association.name,
|
37
|
+
association.options.merge(
|
38
|
+
class_name: clazz.name,
|
39
|
+
foreign_key: association.foreign_key,
|
40
|
+
primary_key: clazz.orig_class.primary_key
|
41
|
+
)
|
42
|
+
)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
module STIWithHistory
|
50
|
+
def sti_name
|
51
|
+
super.sub /History$/, ""
|
52
|
+
end
|
53
|
+
|
54
|
+
def find_sti_class(type_name)
|
55
|
+
sti_class = super(type_name)
|
56
|
+
sti_class = sti_class.history unless sti_class.respond_to?(:orig_class)
|
57
|
+
sti_class
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
module ClassMethods
|
62
|
+
def orig_class
|
63
|
+
name.sub(/History$/, "").constantize
|
64
|
+
end
|
65
|
+
|
66
|
+
def descends_from_active_record?
|
67
|
+
superclass.descends_from_active_record?
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def orig_class
|
72
|
+
self.class.orig_class
|
73
|
+
end
|
74
|
+
|
75
|
+
def orig_id
|
76
|
+
attributes[orig_class.primary_key]
|
77
|
+
end
|
78
|
+
|
79
|
+
def orig_obj
|
80
|
+
@orig_obj ||= orig_class.find_by_id orig_id
|
81
|
+
end
|
82
|
+
|
83
|
+
def prev
|
84
|
+
@prev ||= history.where(self.class.arel_table[:eff_from].lt(eff_from)).last
|
85
|
+
end
|
86
|
+
|
87
|
+
def next
|
88
|
+
@next ||= history.where(self.class.arel_table[:eff_from].gt(eff_from)).first
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module TemporalTables
|
2
|
+
module Whodunnit
|
3
|
+
def self.included(base)
|
4
|
+
base.class_eval do
|
5
|
+
include InstanceMethods
|
6
|
+
|
7
|
+
before_validation :set_updated_by
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
module InstanceMethods
|
12
|
+
def set_updated_by
|
13
|
+
if TemporalTables.updated_by_proc && respond_to?(:updated_by)
|
14
|
+
self.updated_by = TemporalTables.updated_by_proc.call(self)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Person do
|
4
|
+
let(:emily) { Person.create name: "Emily" }
|
5
|
+
let(:historical_emily) { emily.history.last }
|
6
|
+
|
7
|
+
before do
|
8
|
+
emily
|
9
|
+
@init_time = Time.now
|
10
|
+
sleep 0.1
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "upon making significant life changes" do
|
14
|
+
let!(:coven) { Coven.create name: "Double Double Toil & Trouble" }
|
15
|
+
let!(:wart) { Wart.create person: emily, hairiness: 3 }
|
16
|
+
|
17
|
+
before do
|
18
|
+
emily.update_attributes name: "Grunthilda", coven: coven
|
19
|
+
sleep 0.1
|
20
|
+
end
|
21
|
+
|
22
|
+
describe "when affirming changes" do
|
23
|
+
it "should have new name" do
|
24
|
+
expect(emily.name).to eq("Grunthilda")
|
25
|
+
expect(historical_emily.name).to eq("Grunthilda")
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should belong to coven" do
|
29
|
+
expect(emily.coven.name).to eq(coven.name)
|
30
|
+
expect(historical_emily.coven.name).to eq(coven.name)
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should have a wart" do
|
34
|
+
expect(emily.warts).to eq([wart])
|
35
|
+
expect(emily.history.at(Time.now).last.warts).to eq([wart.history.last])
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should allow scopes on associations" do
|
39
|
+
expect(emily.warts.very_hairy).to eq([wart])
|
40
|
+
expect(historical_emily.warts.very_hairy).to eq([wart.history.last])
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe "when reflecting on the past" do
|
45
|
+
let(:orig_emily) { emily.history.at(@init_time).last }
|
46
|
+
|
47
|
+
it "should have historical name" do
|
48
|
+
expect(orig_emily.name).to eq("Emily")
|
49
|
+
expect(orig_emily.at_value).to eq(@init_time)
|
50
|
+
end
|
51
|
+
|
52
|
+
it "should not belong to a coven or have warts" do
|
53
|
+
expect(orig_emily.coven).to eq(nil)
|
54
|
+
expect(orig_emily.warts.count).to eq(0)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe "when preloading associations" do
|
59
|
+
let(:orig_emily) { emily.history.at(@init_time).preload(:warts).first }
|
60
|
+
|
61
|
+
it 'should preload the correct time' do
|
62
|
+
expect(orig_emily.warts).to be_empty
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
describe "when eager_loading associations" do
|
67
|
+
let(:orig_emily) { emily.history.at(@init_time).eager_load(:warts).first }
|
68
|
+
|
69
|
+
it 'should include the correct time' do
|
70
|
+
expect(orig_emily.warts).to be_empty
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
describe "when checking simple code values" do
|
75
|
+
it "should have correct class names" do
|
76
|
+
expect(emily.class.name).to eq("Person")
|
77
|
+
expect(historical_emily.class.name).to eq("PersonHistory")
|
78
|
+
|
79
|
+
expect(Person.history).to eq(PersonHistory)
|
80
|
+
end
|
81
|
+
|
82
|
+
it "should have correct class hierarchies" do
|
83
|
+
expect(emily.is_a?(Person)).to eq(true)
|
84
|
+
expect(emily.is_a?(PersonHistory)).to eq(false)
|
85
|
+
|
86
|
+
expect(historical_emily.is_a?(Person)).to eq(true)
|
87
|
+
expect(historical_emily.is_a?(PersonHistory)).to eq(true)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
ActiveRecord::Schema.define do
|
2
|
+
create_table :people, temporal: true, force: true do |t|
|
3
|
+
t.references :coven
|
4
|
+
t.string :name
|
5
|
+
end
|
6
|
+
|
7
|
+
create_table :covens, force: true do |t|
|
8
|
+
t.string :name
|
9
|
+
end
|
10
|
+
add_temporal_table :covens
|
11
|
+
|
12
|
+
create_table :warts, temporal: true, force: true do |t|
|
13
|
+
t.references :person
|
14
|
+
t.integer :hairiness
|
15
|
+
end
|
16
|
+
end
|