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,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,3 @@
1
+ module TemporalTables
2
+ VERSION = "0.6.1"
3
+ 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,9 @@
1
+ class Combustion::Database::Reset
2
+ def call
3
+ configuration = resettable_db_configs[Rails.env]
4
+ adapter = configuration["adapter"] ||
5
+ configuration["url"].split("://").first
6
+
7
+ operator_class(adapter).new(configuration).reset
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ class Coven < ActiveRecord::Base
2
+ has_many :people
3
+ end
@@ -0,0 +1,4 @@
1
+ class Person < ActiveRecord::Base
2
+ belongs_to :coven
3
+ has_many :warts
4
+ end
@@ -0,0 +1,7 @@
1
+ class Wart < ActiveRecord::Base
2
+ belongs_to :person
3
+
4
+ scope :very_hairy, -> {
5
+ where(arel_table[:hairiness].gteq(3))
6
+ }
7
+ end
@@ -0,0 +1,12 @@
1
+ mysql:
2
+ adapter: mysql2
3
+ database: temporal_tables_test
4
+ host: localhost
5
+ username: root
6
+ password:
7
+
8
+ postgresql:
9
+ adapter: postgresql
10
+ database: temporal_tables_test
11
+ user:
12
+ password:
@@ -0,0 +1,3 @@
1
+ Rails.application.routes.draw do
2
+ #
3
+ 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