chrono_model 1.0.1 → 1.1.0

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 (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'