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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +4 -0
  3. data/.gitignore +19 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +6 -0
  6. data/CHANGELOG.md +73 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE.txt +22 -0
  9. data/README.md +139 -0
  10. data/Rakefile +9 -0
  11. data/config.ru +7 -0
  12. data/gemfiles/Gemfile.5.2.mysql +16 -0
  13. data/gemfiles/Gemfile.5.2.mysql.lock +155 -0
  14. data/gemfiles/Gemfile.5.2.pg +16 -0
  15. data/gemfiles/Gemfile.5.2.pg.lock +155 -0
  16. data/lib/temporal_tables.rb +63 -0
  17. data/lib/temporal_tables/connection_adapters/mysql_adapter.rb +56 -0
  18. data/lib/temporal_tables/connection_adapters/postgresql_adapter.rb +81 -0
  19. data/lib/temporal_tables/history_hook.rb +50 -0
  20. data/lib/temporal_tables/relation_extensions.rb +125 -0
  21. data/lib/temporal_tables/scope_extensions.rb +13 -0
  22. data/lib/temporal_tables/temporal_adapter.rb +159 -0
  23. data/lib/temporal_tables/temporal_class.rb +91 -0
  24. data/lib/temporal_tables/version.rb +3 -0
  25. data/lib/temporal_tables/whodunnit.rb +19 -0
  26. data/spec/basic_history_spec.rb +91 -0
  27. data/spec/extensions/combustion.rb +9 -0
  28. data/spec/internal/app/models/coven.rb +3 -0
  29. data/spec/internal/app/models/person.rb +4 -0
  30. data/spec/internal/app/models/wart.rb +7 -0
  31. data/spec/internal/config/database.sample.yml +12 -0
  32. data/spec/internal/config/routes.rb +3 -0
  33. data/spec/internal/db/schema.rb +16 -0
  34. data/spec/internal/log/.gitignore +1 -0
  35. data/spec/internal/public/favicon.ico +0 -0
  36. data/spec/spec_helper.rb +17 -0
  37. data/spec/support/database.rb +9 -0
  38. data/temporal_tables.gemspec +24 -0
  39. metadata +149 -0
