temporal_tables 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
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