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