temporal_tables 0.6.10 → 0.7.0
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 +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
|