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