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.
- checksums.yaml +5 -5
- data/.travis.yml +19 -14
- data/README.md +49 -25
- data/lib/chrono_model.rb +37 -3
- data/lib/chrono_model/adapter.rb +91 -874
- data/lib/chrono_model/adapter/ddl.rb +225 -0
- data/lib/chrono_model/adapter/indexes.rb +194 -0
- data/lib/chrono_model/adapter/migrations.rb +282 -0
- data/lib/chrono_model/adapter/tsrange.rb +57 -0
- data/lib/chrono_model/adapter/upgrade.rb +120 -0
- data/lib/chrono_model/conversions.rb +20 -0
- data/lib/chrono_model/json.rb +28 -0
- data/lib/chrono_model/patches.rb +8 -232
- data/lib/chrono_model/patches/as_of_time_holder.rb +23 -0
- data/lib/chrono_model/patches/as_of_time_relation.rb +19 -0
- data/lib/chrono_model/patches/association.rb +52 -0
- data/lib/chrono_model/patches/db_console.rb +11 -0
- data/lib/chrono_model/patches/join_node.rb +32 -0
- data/lib/chrono_model/patches/preloader.rb +68 -0
- data/lib/chrono_model/patches/relation.rb +58 -0
- data/lib/chrono_model/time_gate.rb +5 -5
- data/lib/chrono_model/time_machine.rb +47 -427
- data/lib/chrono_model/time_machine/history_model.rb +196 -0
- data/lib/chrono_model/time_machine/time_query.rb +86 -0
- data/lib/chrono_model/time_machine/timeline.rb +94 -0
- data/lib/chrono_model/utilities.rb +27 -0
- data/lib/chrono_model/version.rb +1 -1
- data/spec/aruba/dbconsole_spec.rb +25 -0
- data/spec/chrono_model/adapter/counter_cache_race_spec.rb +46 -0
- data/spec/{adapter_spec.rb → chrono_model/adapter_spec.rb} +124 -5
- data/spec/{utils_spec.rb → chrono_model/conversions_spec.rb} +0 -0
- data/spec/{json_ops_spec.rb → chrono_model/json_ops_spec.rb} +11 -0
- data/spec/{time_machine_spec.rb → chrono_model/time_machine_spec.rb} +15 -5
- data/spec/{time_query_spec.rb → chrono_model/time_query_spec.rb} +0 -0
- data/spec/config.travis.yml +1 -0
- data/spec/config.yml.example +1 -0
- metadata +35 -14
- 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
|
data/lib/chrono_model/version.rb
CHANGED
@@ -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
|