chrono_model 1.2.2 → 2.0.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 +4 -4
- data/LICENSE +19 -20
- data/README.md +62 -40
- data/lib/active_record/connection_adapters/chronomodel_adapter.rb +17 -11
- data/lib/active_record/tasks/chronomodel_database_tasks.rb +64 -23
- data/lib/chrono_model/adapter/ddl.rb +168 -153
- data/lib/chrono_model/adapter/indexes.rb +99 -94
- data/lib/chrono_model/adapter/migrations.rb +81 -104
- data/lib/chrono_model/adapter/migrations_modules/legacy.rb +41 -0
- data/lib/chrono_model/adapter/migrations_modules/stable.rb +41 -0
- data/lib/chrono_model/adapter/tsrange.rb +20 -5
- data/lib/chrono_model/adapter/upgrade.rb +89 -91
- data/lib/chrono_model/adapter.rb +64 -31
- data/lib/chrono_model/chrono.rb +17 -0
- data/lib/chrono_model/conversions.rb +15 -9
- data/lib/chrono_model/db_console.rb +9 -0
- data/lib/chrono_model/json.rb +9 -6
- data/lib/chrono_model/patches/as_of_time_holder.rb +2 -2
- data/lib/chrono_model/patches/as_of_time_relation.rb +2 -2
- data/lib/chrono_model/patches/association.rb +15 -12
- data/lib/chrono_model/patches/batches.rb +17 -0
- data/lib/chrono_model/patches/db_console.rb +20 -4
- data/lib/chrono_model/patches/join_node.rb +4 -4
- data/lib/chrono_model/patches/preloader.rb +41 -11
- data/lib/chrono_model/patches/relation.rb +53 -8
- data/lib/chrono_model/patches.rb +3 -1
- data/lib/chrono_model/railtie.rb +29 -24
- data/lib/chrono_model/time_gate.rb +3 -3
- data/lib/chrono_model/time_machine/history_model.rb +65 -31
- data/lib/chrono_model/time_machine/time_query.rb +65 -49
- data/lib/chrono_model/time_machine/timeline.rb +52 -28
- data/lib/chrono_model/time_machine.rb +66 -25
- data/lib/chrono_model/utilities.rb +3 -3
- data/lib/chrono_model/version.rb +3 -1
- data/lib/chrono_model.rb +31 -36
- metadata +39 -136
- data/.gitignore +0 -21
- data/.rspec +0 -2
- data/.travis.yml +0 -41
- data/Gemfile +0 -4
- data/README.sql +0 -161
- data/Rakefile +0 -25
- data/chrono_model.gemspec +0 -33
- data/gemfiles/rails_5.0.gemfile +0 -6
- data/gemfiles/rails_5.1.gemfile +0 -6
- data/gemfiles/rails_5.2.gemfile +0 -6
- data/spec/aruba/dbconsole_spec.rb +0 -25
- data/spec/aruba/fixtures/database_with_default_username_and_password.yml +0 -14
- data/spec/aruba/fixtures/database_without_username_and_password.yml +0 -11
- data/spec/aruba/fixtures/empty_structure.sql +0 -27
- data/spec/aruba/fixtures/migrations/56/20160812190335_create_impressions.rb +0 -10
- data/spec/aruba/fixtures/migrations/56/20171115195229_add_temporal_extension_to_impressions.rb +0 -10
- data/spec/aruba/fixtures/railsapp/config/application.rb +0 -17
- data/spec/aruba/fixtures/railsapp/config/boot.rb +0 -5
- data/spec/aruba/fixtures/railsapp/config/environments/development.rb +0 -38
- data/spec/aruba/migrations_spec.rb +0 -48
- data/spec/aruba/rake_task_spec.rb +0 -71
- data/spec/chrono_model/adapter/base_spec.rb +0 -157
- data/spec/chrono_model/adapter/ddl_spec.rb +0 -243
- data/spec/chrono_model/adapter/indexes_spec.rb +0 -72
- data/spec/chrono_model/adapter/migrations_spec.rb +0 -312
- data/spec/chrono_model/conversions_spec.rb +0 -43
- data/spec/chrono_model/history_models_spec.rb +0 -32
- data/spec/chrono_model/json_ops_spec.rb +0 -59
- data/spec/chrono_model/time_machine/as_of_spec.rb +0 -188
- data/spec/chrono_model/time_machine/changes_spec.rb +0 -50
- data/spec/chrono_model/time_machine/counter_cache_race_spec.rb +0 -46
- data/spec/chrono_model/time_machine/default_scope_spec.rb +0 -37
- data/spec/chrono_model/time_machine/history_spec.rb +0 -104
- data/spec/chrono_model/time_machine/keep_cool_spec.rb +0 -27
- data/spec/chrono_model/time_machine/manipulations_spec.rb +0 -84
- data/spec/chrono_model/time_machine/model_identification_spec.rb +0 -46
- data/spec/chrono_model/time_machine/sequence_spec.rb +0 -74
- data/spec/chrono_model/time_machine/sti_spec.rb +0 -100
- data/spec/chrono_model/time_machine/time_query_spec.rb +0 -261
- data/spec/chrono_model/time_machine/timeline_spec.rb +0 -63
- data/spec/chrono_model/time_machine/timestamps_spec.rb +0 -43
- data/spec/chrono_model/time_machine/transactions_spec.rb +0 -69
- data/spec/config.travis.yml +0 -5
- data/spec/config.yml.example +0 -9
- data/spec/spec_helper.rb +0 -33
- data/spec/support/adapter/helpers.rb +0 -53
- data/spec/support/adapter/structure.rb +0 -44
- data/spec/support/aruba.rb +0 -44
- data/spec/support/connection.rb +0 -70
- data/spec/support/matchers/base.rb +0 -56
- data/spec/support/matchers/column.rb +0 -99
- data/spec/support/matchers/function.rb +0 -79
- data/spec/support/matchers/index.rb +0 -69
- data/spec/support/matchers/schema.rb +0 -39
- data/spec/support/matchers/table.rb +0 -275
- data/spec/support/time_machine/helpers.rb +0 -47
- data/spec/support/time_machine/structure.rb +0 -111
- data/sql/json_ops.sql +0 -56
- data/sql/uninstall-json_ops.sql +0 -24
data/lib/chrono_model/railtie.rb
CHANGED
|
@@ -1,45 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'active_record/tasks/chronomodel_database_tasks'
|
|
2
4
|
|
|
3
5
|
module ChronoModel
|
|
4
6
|
class Railtie < ::Rails::Railtie
|
|
7
|
+
TASKS_CLASS = ActiveRecord::Tasks::ChronomodelDatabaseTasks
|
|
8
|
+
|
|
9
|
+
# Register our database tasks under our adapter name
|
|
10
|
+
if Rails.version < '5.2'
|
|
11
|
+
ActiveRecord::Tasks::DatabaseTasks.register_task(/chronomodel/, TASKS_CLASS)
|
|
12
|
+
else
|
|
13
|
+
ActiveRecord::Tasks::DatabaseTasks.register_task(/chronomodel/, TASKS_CLASS.to_s)
|
|
14
|
+
end
|
|
5
15
|
|
|
6
16
|
rake_tasks do
|
|
7
|
-
|
|
8
|
-
|
|
17
|
+
def task_config
|
|
18
|
+
if Rails.version < '6.1'
|
|
19
|
+
ActiveRecord::Tasks::DatabaseTasks.current_config.with_indifferent_access
|
|
20
|
+
else
|
|
21
|
+
ActiveRecord::Base.connection_db_config
|
|
22
|
+
end
|
|
9
23
|
end
|
|
10
24
|
|
|
11
|
-
|
|
25
|
+
if Rails.version < '6.1'
|
|
26
|
+
# Make schema:dump and schema:load invoke structure:dump and structure:load
|
|
27
|
+
Rake::Task['db:schema:dump'].clear.enhance(['environment']) do
|
|
28
|
+
Rake::Task['db:structure:dump'].invoke
|
|
29
|
+
end
|
|
12
30
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# Make schema:dump and schema:load invoke structure:dump and structure:load
|
|
18
|
-
Rake::Task['db:schema:dump'].clear.enhance(['environment']) do
|
|
19
|
-
Rake::Task['db:structure:dump'].invoke
|
|
31
|
+
Rake::Task['db:schema:load'].clear.enhance(['environment']) do
|
|
32
|
+
Rake::Task['db:structure:load'].invoke
|
|
33
|
+
end
|
|
20
34
|
end
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
Rake::Task['db:structure:load'].invoke
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
desc "Dumps database into db/data.NOW.sql or file specified via DUMP="
|
|
36
|
+
desc 'Dumps database into db/data.NOW.sql or file specified via DUMP='
|
|
27
37
|
task 'db:data:dump' => :environment do
|
|
28
|
-
config = ActiveRecord::Tasks::DatabaseTasks.current_config
|
|
29
38
|
target = ENV['DUMP'] || Rails.root.join('db', "data.#{Time.now.to_f}.sql")
|
|
30
|
-
|
|
31
|
-
tasks_class.new(config).data_dump(target)
|
|
39
|
+
TASKS_CLASS.new(task_config).data_dump(target)
|
|
32
40
|
end
|
|
33
41
|
|
|
34
|
-
desc
|
|
42
|
+
desc 'Loads database dump from file specified via DUMP='
|
|
35
43
|
task 'db:data:load' => :environment do
|
|
36
|
-
config = ActiveRecord::Tasks::DatabaseTasks.current_config
|
|
37
44
|
source = ENV['DUMP'].presence or
|
|
38
|
-
raise ArgumentError,
|
|
39
|
-
|
|
40
|
-
tasks_class.new(config).data_load(source)
|
|
45
|
+
raise ArgumentError, 'Invoke as rake db:data:load DUMP=/path/to/data.sql'
|
|
46
|
+
TASKS_CLASS.new(task_config).data_load(source)
|
|
41
47
|
end
|
|
42
48
|
end
|
|
43
|
-
|
|
44
49
|
end
|
|
45
50
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
module ChronoModel
|
|
3
4
|
# Provides the TimeMachine API to non-temporal models that associate
|
|
4
5
|
# temporal ones.
|
|
5
6
|
#
|
|
@@ -17,12 +18,11 @@ module ChronoModel
|
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def as_of(time)
|
|
20
|
-
self.class.as_of(time).where(id:
|
|
21
|
+
self.class.as_of(time).where(id: id).first!
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def timeline
|
|
24
25
|
self.class.timeline(self)
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
|
-
|
|
28
28
|
end
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module ChronoModel
|
|
2
4
|
module TimeMachine
|
|
3
|
-
|
|
4
5
|
module HistoryModel
|
|
5
6
|
extend ActiveSupport::Concern
|
|
6
7
|
|
|
7
8
|
included do
|
|
8
|
-
self.table_name =
|
|
9
|
+
self.table_name = "#{Adapter::HISTORY_SCHEMA}.#{superclass.table_name}"
|
|
9
10
|
|
|
10
11
|
scope :chronological, -> { order(Arel.sql('lower(validity) ASC')) }
|
|
11
12
|
end
|
|
12
13
|
|
|
14
|
+
# ACTIVE RECORD 7 does not call `class.find` but a new internal method called `_find_record`
|
|
15
|
+
def _find_record(options)
|
|
16
|
+
if options && options[:lock]
|
|
17
|
+
self.class.preload(strict_loaded_associations).lock(options[:lock]).find_by!(hid: hid)
|
|
18
|
+
else
|
|
19
|
+
self.class.preload(strict_loaded_associations).find_by!(hid: hid)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
13
23
|
# Methods that make up the history interface of the companion History
|
|
14
24
|
# model, automatically built for each Model that includes TimeMachine
|
|
15
25
|
#
|
|
@@ -21,11 +31,11 @@ module ChronoModel
|
|
|
21
31
|
# setting it now and ensuring to reset it to the original one after
|
|
22
32
|
# execution completes. FIXME
|
|
23
33
|
#
|
|
24
|
-
def with_hid_pkey
|
|
25
|
-
old =
|
|
34
|
+
def with_hid_pkey
|
|
35
|
+
old = primary_key
|
|
26
36
|
self.primary_key = :hid
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
yield
|
|
29
39
|
ensure
|
|
30
40
|
self.primary_key = old
|
|
31
41
|
end
|
|
@@ -61,9 +71,12 @@ module ChronoModel
|
|
|
61
71
|
end
|
|
62
72
|
|
|
63
73
|
def virtual_table_at(time, table_name: nil)
|
|
64
|
-
virtual_name =
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
virtual_name =
|
|
75
|
+
if table_name
|
|
76
|
+
connection.quote_table_name(table_name)
|
|
77
|
+
else
|
|
78
|
+
superclass.quoted_table_name
|
|
79
|
+
end
|
|
67
80
|
|
|
68
81
|
"(#{at(time).to_sql}) #{virtual_name}"
|
|
69
82
|
end
|
|
@@ -77,7 +90,7 @@ module ChronoModel
|
|
|
77
90
|
# Returns the history sorted by recorded_at
|
|
78
91
|
#
|
|
79
92
|
def sorted
|
|
80
|
-
all.order(Arel.sql(%
|
|
93
|
+
all.order(Arel.sql(%( #{quoted_table_name}."recorded_at" ASC, #{quoted_table_name}."hid" ASC )))
|
|
81
94
|
end
|
|
82
95
|
|
|
83
96
|
# Fetches the given +object+ history, sorted by history record time
|
|
@@ -96,9 +109,7 @@ module ChronoModel
|
|
|
96
109
|
#
|
|
97
110
|
# As such it is overriden here to return the same contents that
|
|
98
111
|
# the parent would have returned.
|
|
99
|
-
|
|
100
|
-
superclass.sti_name
|
|
101
|
-
end
|
|
112
|
+
delegate :sti_name, to: :superclass
|
|
102
113
|
|
|
103
114
|
# For STI to work, the history model needs to have the exact same
|
|
104
115
|
# semantics as the model it inherits from. However given it is
|
|
@@ -107,18 +118,17 @@ module ChronoModel
|
|
|
107
118
|
# same exact hierarchy location as its parent, thus this is defined in
|
|
108
119
|
# this override.
|
|
109
120
|
#
|
|
110
|
-
|
|
111
|
-
superclass.descends_from_active_record?
|
|
112
|
-
end
|
|
121
|
+
delegate :descends_from_active_record?, to: :superclass
|
|
113
122
|
|
|
114
123
|
private
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
|
|
125
|
+
# STI fails when a Foo::History record has Foo as type in the
|
|
126
|
+
# inheritance column; AR expects the type to be an instance of the
|
|
127
|
+
# current class or a descendant (or self).
|
|
128
|
+
#
|
|
129
|
+
def find_sti_class(type_name)
|
|
130
|
+
super("#{type_name}::History")
|
|
131
|
+
end
|
|
122
132
|
end
|
|
123
133
|
|
|
124
134
|
# The history id is `hid`, but this cannot set as primary key
|
|
@@ -134,15 +144,15 @@ module ChronoModel
|
|
|
134
144
|
end
|
|
135
145
|
|
|
136
146
|
def save(*)
|
|
137
|
-
|
|
147
|
+
with_hid_pkey { super }
|
|
138
148
|
end
|
|
139
149
|
|
|
140
150
|
def save!(*)
|
|
141
|
-
|
|
151
|
+
with_hid_pkey { super }
|
|
142
152
|
end
|
|
143
153
|
|
|
144
154
|
def update_columns(*)
|
|
145
|
-
|
|
155
|
+
with_hid_pkey { super }
|
|
146
156
|
end
|
|
147
157
|
|
|
148
158
|
def historical?
|
|
@@ -153,7 +163,7 @@ module ChronoModel
|
|
|
153
163
|
# is the first one.
|
|
154
164
|
#
|
|
155
165
|
def pred
|
|
156
|
-
return if
|
|
166
|
+
return if valid_from.nil?
|
|
157
167
|
|
|
158
168
|
if self.class.timeline_associations.empty?
|
|
159
169
|
self.class.where('id = ? AND upper(validity) = ?', rid, valid_from).first
|
|
@@ -166,7 +176,7 @@ module ChronoModel
|
|
|
166
176
|
# last one.
|
|
167
177
|
#
|
|
168
178
|
def succ
|
|
169
|
-
return if
|
|
179
|
+
return if valid_to.nil?
|
|
170
180
|
|
|
171
181
|
if self.class.timeline_associations.empty?
|
|
172
182
|
self.class.where('id = ? AND lower(validity) = ?', rid, valid_to).first
|
|
@@ -174,7 +184,7 @@ module ChronoModel
|
|
|
174
184
|
super(id: rid, after: valid_to, table: self.class.superclass.quoted_table_name)
|
|
175
185
|
end
|
|
176
186
|
end
|
|
177
|
-
alias
|
|
187
|
+
alias next succ
|
|
178
188
|
|
|
179
189
|
# Returns the first history entry
|
|
180
190
|
#
|
|
@@ -194,9 +204,9 @@ module ChronoModel
|
|
|
194
204
|
self.class.superclass.find(rid)
|
|
195
205
|
end
|
|
196
206
|
|
|
197
|
-
def record
|
|
207
|
+
def record # :nodoc:
|
|
198
208
|
ActiveSupport::Deprecation.warn '.record is deprecated in favour of .current_version'
|
|
199
|
-
|
|
209
|
+
current_version
|
|
200
210
|
end
|
|
201
211
|
|
|
202
212
|
def valid_from
|
|
@@ -211,7 +221,31 @@ module ChronoModel
|
|
|
211
221
|
def recorded_at
|
|
212
222
|
ChronoModel::Conversions.string_to_utc_time attributes_before_type_cast['recorded_at']
|
|
213
223
|
end
|
|
214
|
-
end
|
|
215
224
|
|
|
225
|
+
# Starting from Rails 6.0, `.read_attribute` will use the memoized
|
|
226
|
+
# `primary_key` if it detects that the attribute name is `id`.
|
|
227
|
+
#
|
|
228
|
+
# Since the `primary key` may have been changed to `hid` because of
|
|
229
|
+
# `.find` overload, the new behavior may break relations where `id` is
|
|
230
|
+
# still the correct attribute to read
|
|
231
|
+
#
|
|
232
|
+
# Ref: ifad/chronomodel#181
|
|
233
|
+
def read_attribute(attr_name, &block)
|
|
234
|
+
return super unless attr_name.to_s == 'id' && @primary_key.to_s == 'hid'
|
|
235
|
+
|
|
236
|
+
_read_attribute('id', &block)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def with_hid_pkey(&block)
|
|
242
|
+
old_primary_key = @primary_key
|
|
243
|
+
@primary_key = :hid
|
|
244
|
+
|
|
245
|
+
self.class.with_hid_pkey(&block)
|
|
246
|
+
ensure
|
|
247
|
+
@primary_key = old_primary_key
|
|
248
|
+
end
|
|
249
|
+
end
|
|
216
250
|
end
|
|
217
251
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module ChronoModel
|
|
2
4
|
module TimeMachine
|
|
3
|
-
|
|
4
5
|
#
|
|
5
6
|
# TODO Documentation
|
|
6
7
|
#
|
|
@@ -12,75 +13,90 @@ module ChronoModel
|
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
private
|
|
15
|
-
def time_query_sql(match, time, range, options)
|
|
16
|
-
case match
|
|
17
|
-
when :at
|
|
18
|
-
build_time_query_at(time, range)
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
def time_query_sql(match, time, range, options)
|
|
18
|
+
case match
|
|
19
|
+
when :at
|
|
20
|
+
build_time_query_at(time, range)
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
build_time_query(['NULL', time_for_time_query(time, range)], range, op)
|
|
22
|
+
when :not
|
|
23
|
+
"NOT (#{build_time_query_at(time, range)})"
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
when :before
|
|
26
|
+
op =
|
|
27
|
+
if options.fetch(:inclusive, true)
|
|
28
|
+
'&&'
|
|
29
|
+
else
|
|
30
|
+
'@>'
|
|
31
|
+
end
|
|
32
|
+
build_time_query(['NULL', time_for_time_query(time, range)], range, op)
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
when :after
|
|
35
|
+
op =
|
|
36
|
+
if options.fetch(:inclusive, true)
|
|
37
|
+
'&&'
|
|
38
|
+
else
|
|
39
|
+
'@>'
|
|
40
|
+
end
|
|
41
|
+
build_time_query([time_for_time_query(time, range), 'NULL'], range, op)
|
|
42
|
+
|
|
43
|
+
else
|
|
44
|
+
raise ChronoModel::Error, "Invalid time_query: #{match}"
|
|
34
45
|
end
|
|
46
|
+
end
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
48
|
+
def time_for_time_query(t, column)
|
|
49
|
+
if t == :now || t == :today
|
|
50
|
+
now_for_column(column)
|
|
51
|
+
else
|
|
52
|
+
quoted_t = connection.quote(connection.quoted_date(t))
|
|
53
|
+
"#{quoted_t}::#{primitive_type_for_column(column)}"
|
|
43
54
|
end
|
|
55
|
+
end
|
|
44
56
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
end
|
|
57
|
+
def now_for_column(column)
|
|
58
|
+
case column.type
|
|
59
|
+
when :tsrange, :tstzrange then "timezone('UTC', current_timestamp)"
|
|
60
|
+
when :daterange then 'current_date'
|
|
61
|
+
else raise "Cannot generate 'now()' for #{column.type} column #{column.name}"
|
|
51
62
|
end
|
|
63
|
+
end
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
end
|
|
65
|
+
def primitive_type_for_column(column)
|
|
66
|
+
case column.type
|
|
67
|
+
when :tsrange then :timestamp
|
|
68
|
+
when :tstzrange then :timestamptz
|
|
69
|
+
when :daterange then :date
|
|
70
|
+
else raise "Don't know how to map #{column.type} column #{column.name} to a primitive type"
|
|
60
71
|
end
|
|
72
|
+
end
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
def build_time_query_at(time, range)
|
|
75
|
+
time =
|
|
76
|
+
if time.is_a?(Array)
|
|
77
|
+
time.map! { |t| time_for_time_query(t, range) }
|
|
65
78
|
|
|
66
79
|
# If both edges of the range are the same the query fails using the '&&' operator.
|
|
67
80
|
# The correct solution is to use the <@ operator.
|
|
68
|
-
time.first == time.last
|
|
81
|
+
if time.first == time.last
|
|
82
|
+
time.first
|
|
83
|
+
else
|
|
84
|
+
time
|
|
85
|
+
end
|
|
69
86
|
else
|
|
70
87
|
time_for_time_query(time, range)
|
|
71
88
|
end
|
|
72
89
|
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
build_time_query(time, range)
|
|
91
|
+
end
|
|
75
92
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
end
|
|
93
|
+
def build_time_query(time, range, op = '&&')
|
|
94
|
+
if time.is_a?(Array)
|
|
95
|
+
Arel.sql %[ #{range.type}(#{time.first}, #{time.last}) #{op} #{table_name}.#{range.name} ]
|
|
96
|
+
else
|
|
97
|
+
Arel.sql %( #{time} <@ #{table_name}.#{range.name} )
|
|
82
98
|
end
|
|
99
|
+
end
|
|
83
100
|
end
|
|
84
|
-
|
|
85
101
|
end
|
|
86
102
|
end
|
|
@@ -1,59 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module ChronoModel
|
|
2
4
|
module TimeMachine
|
|
3
|
-
|
|
4
5
|
module Timeline
|
|
5
6
|
# Returns an Array of unique UTC timestamps for which at least an
|
|
6
7
|
# history record exists. Takes temporal associations into account.
|
|
7
8
|
#
|
|
8
9
|
def timeline(record = nil, options = {})
|
|
9
|
-
rid =
|
|
10
|
+
rid =
|
|
11
|
+
if record
|
|
12
|
+
if record.respond_to?(:rid)
|
|
13
|
+
record.rid
|
|
14
|
+
else
|
|
15
|
+
record.id
|
|
16
|
+
end
|
|
17
|
+
end
|
|
10
18
|
|
|
11
|
-
assocs =
|
|
12
|
-
|
|
19
|
+
assocs =
|
|
20
|
+
if options.key?(:with)
|
|
21
|
+
timeline_associations_from(options[:with])
|
|
22
|
+
else
|
|
23
|
+
timeline_associations
|
|
24
|
+
end
|
|
13
25
|
|
|
14
26
|
models = []
|
|
15
|
-
models.push self if
|
|
16
|
-
models.concat(assocs.map {|a| a.klass.history})
|
|
27
|
+
models.push self if chrono?
|
|
28
|
+
models.concat(assocs.map { |a| a.klass.history })
|
|
17
29
|
|
|
18
30
|
return [] if models.empty?
|
|
19
31
|
|
|
20
|
-
fields = models.inject([]) {|a,m| a.concat m.quoted_history_fields}
|
|
32
|
+
fields = models.inject([]) { |a, m| a.concat m.quoted_history_fields }
|
|
21
33
|
|
|
22
|
-
relation =
|
|
23
|
-
|
|
34
|
+
relation = except(:order)
|
|
35
|
+
.select("DISTINCT UNNEST(ARRAY[#{fields.join(',')}]) AS ts")
|
|
24
36
|
|
|
25
37
|
if assocs.present?
|
|
26
|
-
|
|
38
|
+
assocs.each do |ass|
|
|
39
|
+
association_quoted_table_name = connection.quote_table_name(ass.table_name)
|
|
40
|
+
# `join` first, then use `where`s
|
|
41
|
+
relation =
|
|
42
|
+
if ass.belongs_to?
|
|
43
|
+
relation.joins("LEFT JOIN #{association_quoted_table_name} ON #{association_quoted_table_name}.#{ass.association_primary_key} = #{quoted_table_name}.#{ass.association_foreign_key}")
|
|
44
|
+
else
|
|
45
|
+
relation.joins("LEFT JOIN #{association_quoted_table_name} ON #{association_quoted_table_name}.#{ass.foreign_key} = #{quoted_table_name}.#{primary_key}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
27
48
|
end
|
|
28
49
|
|
|
29
|
-
|
|
30
|
-
|
|
50
|
+
relation_order =
|
|
51
|
+
if options[:reverse]
|
|
52
|
+
'DESC'
|
|
53
|
+
else
|
|
54
|
+
'ASC'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
relation = relation.order("ts #{relation_order}")
|
|
31
58
|
|
|
32
|
-
relation = relation.from(
|
|
59
|
+
relation = relation.from("public.#{quoted_table_name}") unless chrono?
|
|
33
60
|
relation = relation.where(id: rid) if rid
|
|
34
61
|
|
|
35
|
-
sql = "SELECT ts FROM ( #{relation.to_sql} ) foo WHERE ts IS NOT NULL"
|
|
62
|
+
sql = "SELECT ts FROM ( #{relation.to_sql} ) AS foo WHERE ts IS NOT NULL".dup
|
|
36
63
|
|
|
37
64
|
if options.key?(:before)
|
|
38
65
|
sql << " AND ts < '#{Conversions.time_to_utc_string(options[:before])}'"
|
|
39
66
|
end
|
|
40
67
|
|
|
41
68
|
if options.key?(:after)
|
|
42
|
-
sql << " AND ts > '#{Conversions.time_to_utc_string(options[:after
|
|
69
|
+
sql << " AND ts > '#{Conversions.time_to_utc_string(options[:after])}'"
|
|
43
70
|
end
|
|
44
71
|
|
|
45
72
|
if rid && !options[:with]
|
|
46
|
-
sql <<
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
sql <<
|
|
74
|
+
if chrono?
|
|
75
|
+
%{ AND ts <@ ( SELECT tsrange(min(lower(validity)), max(upper(validity)), '[]') FROM #{quoted_table_name} WHERE id = #{rid} ) }
|
|
76
|
+
else
|
|
77
|
+
%[ AND ts < NOW() ]
|
|
78
|
+
end
|
|
49
79
|
end
|
|
50
80
|
|
|
51
81
|
sql << " LIMIT #{options[:limit].to_i}" if options.key?(:limit)
|
|
52
82
|
|
|
53
|
-
sql.gsub! 'INNER JOIN', 'LEFT OUTER JOIN'
|
|
54
|
-
|
|
55
83
|
connection.on_schema(Adapter::HISTORY_SCHEMA) do
|
|
56
|
-
connection.select_values(sql, "#{
|
|
84
|
+
connection.select_values(sql, "#{name} periods").map! do |ts|
|
|
57
85
|
Conversions.string_to_utc_time ts
|
|
58
86
|
end
|
|
59
87
|
end
|
|
@@ -74,21 +102,17 @@ module ChronoModel
|
|
|
74
102
|
def timeline_associations_from(names)
|
|
75
103
|
Array.wrap(names).map do |name|
|
|
76
104
|
reflect_on_association(name) or raise ArgumentError,
|
|
77
|
-
|
|
105
|
+
"No association found for name `#{name}'"
|
|
78
106
|
end
|
|
79
107
|
end
|
|
80
108
|
|
|
81
109
|
def quoted_history_fields
|
|
82
110
|
@quoted_history_fields ||= begin
|
|
83
|
-
validity =
|
|
84
|
-
[connection.quote_table_name(table_name),
|
|
85
|
-
connection.quote_column_name('validity')
|
|
86
|
-
].join('.')
|
|
111
|
+
validity = "#{quoted_table_name}.#{connection.quote_column_name('validity')}"
|
|
87
112
|
|
|
88
|
-
[
|
|
113
|
+
%w[lower upper].map! { |func| "#{func}(#{validity})" }
|
|
89
114
|
end
|
|
90
115
|
end
|
|
91
116
|
end
|
|
92
|
-
|
|
93
117
|
end
|
|
94
118
|
end
|