@@ -0,0 +1,16 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Runtime dependencies
4
+ gem 'rails', '~> 5.2.0'
5
+ gem 'pg', '>= 0.18', '< 2.0'
6
+
7
+ # Development dependencies
8
+ gem 'rspec', '~> 3.4'
9
+ gem 'rake'
10
+ gem 'byebug'
11
+ gem 'database_cleaner'
12
+ gem 'combustion'
13
+ gem 'gemika'
14
+
15
+ # Gem under test
16
+ gem 'temporal_tables', :path => '..'
@@ -0,0 +1,155 @@
1
+ PATH
2
+ remote: ..
3
+ specs:
4
+ temporal_tables (0.6.0)
5
+ rails (~> 5.2)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ actioncable (5.2.0)
11
+ actionpack (= 5.2.0)
12
+ nio4r (~> 2.0)
13
+ websocket-driver (>= 0.6.1)
14
+ actionmailer (5.2.0)
15
+ actionpack (= 5.2.0)
16
+ actionview (= 5.2.0)
17
+ activejob (= 5.2.0)
18
+ mail (~> 2.5, >= 2.5.4)
19
+ rails-dom-testing (~> 2.0)
20
+ actionpack (5.2.0)
21
+ actionview (= 5.2.0)
22
+ activesupport (= 5.2.0)
23
+ rack (~> 2.0)
24
+ rack-test (>= 0.6.3)
25
+ rails-dom-testing (~> 2.0)
26
+ rails-html-sanitizer (~> 1.0, >= 1.0.2)
27
+ actionview (5.2.0)
28
+ activesupport (= 5.2.0)
29
+ builder (~> 3.1)
30
+ erubi (~> 1.4)
31
+ rails-dom-testing (~> 2.0)
32
+ rails-html-sanitizer (~> 1.0, >= 1.0.3)
33
+ activejob (5.2.0)
34
+ activesupport (= 5.2.0)
35
+ globalid (>= 0.3.6)
36
+ activemodel (5.2.0)
37
+ activesupport (= 5.2.0)
38
+ activerecord (5.2.0)
39
+ activemodel (= 5.2.0)
40
+ activesupport (= 5.2.0)
41
+ arel (>= 9.0)
42
+ activestorage (5.2.0)
43
+ actionpack (= 5.2.0)
44
+ activerecord (= 5.2.0)
45
+ marcel (~> 0.3.1)
46
+ activesupport (5.2.0)
47
+ concurrent-ruby (~> 1.0, >= 1.0.2)
48
+ i18n (>= 0.7, < 2)
49
+ minitest (~> 5.1)
50
+ tzinfo (~> 1.1)
51
+ arel (9.0.0)
52
+ builder (3.2.3)
53
+ byebug (10.0.2)
54
+ combustion (0.9.1)
55
+ activesupport (>= 3.0.0)
56
+ railties (>= 3.0.0)
57
+ thor (>= 0.14.6)
58
+ concurrent-ruby (1.0.5)
59
+ crass (1.0.4)
60
+ database_cleaner (1.7.0)
61
+ diff-lcs (1.3)
62
+ erubi (1.7.1)
63
+ gemika (0.3.2)
64
+ globalid (0.4.1)
65
+ activesupport (>= 4.2.0)
66
+ i18n (1.0.1)
67
+ concurrent-ruby (~> 1.0)
68
+ loofah (2.2.2)
69
+ crass (~> 1.0.2)
70
+ nokogiri (>= 1.5.9)
71
+ mail (2.7.0)
72
+ mini_mime (>= 0.1.1)
73
+ marcel (0.3.2)
74
+ mimemagic (~> 0.3.2)
75
+ method_source (0.9.0)
76
+ mimemagic (0.3.2)
77
+ mini_mime (1.0.0)
78
+ mini_portile2 (2.3.0)
79
+ minitest (5.11.3)
80
+ nio4r (2.3.1)
81
+ nokogiri (1.8.4)
82
+ mini_portile2 (~> 2.3.0)
83
+ pg (1.0.0)
84
+ rack (2.0.5)
85
+ rack-test (1.0.0)
86
+ rack (>= 1.0, < 3)
87
+ rails (5.2.0)
88
+ actioncable (= 5.2.0)
89
+ actionmailer (= 5.2.0)
90
+ actionpack (= 5.2.0)
91
+ actionview (= 5.2.0)
92
+ activejob (= 5.2.0)
93
+ activemodel (= 5.2.0)
94
+ activerecord (= 5.2.0)
95
+ activestorage (= 5.2.0)
96
+ activesupport (= 5.2.0)
97
+ bundler (>= 1.3.0)
98
+ railties (= 5.2.0)
99
+ sprockets-rails (>= 2.0.0)
100
+ rails-dom-testing (2.0.3)
101
+ activesupport (>= 4.2.0)
102
+ nokogiri (>= 1.6)
103
+ rails-html-sanitizer (1.0.4)
104
+ loofah (~> 2.2, >= 2.2.2)
105
+ railties (5.2.0)
106
+ actionpack (= 5.2.0)
107
+ activesupport (= 5.2.0)
108
+ method_source
109
+ rake (>= 0.8.7)
110
+ thor (>= 0.18.1, < 2.0)
111
+ rake (12.3.1)
112
+ rspec (3.7.0)
113
+ rspec-core (~> 3.7.0)
114
+ rspec-expectations (~> 3.7.0)
115
+ rspec-mocks (~> 3.7.0)
116
+ rspec-core (3.7.1)
117
+ rspec-support (~> 3.7.0)
118
+ rspec-expectations (3.7.0)
119
+ diff-lcs (>= 1.2.0, < 2.0)
120
+ rspec-support (~> 3.7.0)
121
+ rspec-mocks (3.7.0)
122
+ diff-lcs (>= 1.2.0, < 2.0)
123
+ rspec-support (~> 3.7.0)
124
+ rspec-support (3.7.1)
125
+ sprockets (3.7.2)
126
+ concurrent-ruby (~> 1.0)
127
+ rack (> 1, < 3)
128
+ sprockets-rails (3.2.1)
129
+ actionpack (>= 4.0)
130
+ activesupport (>= 4.0)
131
+ sprockets (>= 3.0.0)
132
+ thor (0.20.0)
133
+ thread_safe (0.3.6)
134
+ tzinfo (1.2.5)
135
+ thread_safe (~> 0.1)
136
+ websocket-driver (0.7.0)
137
+ websocket-extensions (>= 0.1.0)
138
+ websocket-extensions (0.1.3)
139
+
140
+ PLATFORMS
141
+ ruby
142
+
143
+ DEPENDENCIES
144
+ byebug
145
+ combustion
146
+ database_cleaner
147
+ gemika
148
+ pg (>= 0.18, < 2.0)
149
+ rails (~> 5.2.0)
150
+ rake
151
+ rspec (~> 3.4)
152
+ temporal_tables!
153
+
154
+ BUNDLED WITH
155
+ 1.16.1
@@ -0,0 +1,63 @@
1
+ require "temporal_tables/temporal_adapter"
2
+ require "temporal_tables/connection_adapters/mysql_adapter"
3
+ require "temporal_tables/connection_adapters/postgresql_adapter"
4
+ require "temporal_tables/whodunnit"
5
+ require "temporal_tables/temporal_class"
6
+ require "temporal_tables/history_hook"
7
+ require "temporal_tables/relation_extensions"
8
+ require "temporal_tables/scope_extensions"
9
+ require "temporal_tables/version"
10
+
11
+ module TemporalTables
12
+ class Railtie < ::Rails::Railtie
13
+ initializer "temporal_tables.load" do
14
+ # Iterating the subclasses will find any adapter implementations
15
+ # which are in use by the rails app, and mixin the temporal functionality.
16
+ # It's necessary to do this on the implementations in order for the
17
+ # alias method chain hooks to work.
18
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.subclasses.each do |subclass|
19
+ subclass.send :prepend, TemporalTables::TemporalAdapter
20
+
21
+ module_name = subclass.name.split("::").last
22
+ subclass.send :prepend, TemporalTables::ConnectionAdapters.const_get(module_name) if TemporalTables::ConnectionAdapters.const_defined?(module_name)
23
+ end
24
+
25
+ ActiveRecord::Base.send :prepend, TemporalTables::Whodunnit
26
+ end
27
+ end
28
+
29
+ @@create_by_default = false
30
+ def self.create_by_default
31
+ @@create_by_default
32
+ end
33
+ def self.create_by_default=(default)
34
+ @@create_by_default = default
35
+ end
36
+
37
+ @@skipped_temporal_tables = [:schema_migrations, :sessions]
38
+ def self.skip_temporal_table_for(*tables)
39
+ @@skipped_temporal_tables += tables
40
+ end
41
+ def self.skipped_temporal_tables
42
+ @@skipped_temporal_tables.dup
43
+ end
44
+
45
+ @@add_updated_by_field = false
46
+ @@updated_by_type = :string
47
+ @@updated_by_proc = nil
48
+ def self.updated_by_type
49
+ @@updated_by_type
50
+ end
51
+ def self.updated_by_proc
52
+ @@updated_by_proc
53
+ end
54
+ def self.add_updated_by_field(type = :string, &block)
55
+ if block_given?
56
+ @@add_updated_by_field = true
57
+ @@updated_by_type = type
58
+ @@updated_by_proc = block
59
+ end
60
+
61
+ @@add_updated_by_field
62
+ end
63
+ end
@@ -0,0 +1,56 @@
1
+ module TemporalTables
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
+ end
@@ -0,0 +1,81 @@
1
+ module TemporalTables
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
+
10
+ def create_temporal_triggers(table_name)
11
+ column_names = columns(table_name).map(&:name)
12
+
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
+
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
+
23
+ return null;
24
+ end
25
+ $#{table_name}_ai$ language plpgsql;
26
+
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
+
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
+
39
+ update #{temporal_name(table_name)} set eff_to = cur_time
40
+ where id = new.id
41
+ and eff_to = '9999-12-31';
42
+
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
+
46
+ return null;
47
+ end
48
+ $#{table_name}_au$ language plpgsql;
49
+
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
+
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
+
62
+ update #{temporal_name(table_name)} set eff_to = cur_time
63
+ where id = old.id
64
+ and eff_to = '9999-12-31';
65
+
66
+ return null;
67
+ end
68
+ $#{table_name}_ad$ language plpgsql;
69
+
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
+
76
+ def column_list(column_names)
77
+ column_names.map { |c| "\"#{c}\"" }.join(', ')
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,50 @@
1
+ module TemporalTables
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
+
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
+
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
+
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
+ end
49
+
50
+ ActiveRecord::Base.send :include, TemporalTables::HistoryHook
@@ -0,0 +1,125 @@
1
+ module TemporalTables
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
+
11
+ def at_value
12
+ get_value(:at) || Thread.current[:at_time]
13
+ end
14
+
15
+ def at_value=(value)
16
+ set_value(:at, value)
17
+ end
18
+
19
+ def at(*args)
20
+ spawn.at!(*args)
21
+ end
22
+
23
+ def at!(value)
24
+ self.at_value = value
25
+ self
26
+ end
27
+
28
+ def where_clause
29
+ s = super
30
+
31
+ at_clauses = []
32
+ if historical?
33
+ at_clauses << where_clause_factory.build(
34
+ arel_table[:eff_to].gteq(at_value).and(
35
+ arel_table[:eff_from].lteq(at_value)
36
+ ),
37
+ []
38
+ )
39
+ end
40
+
41
+ [s, *at_clauses.compact].sum
42
+ end
43
+
44
+ def to_sql(*args)
45
+ threadify_at do
46
+ super *args
47
+ end
48
+ end
49
+
50
+ def threadify_at
51
+ if at_value && !Thread.current[:at_time]
52
+ Thread.current[:at_time] = at_value
53
+ result = yield
54
+ Thread.current[:at_time] = nil
55
+ else
56
+ result = yield
57
+ end
58
+ result
59
+ end
60
+
61
+ def limited_ids_for(*args)
62
+ threadify_at do
63
+ super *args
64
+ end
65
+ end
66
+
67
+ def exec_queries
68
+ # Note that record preloading, like when you specify
69
+ # MyClass.includes(:associations)
70
+ # happens within this exec_queries call. That's why we needed to
71
+ # store the at_time in the thread above.
72
+ threadify_at do
73
+ super
74
+ end
75
+
76
+ if historical?
77
+ # Store the at value on each record returned
78
+ # TODO: traverse preloaded associations too
79
+ @records.each do |r|
80
+ r.at_value = at_value
81
+ end
82
+ end
83
+ @records
84
+ end
85
+
86
+ def historical?
87
+ table_name =~ /_h$/i && at_value
88
+ end
89
+ end
90
+
91
+ # Uses the time from the "at" field stored in the record to filter queries
92
+ # made to associations.
93
+ module AssociationExtensions
94
+ def target_scope
95
+ if @owner.respond_to?(:at_value)
96
+ # If this is a history record but no at time was given,
97
+ # assume the record's effective to date
98
+ super.at(@owner.at_value || @owner.eff_to)
99
+ else
100
+ super
101
+ end
102
+ end
103
+ end
104
+
105
+ # Uses the at time when fetching preloaded records
106
+ module PreloaderExtensions
107
+ def build_scope
108
+ # It seems the at time can be in either of these places, but not both,
109
+ # depending on when the preloading happens to be done
110
+ at_time = @owners.first.at_value if @owners.first.respond_to?(:at_value)
111
+ at_time ||= Thread.current[:at_time]
112
+
113
+ if at_time
114
+ super.at(at_time)
115
+ else
116
+ super
117
+ end
118
+ end
119
+ end
120
+
121
+ end
122
+
123
+ ActiveRecord::Relation.send :prepend, TemporalTables::RelationExtensions
124
+ ActiveRecord::Associations::Association.send :prepend, TemporalTables::AssociationExtensions
125
+ ActiveRecord::Associations::Preloader::Association.send :prepend, TemporalTables::PreloaderExtensions