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,196 @@
1
+ module ChronoModel
2
+ module TimeMachine
3
+
4
+ module HistoryModel
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ self.table_name = [Adapter::HISTORY_SCHEMA, superclass.table_name].join('.')
9
+
10
+ scope :chronological, -> { order(Arel.sql('lower(validity) ASC')) }
11
+ end
12
+
13
+ # Methods that make up the history interface of the companion History
14
+ # model, automatically built for each Model that includes TimeMachine
15
+ #
16
+ module ClassMethods
17
+ include ChronoModel::TimeMachine::TimeQuery
18
+ include ChronoModel::TimeMachine::Timeline
19
+
20
+ # HACK. find() and save() require the real history ID. So we are
21
+ # setting it now and ensuring to reset it to the original one after
22
+ # execution completes. FIXME
23
+ #
24
+ def with_hid_pkey(&block)
25
+ old = self.primary_key
26
+ self.primary_key = :hid
27
+
28
+ block.call
29
+ ensure
30
+ self.primary_key = old
31
+ end
32
+
33
+ def find(*)
34
+ with_hid_pkey { super }
35
+ end
36
+
37
+ # In the History context, pre-fill the :on options with the validity interval.
38
+ #
39
+ def time_query(match, time, options = {})
40
+ options[:on] ||= :validity
41
+ super
42
+ end
43
+
44
+ def past
45
+ time_query(:before, :now).where("NOT upper_inf(#{quoted_table_name}.validity)")
46
+ end
47
+
48
+ # To identify this class as the History subclass
49
+ def history?
50
+ true
51
+ end
52
+
53
+ # Getting the correct quoted_table_name can be tricky when
54
+ # STI is involved. If Orange < Fruit, then Orange::History < Fruit::History
55
+ # (see define_inherited_history_model_for).
56
+ # This means that the superclass method returns Fruit::History, which
57
+ # will give us the wrong table name. What we actually want is the
58
+ # superclass of Fruit::History, which is Fruit. So, we use
59
+ # non_history_superclass instead. -npj
60
+ def non_history_superclass(klass = self)
61
+ if klass.superclass.history?
62
+ non_history_superclass(klass.superclass)
63
+ else
64
+ klass.superclass
65
+ end
66
+ end
67
+
68
+ def relation
69
+ super.as_of_time!(Time.now)
70
+ end
71
+
72
+ # Fetches as of +time+ records.
73
+ #
74
+ def as_of(time)
75
+ non_history_superclass.from(virtual_table_at(time)).as_of_time!(time)
76
+ end
77
+
78
+ def virtual_table_at(time, name = nil)
79
+ name = name ? connection.quote_table_name(name) :
80
+ non_history_superclass.quoted_table_name
81
+
82
+ "(#{at(time).to_sql}) #{name}"
83
+ end
84
+
85
+ # Fetches history record at the given time
86
+ #
87
+ def at(time)
88
+ time_query(:at, time).from(quoted_table_name).as_of_time!(time)
89
+ end
90
+
91
+ # Returns the history sorted by recorded_at
92
+ #
93
+ def sorted
94
+ all.order(Arel.sql(%[ #{quoted_table_name}."recorded_at" ASC, #{quoted_table_name}."hid" ASC ]))
95
+ end
96
+
97
+ # Fetches the given +object+ history, sorted by history record time
98
+ # by default. Always includes an "as_of_time" column that is either
99
+ # the upper bound of the validity range or now() if history validity
100
+ # is maximum.
101
+ #
102
+ def of(object)
103
+ where(id: object)
104
+ end
105
+ end
106
+
107
+ # The history id is `hid`, but this cannot set as primary key
108
+ # or temporal assocations will break. Solutions are welcome.
109
+ def id
110
+ hid
111
+ end
112
+
113
+ # Referenced record ID.
114
+ #
115
+ def rid
116
+ attributes[self.class.primary_key]
117
+ end
118
+
119
+ def save(*)
120
+ self.class.with_hid_pkey { super }
121
+ end
122
+
123
+ def save!(*)
124
+ self.class.with_hid_pkey { super }
125
+ end
126
+
127
+ def update_columns(*)
128
+ self.class.with_hid_pkey { super }
129
+ end
130
+
131
+ # Returns the previous history entry, or nil if this
132
+ # is the first one.
133
+ #
134
+ def pred
135
+ return if self.valid_from.nil?
136
+
137
+ if self.class.timeline_associations.empty?
138
+ self.class.where('id = ? AND upper(validity) = ?', rid, valid_from).first
139
+ else
140
+ super(id: rid, before: valid_from, table: self.class.superclass.quoted_table_name)
141
+ end
142
+ end
143
+
144
+ # Returns the next history entry, or nil if this is the
145
+ # last one.
146
+ #
147
+ def succ
148
+ return if self.valid_to.nil?
149
+
150
+ if self.class.timeline_associations.empty?
151
+ self.class.where('id = ? AND lower(validity) = ?', rid, valid_to).first
152
+ else
153
+ super(id: rid, after: valid_to, table: self.class.superclass.quoted_table_name)
154
+ end
155
+ end
156
+ alias :next :succ
157
+
158
+ # Returns the first history entry
159
+ #
160
+ def first
161
+ self.class.where(id: rid).chronological.first
162
+ end
163
+
164
+ # Returns the last history entry
165
+ #
166
+ def last
167
+ self.class.where(id: rid).chronological.last
168
+ end
169
+
170
+ # Returns this history entry's current record
171
+ #
172
+ def current_version
173
+ self.class.non_history_superclass.find(rid)
174
+ end
175
+
176
+ def record #:nodoc:
177
+ ActiveSupport::Deprecation.warn '.record is deprecated in favour of .current_version'
178
+ self.current_version
179
+ end
180
+
181
+ def valid_from
182
+ validity.first
183
+ end
184
+
185
+ def valid_to
186
+ validity.last
187
+ end
188
+ alias as_of_time valid_to
189
+
190
+ def recorded_at
191
+ ChronoModel::Conversions.string_to_utc_time attributes_before_type_cast['recorded_at']
192
+ end
193
+ end
194
+
195
+ end
196
+ end
@@ -0,0 +1,86 @@
1
+ module ChronoModel
2
+ module TimeMachine
3
+
4
+ #
5
+ # TODO Documentation
6
+ #
7
+ module TimeQuery
8
+ def time_query(match, time, options)
9
+ range = columns_hash.fetch(options[:on].to_s)
10
+
11
+ where(time_query_sql(match, time, range, options))
12
+ end
13
+
14
+ private
15
+ def time_query_sql(match, time, range, options)
16
+ case match
17
+ when :at
18
+ build_time_query_at(time, range)
19
+
20
+ when :not
21
+ "NOT (#{build_time_query_at(time, range)})"
22
+
23
+ when :before
24
+ op = options.fetch(:inclusive, true) ? '&&' : '@>'
25
+ build_time_query(['NULL', time_for_time_query(time, range)], range, op)
26
+
27
+ when :after
28
+ op = options.fetch(:inclusive, true) ? '&&' : '@>'
29
+ build_time_query([time_for_time_query(time, range), 'NULL'], range, op)
30
+
31
+ else
32
+ raise ChronoModel::Error, "Invalid time_query: #{match}"
33
+ end
34
+ end
35
+
36
+ def time_for_time_query(t, column)
37
+ if t == :now || t == :today
38
+ now_for_column(column)
39
+ else
40
+ quoted_t = connection.quote(connection.quoted_date(t))
41
+ [quoted_t, primitive_type_for_column(column)].join('::')
42
+ end
43
+ end
44
+
45
+ def now_for_column(column)
46
+ case column.type
47
+ when :tsrange, :tstzrange then "timezone('UTC', current_timestamp)"
48
+ when :daterange then "current_date"
49
+ else raise "Cannot generate 'now()' for #{column.type} column #{column.name}"
50
+ end
51
+ end
52
+
53
+ def primitive_type_for_column(column)
54
+ case column.type
55
+ when :tsrange then :timestamp
56
+ when :tstzrange then :timestamptz
57
+ when :daterange then :date
58
+ else raise "Don't know how to map #{column.type} column #{column.name} to a primitive type"
59
+ end
60
+ end
61
+
62
+ def build_time_query_at(time, range)
63
+ time = if time.kind_of?(Array)
64
+ time.map! {|t| time_for_time_query(t, range)}
65
+
66
+ # If both edges of the range are the same the query fails using the '&&' operator.
67
+ # The correct solution is to use the <@ operator.
68
+ time.first == time.last ? time.first : time
69
+ else
70
+ time_for_time_query(time, range)
71
+ end
72
+
73
+ build_time_query(time, range)
74
+ end
75
+
76
+ def build_time_query(time, range, op = '&&')
77
+ if time.kind_of?(Array)
78
+ Arel.sql %[ #{range.type}(#{time.first}, #{time.last}) #{op} #{table_name}.#{range.name} ]
79
+ else
80
+ Arel.sql %[ #{time} <@ #{table_name}.#{range.name} ]
81
+ end
82
+ end
83
+ end
84
+
85
+ end
86
+ end
@@ -0,0 +1,94 @@
1
+ module ChronoModel
2
+ module TimeMachine
3
+
4
+ module Timeline
5
+ # Returns an Array of unique UTC timestamps for which at least an
6
+ # history record exists. Takes temporal associations into account.
7
+ #
8
+ def timeline(record = nil, options = {})
9
+ rid = record.respond_to?(:rid) ? record.rid : record.id if record
10
+
11
+ assocs = options.key?(:with) ?
12
+ timeline_associations_from(options[:with]) : timeline_associations
13
+
14
+ models = []
15
+ models.push self if self.chrono?
16
+ models.concat(assocs.map {|a| a.klass.history})
17
+
18
+ return [] if models.empty?
19
+
20
+ fields = models.inject([]) {|a,m| a.concat m.quoted_history_fields}
21
+
22
+ relation = self.except(:order).
23
+ select("DISTINCT UNNEST(ARRAY[#{fields.join(',')}]) AS ts")
24
+
25
+ if assocs.present?
26
+ relation = relation.joins(*assocs.map(&:name))
27
+ end
28
+
29
+ relation = relation.
30
+ order('ts ' << (options[:reverse] ? 'DESC' : 'ASC'))
31
+
32
+ relation = relation.from(%["public".#{quoted_table_name}]) unless self.chrono?
33
+ relation = relation.where(id: rid) if rid
34
+
35
+ sql = "SELECT ts FROM ( #{relation.to_sql} ) foo WHERE ts IS NOT NULL"
36
+
37
+ if options.key?(:before)
38
+ sql << " AND ts < '#{Conversions.time_to_utc_string(options[:before])}'"
39
+ end
40
+
41
+ if options.key?(:after)
42
+ sql << " AND ts > '#{Conversions.time_to_utc_string(options[:after ])}'"
43
+ end
44
+
45
+ if rid && !options[:with]
46
+ sql << (self.chrono? ? %{
47
+ AND ts <@ ( SELECT tsrange(min(lower(validity)), max(upper(validity)), '[]') FROM #{quoted_table_name} WHERE id = #{rid} )
48
+ } : %[ AND ts < NOW() ])
49
+ end
50
+
51
+ sql << " LIMIT #{options[:limit].to_i}" if options.key?(:limit)
52
+
53
+ sql.gsub! 'INNER JOIN', 'LEFT OUTER JOIN'
54
+
55
+ connection.on_schema(Adapter::HISTORY_SCHEMA) do
56
+ connection.select_values(sql, "#{self.name} periods").map! do |ts|
57
+ Conversions.string_to_utc_time ts
58
+ end
59
+ end
60
+ end
61
+
62
+ def has_timeline(options)
63
+ options.assert_valid_keys(:with)
64
+
65
+ timeline_associations_from(options[:with]).tap do |assocs|
66
+ timeline_associations.concat assocs
67
+ end
68
+ end
69
+
70
+ def timeline_associations
71
+ @timeline_associations ||= []
72
+ end
73
+
74
+ def timeline_associations_from(names)
75
+ Array.wrap(names).map do |name|
76
+ reflect_on_association(name) or raise ArgumentError,
77
+ "No association found for name `#{name}'"
78
+ end
79
+ end
80
+
81
+ def quoted_history_fields
82
+ @quoted_history_fields ||= begin
83
+ validity =
84
+ [connection.quote_table_name(table_name),
85
+ connection.quote_column_name('validity')
86
+ ].join('.')
87
+
88
+ [:lower, :upper].map! {|func| "#{func}(#{validity})"}
89
+ end
90
+ end
91
+ end
92
+
93
+ end
94
+ end
@@ -0,0 +1,27 @@
1
+ module ChronoModel
2
+
3
+ module Utilities
4
+ # Amends the given history item setting a different period.
5
+ # Useful when migrating from legacy systems.
6
+ #
7
+ # To use it, extend AR::Base with ChronoModel::Utilities
8
+ #
9
+ # ActiveRecord::Base.instance_eval do
10
+ # extend ChronoModel::Utilities
11
+ # end
12
+ #
13
+ def amend_period!(hid, from, to)
14
+ unless [from, to].any? {|ts| ts.respond_to?(:zone) && ts.zone == 'UTC'}
15
+ raise 'Can amend history only with UTC timestamps'
16
+ end
17
+
18
+ connection.execute %[
19
+ UPDATE #{quoted_table_name}
20
+ SET "validity" = tsrange(#{connection.quote(from)}, #{connection.quote(to)}),
21
+ "recorded_at" = #{connection.quote(from)}
22
+ WHERE "hid" = #{hid.to_i}
23
+ ]
24
+ end
25
+ end
26
+
27
+ end
@@ -1,3 +1,3 @@
1
1
  module ChronoModel
2
- VERSION = "1.0.1"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -0,0 +1,25 @@
1
+ # The db consle does not work on Rails 5.0
2
+ #
3
+ unless Bundler.default_gemfile.to_s =~ /rails_5.0/
4
+
5
+
6
+ require 'spec_helper'
7
+
8
+ describe 'rails dbconsole' do
9
+ before do
10
+ write_file(
11
+ 'config/database.yml',
12
+ File.read(File.expand_path('fixtures/database_without_username_and_password.yml', __dir__)))
13
+ end
14
+
15
+ describe 'rails dbconsole', type: :aruba do
16
+ let(:action) { run_command("bash -c \"echo 'select 1 as foo_column; \\q' | bundle exec rails db\"") }
17
+ let(:last_command) { action && last_command_started }
18
+
19
+ specify { expect(last_command).to be_successfully_executed }
20
+ specify { expect(last_command).to have_output(/\bfoo_column\b/) }
21
+ end
22
+ end
23
+
24
+
25
+ end
@@ -0,0 +1,46 @@
1
+ require 'spec_helper'
2
+ require 'support/helpers'
3
+
4
+ describe 'models with counter cache' do
5
+ include ChronoTest::Helpers::TimeMachine
6
+
7
+ adapter.create_table 'sections', temporal: true, no_journal: %w( articles_count ) do |t|
8
+ t.string :name
9
+ t.integer :articles_count, default: 0
10
+ end
11
+
12
+ adapter.create_table 'articles', temporal: true do |t|
13
+ t.string :title
14
+ t.references :section
15
+ end
16
+
17
+ class ::Section < ActiveRecord::Base
18
+ include ChronoModel::TimeMachine
19
+
20
+ has_many :articles
21
+ end
22
+
23
+ class ::Article < ActiveRecord::Base
24
+ include ChronoModel::TimeMachine
25
+
26
+ belongs_to :section, counter_cache: true
27
+ end
28
+
29
+ describe 'are not subject to race condition if no_journal is set on the counter cache column' do
30
+ specify do
31
+ section = Section.create!
32
+
33
+ expect(section.articles_count).to eq(0)
34
+ Article.create!(section_id: section.id)
35
+ expect(section.reload.articles_count).to eq(1)
36
+
37
+ num_threads = 10
38
+
39
+ expect {
40
+ Array.new(num_threads).map do
41
+ Thread.new { Article.create!(section_id: section.id) }
42
+ end.each(&:join)
43
+ }.to_not raise_error
44
+ end
45
+ end
46
+ end