chrono_model 1.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +5 -5
  2. data/.travis.yml +19 -14
  3. data/README.md +49 -25
  4. data/lib/chrono_model.rb +37 -3
  5. data/lib/chrono_model/adapter.rb +91 -874
  6. data/lib/chrono_model/adapter/ddl.rb +225 -0
  7. data/lib/chrono_model/adapter/indexes.rb +194 -0
  8. data/lib/chrono_model/adapter/migrations.rb +282 -0
  9. data/lib/chrono_model/adapter/tsrange.rb +57 -0
  10. data/lib/chrono_model/adapter/upgrade.rb +120 -0
  11. data/lib/chrono_model/conversions.rb +20 -0
  12. data/lib/chrono_model/json.rb +28 -0
  13. data/lib/chrono_model/patches.rb +8 -232
  14. data/lib/chrono_model/patches/as_of_time_holder.rb +23 -0
  15. data/lib/chrono_model/patches/as_of_time_relation.rb +19 -0
  16. data/lib/chrono_model/patches/association.rb +52 -0
  17. data/lib/chrono_model/patches/db_console.rb +11 -0
  18. data/lib/chrono_model/patches/join_node.rb +32 -0
  19. data/lib/chrono_model/patches/preloader.rb +68 -0
  20. data/lib/chrono_model/patches/relation.rb +58 -0
  21. data/lib/chrono_model/time_gate.rb +5 -5
  22. data/lib/chrono_model/time_machine.rb +47 -427
  23. data/lib/chrono_model/time_machine/history_model.rb +196 -0
  24. data/lib/chrono_model/time_machine/time_query.rb +86 -0
  25. data/lib/chrono_model/time_machine/timeline.rb +94 -0
  26. data/lib/chrono_model/utilities.rb +27 -0
  27. data/lib/chrono_model/version.rb +1 -1
  28. data/spec/aruba/dbconsole_spec.rb +25 -0
  29. data/spec/chrono_model/adapter/counter_cache_race_spec.rb +46 -0
  30. data/spec/{adapter_spec.rb → chrono_model/adapter_spec.rb} +124 -5
  31. data/spec/{utils_spec.rb → chrono_model/conversions_spec.rb} +0 -0
  32. data/spec/{json_ops_spec.rb → chrono_model/json_ops_spec.rb} +11 -0
  33. data/spec/{time_machine_spec.rb → chrono_model/time_machine_spec.rb} +15 -5
  34. data/spec/{time_query_spec.rb → chrono_model/time_query_spec.rb} +0 -0
  35. data/spec/config.travis.yml +1 -0
  36. data/spec/config.yml.example +1 -0
  37. metadata +35 -14
  38. data/lib/chrono_model/utils.rb +0 -117
