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,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