temporal_tables 0.6.10 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/.travis.yml +3 -1
- data/README.md +1 -1
- data/gemfiles/Gemfile.5.0.mysql.lock +3 -3
- data/gemfiles/Gemfile.5.0.pg.lock +3 -3
- data/gemfiles/Gemfile.5.1.mysql.lock +3 -3
- data/gemfiles/Gemfile.5.1.pg.lock +3 -3
- data/gemfiles/Gemfile.5.2.mysql.lock +3 -3
- data/gemfiles/Gemfile.5.2.pg.lock +3 -3
- data/gemfiles/Gemfile.6.0.mysql +16 -0
- data/gemfiles/Gemfile.6.0.mysql.lock +171 -0
- data/gemfiles/Gemfile.6.0.pg +16 -0
- data/gemfiles/Gemfile.6.0.pg.lock +171 -0
- data/lib/temporal_tables/arel_table.rb +19 -0
- data/lib/temporal_tables/association_extensions.rb +16 -16
- data/lib/temporal_tables/connection_adapters/mysql_adapter.rb +54 -54
- data/lib/temporal_tables/connection_adapters/postgresql_adapter.rb +64 -64
- data/lib/temporal_tables/history_hook.rb +43 -43
- data/lib/temporal_tables/join_extensions.rb +14 -14
- data/lib/temporal_tables/preloader_extensions.rb +14 -14
- data/lib/temporal_tables/reflection_extensions.rb +14 -14
- data/lib/temporal_tables/relation_extensions.rb +79 -79
- data/lib/temporal_tables/temporal_adapter.rb +181 -174
- data/lib/temporal_tables/temporal_class.rb +101 -101
- data/lib/temporal_tables/version.rb +1 -1
- data/lib/temporal_tables/whodunnit.rb +16 -16
- data/lib/temporal_tables.rb +46 -45
- data/spec/basic_history_spec.rb +138 -138
- data/spec/internal/app/models/flying_machine.rb +1 -1
- data/spec/internal/app/models/person.rb +3 -3
- data/spec/internal/db/schema.rb +17 -17
- data/spec/spec_helper.rb +4 -4
- data/spec/support/database.rb +7 -7
- data/temporal_tables.gemspec +15 -15
- metadata +16 -6
@@ -1,56 +1,56 @@
|
|
1
1
|
module TemporalTables
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
2
|
+
module ConnectionAdapters
|
3
|
+
module AbstractMysqlAdapter
|
4
|
+
def drop_temporal_triggers(table_name)
|
5
|
+
execute "drop trigger #{table_name}_ai"
|
6
|
+
execute "drop trigger #{table_name}_au"
|
7
|
+
execute "drop trigger #{table_name}_ad"
|
8
|
+
end
|
9
|
+
|
10
|
+
def create_temporal_triggers(table_name)
|
11
|
+
column_names = columns(table_name).map(&:name)
|
12
|
+
|
13
|
+
execute %{
|
14
|
+
create trigger #{table_name}_ai after insert on #{table_name}
|
15
|
+
for each row
|
16
|
+
begin
|
17
|
+
set @current_time = utc_timestamp(6);
|
18
|
+
|
19
|
+
insert into #{temporal_name(table_name)} (#{column_names.join(', ')}, eff_from)
|
20
|
+
values (#{column_names.collect {|c| "new.#{c}"}.join(', ')}, @current_time);
|
21
|
+
|
22
|
+
end
|
23
|
+
}
|
24
|
+
|
25
|
+
execute %{
|
26
|
+
create trigger #{table_name}_au after update on #{table_name}
|
27
|
+
for each row
|
28
|
+
begin
|
29
|
+
set @current_time = utc_timestamp(6);
|
30
|
+
|
31
|
+
update #{temporal_name(table_name)} set eff_to = @current_time
|
32
|
+
where id = new.id
|
33
|
+
and eff_to = '9999-12-31';
|
34
|
+
|
35
|
+
insert into #{temporal_name(table_name)} (#{column_names.join(', ')}, eff_from)
|
36
|
+
values (#{column_names.collect {|c| "new.#{c}"}.join(', ')}, @current_time);
|
37
|
+
|
38
|
+
end
|
39
|
+
}
|
40
|
+
|
41
|
+
execute %{
|
42
|
+
create trigger #{table_name}_ad after delete on #{table_name}
|
43
|
+
for each row
|
44
|
+
begin
|
45
|
+
set @current_time = utc_timestamp(6);
|
46
|
+
|
47
|
+
update #{temporal_name(table_name)} set eff_to = @current_time
|
48
|
+
where id = old.id
|
49
|
+
and eff_to = '9999-12-31';
|
50
|
+
|
51
|
+
end
|
52
|
+
}
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
56
|
end
|
@@ -1,81 +1,81 @@
|
|
1
1
|
module TemporalTables
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
2
|
+
module ConnectionAdapters
|
3
|
+
module PostgreSQLAdapter
|
4
|
+
def drop_temporal_triggers(table_name)
|
5
|
+
execute "drop trigger #{table_name}_ai on #{table_name}"
|
6
|
+
execute "drop trigger #{table_name}_au on #{table_name}"
|
7
|
+
execute "drop trigger #{table_name}_ad on #{table_name}"
|
8
|
+
end
|
9
9
|
|
10
|
-
|
11
|
-
|
10
|
+
def create_temporal_triggers(table_name)
|
11
|
+
column_names = columns(table_name).map(&:name)
|
12
12
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
13
|
+
execute %{
|
14
|
+
create or replace function #{table_name}_ai() returns trigger as $#{table_name}_ai$
|
15
|
+
declare
|
16
|
+
cur_time timestamp without time zone;
|
17
|
+
begin
|
18
|
+
cur_time := localtimestamp;
|
19
19
|
|
20
|
-
|
21
|
-
|
20
|
+
insert into #{temporal_name(table_name)} (#{column_list(column_names)}, eff_from)
|
21
|
+
values (#{column_names.collect {|c| "new.#{c}"}.join(', ')}, cur_time);
|
22
22
|
|
23
|
-
|
24
|
-
|
25
|
-
|
23
|
+
return null;
|
24
|
+
end
|
25
|
+
$#{table_name}_ai$ language plpgsql;
|
26
26
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
27
|
+
drop trigger if exists #{table_name}_ai on #{table_name};
|
28
|
+
create trigger #{table_name}_ai after insert on #{table_name}
|
29
|
+
for each row execute procedure #{table_name}_ai();
|
30
|
+
}
|
31
31
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
32
|
+
execute %{
|
33
|
+
create or replace function #{table_name}_au() returns trigger as $#{table_name}_au$
|
34
|
+
declare
|
35
|
+
cur_time timestamp without time zone;
|
36
|
+
begin
|
37
|
+
cur_time := localtimestamp;
|
38
38
|
|
39
|
-
|
40
|
-
|
41
|
-
|
39
|
+
update #{temporal_name(table_name)} set eff_to = cur_time
|
40
|
+
where id = new.id
|
41
|
+
and eff_to = '9999-12-31';
|
42
42
|
|
43
|
-
|
44
|
-
|
43
|
+
insert into #{temporal_name(table_name)} (#{column_list(column_names)}, eff_from)
|
44
|
+
values (#{column_names.collect {|c| "new.#{c}"}.join(', ')}, cur_time);
|
45
45
|
|
46
|
-
|
47
|
-
|
48
|
-
|
46
|
+
return null;
|
47
|
+
end
|
48
|
+
$#{table_name}_au$ language plpgsql;
|
49
49
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
50
|
+
drop trigger if exists #{table_name}_au on #{table_name};
|
51
|
+
create trigger #{table_name}_au after update on #{table_name}
|
52
|
+
for each row execute procedure #{table_name}_au();
|
53
|
+
}
|
54
54
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
55
|
+
execute %{
|
56
|
+
create or replace function #{table_name}_ad() returns trigger as $#{table_name}_ad$
|
57
|
+
declare
|
58
|
+
cur_time timestamp without time zone;
|
59
|
+
begin
|
60
|
+
cur_time := localtimestamp;
|
61
61
|
|
62
|
-
|
63
|
-
|
64
|
-
|
62
|
+
update #{temporal_name(table_name)} set eff_to = cur_time
|
63
|
+
where id = old.id
|
64
|
+
and eff_to = '9999-12-31';
|
65
65
|
|
66
|
-
|
67
|
-
|
68
|
-
|
66
|
+
return null;
|
67
|
+
end
|
68
|
+
$#{table_name}_ad$ language plpgsql;
|
69
69
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
70
|
+
drop trigger if exists #{table_name}_ad on #{table_name};
|
71
|
+
create trigger #{table_name}_ad after delete on #{table_name}
|
72
|
+
for each row execute procedure #{table_name}_ad();
|
73
|
+
}
|
74
|
+
end
|
75
75
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
76
|
+
def column_list(column_names)
|
77
|
+
column_names.map { |c| "\"#{c}\"" }.join(', ')
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
81
|
end
|
@@ -1,50 +1,50 @@
|
|
1
1
|
module TemporalTables
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
2
|
+
# This hooks in a "history" method to ActiveRecord::Base which will
|
3
|
+
# return the class's History class. The history class extends the original
|
4
|
+
# class, but runs against the history table to provide temporal results.
|
5
|
+
#
|
6
|
+
# class Person < ActiveRecord::Base
|
7
|
+
# attr_accessible :name
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# Person #=> Person(id: integer, name: string)
|
11
|
+
# Person.history #=> PersonHistory(history_id: integer, id: integer, name: string, eff_from: datetime, eff_to: datetime)
|
12
|
+
module HistoryHook
|
13
|
+
def self.included(base)
|
14
|
+
base.class_eval do
|
15
|
+
# Return this class's history class.
|
16
|
+
# If it doesn't exist yet, create and initialize it, as well
|
17
|
+
# as all dependent classes (through associations).
|
18
|
+
def self.history
|
19
|
+
raise "Can't view history of history" if name =~ /History$/
|
20
20
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
21
|
+
history_class = "#{name}History"
|
22
|
+
history_class.constantize
|
23
|
+
rescue NameError
|
24
|
+
# If the history class doesn't exist yet, create it
|
25
|
+
new_class = Class.new(self) do
|
26
|
+
include TemporalTables::TemporalClass
|
27
|
+
end
|
28
|
+
segments = history_class.split("::")
|
29
|
+
object_class = segments[0...-1].inject(Object) { |o, s| o.const_get(s) }
|
30
|
+
object_class.const_set segments.last, new_class
|
31
31
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
32
|
+
# Traverse associations and make sure they have
|
33
|
+
# history classes too.
|
34
|
+
history_class.constantize.temporalize_associations!
|
35
|
+
history_class.constantize
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
39
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
40
|
+
# Returns a scope for the list of all history records for this
|
41
|
+
# particular object.
|
42
|
+
def history
|
43
|
+
clazz = is_a?(TemporalTables::TemporalClass) ? self.class : self.class.history
|
44
|
+
oid = is_a?(TemporalTables::TemporalClass) ? orig_class.primary_key : self.class.primary_key
|
45
|
+
clazz.unscoped.where(id: attributes[oid]).order(:eff_from)
|
46
|
+
end
|
47
|
+
end
|
48
48
|
end
|
49
49
|
|
50
50
|
ActiveRecord::Base.send :include, TemporalTables::HistoryHook
|
@@ -1,20 +1,20 @@
|
|
1
1
|
module TemporalTables
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
2
|
+
# This is required for eager_load to work in Rails 5.0.x
|
3
|
+
module JoinDependencyExtensions
|
4
|
+
def build_constraint(klass, table, key, foreign_table, foreign_key)
|
5
|
+
constraint = super
|
6
|
+
if at_value = Thread.current[:at_time]
|
7
|
+
constraint = constraint.and(klass.build_temporal_constraint(at_value))
|
8
|
+
end
|
9
|
+
constraint
|
10
|
+
end
|
11
|
+
end
|
12
12
|
end
|
13
13
|
|
14
14
|
case Rails::VERSION::MAJOR
|
15
15
|
when 5
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
16
|
+
case Rails::VERSION::MINOR
|
17
|
+
when 0, 1
|
18
|
+
ActiveRecord::Associations::JoinDependency::JoinAssociation.prepend TemporalTables::JoinDependencyExtensions
|
19
|
+
end
|
20
20
|
end
|
@@ -1,19 +1,19 @@
|
|
1
1
|
module TemporalTables
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
2
|
+
# Uses the at time when fetching preloaded records
|
3
|
+
module PreloaderExtensions
|
4
|
+
def build_scope
|
5
|
+
# It seems the at time can be in either of these places, but not both,
|
6
|
+
# depending on when the preloading happens to be done
|
7
|
+
at_time = @owners.first.at_value if @owners.first.respond_to?(:at_value)
|
8
|
+
at_time ||= Thread.current[:at_time]
|
9
9
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
10
|
+
if at_time
|
11
|
+
super.at(at_time)
|
12
|
+
else
|
13
|
+
super
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
17
|
end
|
18
18
|
|
19
19
|
ActiveRecord::Associations::Preloader::Association.prepend TemporalTables::PreloaderExtensions
|
@@ -1,21 +1,21 @@
|
|
1
1
|
module TemporalTables
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
2
|
+
# This is required for eager_load to work in Rails 5.2.x
|
3
|
+
module AbstractReflectionExtensions
|
4
|
+
def build_join_constraint(table, foreign_table)
|
5
|
+
constraint = super
|
6
|
+
if at_value = Thread.current[:at_time]
|
7
|
+
constraint = constraint.and(klass.build_temporal_constraint(at_value))
|
8
|
+
end
|
9
|
+
constraint
|
10
|
+
end
|
11
|
+
end
|
12
12
|
end
|
13
13
|
|
14
14
|
case Rails::VERSION::MAJOR
|
15
15
|
when 5
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
16
|
+
case Rails::VERSION::MINOR
|
17
|
+
when 2
|
18
|
+
ActiveRecord::Reflection::AbstractReflection.prepend TemporalTables::AbstractReflectionExtensions
|
19
|
+
end
|
20
20
|
end
|
21
21
|
|
@@ -1,94 +1,94 @@
|
|
1
1
|
module TemporalTables
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
2
|
+
# Stores the time from the "at" field into each of the resulting objects
|
3
|
+
# so that it can be carried forward in subsequent queries.
|
4
|
+
module RelationExtensions
|
5
|
+
def self.included(base)
|
6
|
+
base.class_eval do
|
7
|
+
ActiveRecord::Relation::SINGLE_VALUE_METHODS << :at
|
8
|
+
end
|
9
|
+
end
|
10
10
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
11
|
+
def at_value
|
12
|
+
case Rails::VERSION::MINOR
|
13
|
+
when 0
|
14
|
+
@values.fetch(:at, nil) || Thread.current[:at_time]
|
15
|
+
else
|
16
|
+
get_value(:at) || Thread.current[:at_time]
|
17
|
+
end
|
18
|
+
end
|
19
19
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
20
|
+
def at_value=(value)
|
21
|
+
case Rails::VERSION::MINOR
|
22
|
+
when 0
|
23
|
+
@values[:at] = value
|
24
|
+
else
|
25
|
+
set_value(:at, value)
|
26
|
+
end
|
27
|
+
end
|
28
28
|
|
29
|
-
|
30
|
-
|
31
|
-
|
29
|
+
def at(*args)
|
30
|
+
spawn.at!(*args)
|
31
|
+
end
|
32
32
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
33
|
+
def at!(value)
|
34
|
+
self.at_value = value
|
35
|
+
self.where!(klass.build_temporal_constraint(value))
|
36
|
+
end
|
37
37
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
38
|
+
def to_sql(*args)
|
39
|
+
threadify_at do
|
40
|
+
super *args
|
41
|
+
end
|
42
|
+
end
|
43
43
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
44
|
+
def threadify_at
|
45
|
+
if at_value && !Thread.current[:at_time]
|
46
|
+
Thread.current[:at_time] = at_value
|
47
|
+
result = yield
|
48
|
+
Thread.current[:at_time] = nil
|
49
|
+
else
|
50
|
+
result = yield
|
51
|
+
end
|
52
|
+
result
|
53
|
+
end
|
54
54
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
55
|
+
def limited_ids_for(*args)
|
56
|
+
threadify_at do
|
57
|
+
super *args
|
58
|
+
end
|
59
|
+
end
|
60
60
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
61
|
+
def exec_queries
|
62
|
+
# Note that record preloading, like when you specify
|
63
|
+
# MyClass.includes(:associations)
|
64
|
+
# happens within this exec_queries call. That's why we needed to
|
65
|
+
# store the at_time in the thread above.
|
66
|
+
threadify_at do
|
67
|
+
super
|
68
|
+
end
|
69
69
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
70
|
+
if historical?
|
71
|
+
# Store the at value on each record returned
|
72
|
+
@records.each do |r|
|
73
|
+
r.at_value = at_value
|
74
|
+
end
|
75
|
+
end
|
76
|
+
@records
|
77
|
+
end
|
78
78
|
|
79
|
-
|
80
|
-
|
81
|
-
|
79
|
+
def historical?
|
80
|
+
table_name =~ /_h$/i && at_value
|
81
|
+
end
|
82
82
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
83
|
+
# Only needed for Rails 5.1.x
|
84
|
+
def default_value_for(name)
|
85
|
+
if name == :at
|
86
|
+
nil
|
87
|
+
else
|
88
|
+
super(name)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
92
|
end
|
93
93
|
|
94
94
|
ActiveRecord::Relation.prepend TemporalTables::RelationExtensions
|