@@ -0,0 +1,57 @@
1
+ module ChronoModel
2
+ class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
3
+
4
+ module TSRange
5
+ # HACK: Redefine tsrange parsing support, as it is broken currently.
6
+ #
7
+ # This self-made API is here because currently AR4 does not support
8
+ # open-ended ranges. The reasons are poor support in Ruby:
9
+ #
10
+ # https://bugs.ruby-lang.org/issues/6864
11
+ #
12
+ # and an instable interface in Active Record:
13
+ #
14
+ # https://github.com/rails/rails/issues/13793
15
+ # https://github.com/rails/rails/issues/14010
16
+ #
17
+ # so, for now, we are implementing our own.
18
+ #
19
+ class Type < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range
20
+ OID = 3908
21
+
22
+ def cast_value(value)
23
+ return if value == 'empty'
24
+ return value if value.is_a?(::Array)
25
+
26
+ extracted = extract_bounds(value)
27
+
28
+ from = Conversions.string_to_utc_time extracted[:from]
29
+ to = Conversions.string_to_utc_time extracted[:to ]
30
+
31
+ [from, to]
32
+ end
33
+
34
+ def extract_bounds(value)
35
+ from, to = value[1..-2].split(',')
36
+ {
37
+ from: (value[1] == ',' || from == '-infinity') ? nil : from[1..-2],
38
+ to: (value[-2] == ',' || to == 'infinity') ? nil : to[1..-2],
39
+ }
40
+ end
41
+ end
42
+
43
+ def initialize_type_map(m = type_map)
44
+ super.tap do
45
+ typ = ChronoModel::Adapter::TSRange::Type
46
+ oid = typ::OID
47
+
48
+ ar_type = type_map.fetch(oid)
49
+ cm_type = typ.new(ar_type.subtype, ar_type.type)
50
+
51
+ type_map.register_type oid, cm_type
52
+ end
53
+ end
54
+ end
55
+
56
+ end
57
+ end
@@ -0,0 +1,120 @@
1
+ module ChronoModel
2
+ class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
3
+
4
+ module Upgrade
5
+ private
6
+ def chrono_upgrade!
7
+ chrono_ensure_schemas
8
+
9
+ chrono_upgrade_structure!
10
+ end
11
+
12
+ # Locate tables needing a structure upgrade
13
+ #
14
+ def chrono_tables_needing_upgrade
15
+ tables = { }
16
+
17
+ on_temporal_schema { self.tables }.each do |table_name|
18
+ next unless is_chrono?(table_name)
19
+ metadata = chrono_metadata_for(table_name)
20
+ version = metadata['chronomodel']
21
+
22
+ if version.blank?
23
+ tables[table_name] = { version: nil, priority: 'CRITICAL' }
24
+ elsif version != VERSION
25
+ tables[table_name] = { version: version, priority: 'LOW' }
26
+ end
27
+ end
28
+
29
+ return tables
30
+ end
31
+
32
+ # Emit a warning about tables needing an upgrade
33
+ #
34
+ def chrono_upgrade_warning
35
+ upgrade = chrono_tables_needing_upgrade.map do |table, desc|
36
+ "#{table} - priority: #{desc[:priority]}"
37
+ end.join('; ')
38
+
39
+ return if upgrade.empty?
40
+
41
+ logger.warn "ChronoModel: There are tables needing a structure upgrade, and ChronoModel structures need to be recreated."
42
+ logger.warn "ChronoModel: Please run ChronoModel.upgrade! to attempt the upgrade. If you have dependant database objects"
43
+ logger.warn "ChronoModel: the upgrade will fail and you have to drop the dependent objects, run .upgrade! and create them"
44
+ logger.warn "ChronoModel: again. Sorry. Some features or the whole library may not work correctly until upgrade is complete."
45
+ logger.warn "ChronoModel: Tables pending upgrade: #{upgrade}"
46
+ end
47
+
48
+ # Upgrades existing structure for each table, if required.
49
+ #
50
+ def chrono_upgrade_structure!
51
+ transaction do
52
+
53
+ chrono_tables_needing_upgrade.each do |table_name, desc|
54
+
55
+ if desc[:version].blank?
56
+ logger.info "ChronoModel: Upgrading legacy PG 9.0 table #{table_name} to #{VERSION}"
57
+ chrono_upgrade_from_postgres_9_0(table_name)
58
+ logger.info "ChronoModel: legacy #{table_name} upgrade complete"
59
+ else
60
+ logger.info "ChronoModel: upgrading #{table_name} from #{desc[:version]} to #{VERSION}"
61
+ chrono_create_view_for(table_name)
62
+ logger.info "ChronoModel: #{table_name} upgrade complete"
63
+ end
64
+
65
+ end
66
+ end
67
+ rescue => e
68
+ message = "ChronoModel structure upgrade failed: #{e.message}. Please drop dependent objects first and then run ChronoModel.upgrade! again."
69
+
70
+ # Quite important, output it also to stderr.
71
+ #
72
+ logger.error message
73
+ $stderr.puts message
74
+ end
75
+
76
+ def chrono_upgrade_from_postgres_9_0(table_name)
77
+ # roses are red
78
+ # violets are blue
79
+ # and this is the most boring piece of code ever
80
+ history_table = "#{HISTORY_SCHEMA}.#{table_name}"
81
+ p_pkey = primary_key(table_name)
82
+
83
+ execute "ALTER TABLE #{history_table} ADD COLUMN validity tsrange;"
84
+ execute """
85
+ UPDATE #{history_table} SET validity = tsrange(valid_from,
86
+ CASE WHEN extract(year from valid_to) = 9999 THEN NULL
87
+ ELSE valid_to
88
+ END
89
+ );
90
+ """
91
+
92
+ execute "DROP INDEX #{history_table}_temporal_on_valid_from;"
93
+ execute "DROP INDEX #{history_table}_temporal_on_valid_from_and_valid_to;"
94
+ execute "DROP INDEX #{history_table}_temporal_on_valid_to;"
95
+ execute "DROP INDEX #{history_table}_inherit_pkey"
96
+ execute "DROP INDEX #{history_table}_recorded_at"
97
+ execute "DROP INDEX #{history_table}_instance_history"
98
+ execute "ALTER TABLE #{history_table} DROP CONSTRAINT #{table_name}_valid_from_before_valid_to;"
99
+ execute "ALTER TABLE #{history_table} DROP CONSTRAINT #{table_name}_timeline_consistency;"
100
+ execute "DROP RULE #{table_name}_upd_first ON #{table_name};"
101
+ execute "DROP RULE #{table_name}_upd_next ON #{table_name};"
102
+ execute "DROP RULE #{table_name}_del ON #{table_name};"
103
+ execute "DROP RULE #{table_name}_ins ON #{table_name};"
104
+ execute "DROP TRIGGER history_ins ON #{TEMPORAL_SCHEMA}.#{table_name};"
105
+ execute "DROP FUNCTION #{TEMPORAL_SCHEMA}.#{table_name}_ins();"
106
+ execute "ALTER TABLE #{history_table} DROP COLUMN valid_from;"
107
+ execute "ALTER TABLE #{history_table} DROP COLUMN valid_to;"
108
+
109
+ execute "CREATE EXTENSION IF NOT EXISTS btree_gist;"
110
+
111
+ chrono_create_view_for(table_name)
112
+ on_history_schema { add_history_validity_constraint(table_name, p_pkey) }
113
+ on_history_schema { chrono_create_history_indexes_for(table_name, p_pkey) }
114
+ end
115
+ # private
116
+ end
117
+
118
+ end
119
+
120
+ end
@@ -0,0 +1,20 @@
1
+ module ChronoModel
2
+
3
+ module Conversions
4
+ extend self
5
+
6
+ ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d+))?\z/
7
+
8
+ def string_to_utc_time(string)
9
+ if string =~ ISO_DATETIME
10
+ usec = $7.nil? ? '000000' : $7.ljust(6, '0') # .1 is .100000, not .000001
11
+ Time.utc $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, usec.to_i
12
+ end
13
+ end
14
+
15
+ def time_to_utc_string(time)
16
+ [time.to_s(:db), sprintf('%06d', time.usec)].join '.'
17
+ end
18
+ end
19
+
20
+ end
@@ -0,0 +1,28 @@
1
+ module ChronoModel
2
+
3
+ module Json
4
+ extend self
5
+
6
+ def create
7
+ puts "ChronoModel: WARNING - JSON ops are deprecated. Please migrate to JSONB"
8
+
9
+ adapter.execute 'CREATE OR REPLACE LANGUAGE plpythonu'
10
+ adapter.execute File.read(sql 'json_ops.sql')
11
+ end
12
+
13
+ def drop
14
+ adapter.execute File.read(sql 'uninstall-json_ops.sql')
15
+ adapter.execute 'DROP LANGUAGE IF EXISTS plpythonu'
16
+ end
17
+
18
+ private
19
+ def sql(file)
20
+ File.dirname(__FILE__) + '/../../sql/' + file
21
+ end
22
+
23
+ def adapter
24
+ ActiveRecord::Base.connection
25
+ end
26
+ end
27
+
28
+ end
@@ -1,232 +1,8 @@
1
- require 'active_record'
2
-
3
- module ChronoModel
4
- module Patches
5
-
6
- module AsOfTimeHolder
7
- # Sets the virtual 'as_of_time' attribute to the given time, converting to UTC.
8
- #
9
- def as_of_time!(time)
10
- @_as_of_time = time.utc
11
-
12
- self
13
- end
14
-
15
- # Reads the virtual 'as_of_time' attribute
16
- #
17
- def as_of_time
18
- @_as_of_time
19
- end
20
- end
21
-
22
- # This class supports the AR 5.0 code that expects to receive an
23
- # Arel::Table as the left join node. We need to replace the node
24
- # with a virtual table that fetches from the history at a given
25
- # point in time, we replace the join node with a SqlLiteral node
26
- # that does not respond to the methods that AR expects.
27
- #
28
- # This class provides AR with an object implementing the methods
29
- # it expects, yet producing SQL that fetches from history tables
30
- # as-of-time.
31
- #
32
- class JoinNode < Arel::Nodes::SqlLiteral
33
- attr_reader :name, :table_name, :table_alias, :as_of_time
34
-
35
- def initialize(join_node, history_model, as_of_time)
36
- @name = join_node.table_name
37
- @table_name = join_node.table_name
38
- @table_alias = join_node.table_alias
39
-
40
- @as_of_time = as_of_time
41
-
42
- virtual_table = history_model.
43
- virtual_table_at(@as_of_time, @table_alias || @table_name)
44
-
45
- super(virtual_table)
46
- end
47
- end
48
-
49
- module Relation
50
- include AsOfTimeHolder
51
-
52
- def load
53
- return super unless @_as_of_time && !loaded?
54
-
55
- super.each {|record| record.as_of_time!(@_as_of_time) }
56
- end
57
-
58
- def merge(*)
59
- return super unless @_as_of_time
60
-
61
- super.as_of_time!(@_as_of_time)
62
- end
63
-
64
- def build_arel(*)
65
- return super unless @_as_of_time
66
-
67
- super.tap do |arel|
68
-
69
- arel.join_sources.each do |join|
70
- # This case happens with nested includes, where the below
71
- # code has already replaced the join.left with a JoinNode.
72
- #
73
- next if join.left.respond_to?(:as_of_time)
74
-
75
- model = TimeMachine.chrono_models[join.left.table_name]
76
- next unless model
77
-
78
- join.left = JoinNode.new(join.left, model.history, @_as_of_time)
79
- end
80
-
81
- end
82
- end
83
-
84
- # Build a preloader at the +as_of_time+ of this relation.
85
- # Pass the current model to define Relation
86
- #
87
- def build_preloader
88
- ActiveRecord::Associations::Preloader.new(
89
- model: self.model, as_of_time: as_of_time
90
- )
91
- end
92
- end
93
-
94
- # This class is a dummy relation whose scope is only to pass around the
95
- # as_of_time parameters across ActiveRecord call chains.
96
- #
97
- # With AR 5.2 a simple relation can be used, as the only required argument
98
- # is the model. 5.0 and 5.1 require more arguments, that are passed here.
99
- #
100
- class AsOfTimeRelation < ActiveRecord::Relation
101
- if ActiveRecord::VERSION::STRING.to_f < 5.2
102
- def initialize(klass, table: klass.arel_table, predicate_builder: klass.predicate_builder, values: {})
103
- super(klass, table, predicate_builder, values)
104
- end
105
- end
106
- end
107
-
108
- # Patches ActiveRecord::Associations::Preloader to add support for
109
- # temporal associations. This is tying itself to Rails internals
110
- # and it is ugly :-(.
111
- #
112
- module Preloader
113
- attr_reader :options
114
-
115
- # We overwrite the initializer in order to pass the +as_of_time+
116
- # parameter above in the build_preloader
117
- #
118
- def initialize(options = {})
119
- @options = options.freeze
120
- end
121
-
122
- # Patches the AR Preloader (lib/active_record/associations/preloader.rb)
123
- # in order to carry around the +as_of_time+ of the original invocation.
124
- #
125
- # * The +records+ are the parent records where the association is defined
126
- # * The +associations+ are the association names involved in preloading
127
- # * The +given_preload_scope+ is the preloading scope, that is used only
128
- # in the :through association and it holds the intermediate records
129
- # _through_ which the final associated records are eventually fetched.
130
- #
131
- # As the +preload_scope+ is passed around to all the different
132
- # incarnations of the preloader strategies, we are using it to pass
133
- # around the +as_of_time+ of the original query invocation, so that
134
- # preloaded records are preloaded honoring the +as_of_time+.
135
- #
136
- # The +preload_scope+ is present only in through associations, but the
137
- # preloader interfaces expect it to be always defined, for consistency.
138
- #
139
- # For `:through` associations, the +given_preload_scope+ is already a
140
- # +Relation+, that already has the +as_of_time+ getters and setters,
141
- # so we use it directly.
142
- #
143
- def preload(records, associations, given_preload_scope = nil)
144
- if options[:as_of_time]
145
- preload_scope = given_preload_scope ||
146
- AsOfTimeRelation.new(options[:model])
147
-
148
- preload_scope.as_of_time!(options[:as_of_time])
149
- end
150
-
151
- super records, associations, preload_scope
152
- end
153
-
154
- module Association
155
- # Builds the preloader scope taking into account a potential
156
- # +as_of_time+ passed down the call chain starting at the
157
- # end user invocation.
158
- #
159
- def build_scope
160
- scope = super
161
-
162
- if preload_scope.try(:as_of_time)
163
- scope = scope.as_of(preload_scope.as_of_time)
164
- end
165
-
166
- return scope
167
- end
168
- end
169
- end
170
-
171
- # Patches ActiveRecord::Associations::Association to add support for
172
- # temporal associations.
173
- #
174
- # Each record fetched from the +as_of+ scope on the owner class will have
175
- # an additional "as_of_time" field yielding the UTC time of the request,
176
- # then the as_of scope is called on either this association's class or
177
- # on the join model's (:through association) one.
178
- #
179
- module Association
180
- def skip_statement_cache?(*)
181
- super || _chrono_target?
182
- end
183
-
184
- # If the association class or the through association are ChronoModels,
185
- # then fetches the records from a virtual table using a subquery scope
186
- # to a specific timestamp.
187
- def scope
188
- scope = super
189
- return scope unless _chrono_record?
190
-
191
- if _chrono_target?
192
- # For standard associations, replace the table name with the virtual
193
- # as-of table name at the owner's as-of-time
194
- #
195
- scope = scope.from(klass.history.virtual_table_at(owner.as_of_time))
196
- elsif respond_to?(:through_reflection) && through_reflection.klass.chrono?
197
-
198
- # For through associations, replace the joined table name instead
199
- # with a virtual table that selects records from the history at
200
- # the given +as_of_time+.
201
- #
202
- scope.join_sources.each do |join|
203
- if join.left.name == through_reflection.klass.table_name
204
- history_model = through_reflection.klass.history
205
-
206
- join.left = JoinNode.new(join.left, history_model, owner.as_of_time)
207
- end
208
- end
209
- end
210
-
211
- scope.as_of_time!(owner.as_of_time)
212
-
213
- return scope
214
- end
215
-
216
- private
217
- def _chrono_record?
218
- owner.respond_to?(:as_of_time) && owner.as_of_time.present?
219
- end
220
-
221
- def _chrono_target?
222
- @_target_klass ||= reflection.options[:polymorphic] ?
223
- owner.public_send(reflection.foreign_type).constantize :
224
- reflection.klass
225
-
226
- @_target_klass.chrono?
227
- end
228
-
229
- end
230
-
231
- end
232
- end
1
+ require 'chrono_model/patches/as_of_time_holder'
2
+ require 'chrono_model/patches/as_of_time_relation'
3
+
4
+ require 'chrono_model/patches/join_node'
5
+ require 'chrono_model/patches/relation'
6
+ require 'chrono_model/patches/preloader'
7
+ require 'chrono_model/patches/association'
8
+ require 'chrono_model/patches/db_console'