chrono_model 0.4.0 → 0.5.0.beta
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.
- data/Gemfile.lock +1 -1
- data/README.md +64 -35
- data/README.sql +73 -27
- data/lib/chrono_model/adapter.rb +235 -44
- data/lib/chrono_model/patches.rb +28 -75
- data/lib/chrono_model/railtie.rb +0 -25
- data/lib/chrono_model/time_gate.rb +36 -0
- data/lib/chrono_model/time_machine.rb +345 -122
- data/lib/chrono_model/utils.rb +89 -0
- data/lib/chrono_model/version.rb +1 -1
- data/lib/chrono_model.rb +2 -7
- data/spec/adapter_spec.rb +74 -7
- data/spec/support/connection.rb +1 -1
- data/spec/support/helpers.rb +20 -1
- data/spec/time_machine_spec.rb +216 -12
- metadata +11 -9
data/lib/chrono_model/patches.rb
CHANGED
@@ -13,92 +13,45 @@ module ChronoModel
|
|
13
13
|
#
|
14
14
|
class Association < ActiveRecord::Associations::Association
|
15
15
|
|
16
|
-
#
|
17
|
-
#
|
18
|
-
#
|
16
|
+
# If the association class or the through association are ChronoModels,
|
17
|
+
# then fetches the records from a virtual table using a subquery scoped
|
18
|
+
# to a specific timestamp.
|
19
19
|
def scoped
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
20
|
+
scoped = super
|
21
|
+
return scoped unless _chrono_record?
|
22
|
+
|
23
|
+
klass = reflection.options[:polymorphic] ?
|
24
|
+
owner.public_send(reflection.foreign_type).constantize :
|
25
|
+
reflection.klass
|
26
|
+
|
27
|
+
if klass.chrono?
|
28
|
+
# For standard associations, replace the table name with the virtual
|
29
|
+
# as-of table name at the owner's as-of-time
|
30
|
+
#
|
31
|
+
scoped = scoped.readonly.from(klass.history.virtual_table_at(owner.as_of_time))
|
32
|
+
elsif respond_to?(:through_reflection) && through_reflection.klass.chrono?
|
33
|
+
|
34
|
+
# For through associations, replace the joined table name instead.
|
35
|
+
#
|
36
|
+
scoped.join_sources.each do |join|
|
37
|
+
if join.left.name == through_reflection.klass.table_name
|
38
|
+
v_table = through_reflection.klass.history.virtual_table_at(
|
39
|
+
owner.as_of_time, join.left.table_alias || join.left.table_name)
|
40
|
+
|
41
|
+
join.left = Arel::Nodes::SqlLiteral.new(v_table)
|
42
|
+
end
|
43
|
+
end
|
27
44
|
|
28
|
-
if respond_to?(:through_reflection) && through_reflection.klass.chrono?
|
29
|
-
ctes.update _chrono_ctes_for(through_reflection.klass)
|
30
45
|
end
|
31
46
|
|
32
|
-
scoped
|
33
|
-
ctes.each {|table, cte| scoped = scoped.with(table, cte) }
|
34
|
-
return scoped.readonly
|
47
|
+
return scoped
|
35
48
|
end
|
36
49
|
|
37
50
|
private
|
38
|
-
def _chrono_ctes_for(klass)
|
39
|
-
klass.as_of(owner.as_of_time).with_values
|
40
|
-
end
|
41
|
-
|
42
51
|
def _chrono_record?
|
43
52
|
owner.respond_to?(:as_of_time) && owner.as_of_time.present?
|
44
53
|
end
|
45
54
|
end
|
46
55
|
|
47
|
-
# Adds the WITH queries (Common Table Expressions) support to
|
48
|
-
# ActiveRecord::Relation.
|
49
|
-
#
|
50
|
-
# \name is the CTE you want
|
51
|
-
# \value can be a plain SQL query or another AR::Relation
|
52
|
-
#
|
53
|
-
# Example:
|
54
|
-
#
|
55
|
-
# Post.with('posts',
|
56
|
-
# Post.from('history.posts').
|
57
|
-
# where('? BETWEEN valid_from AND valid_to', 1.month.ago)
|
58
|
-
# ).where(:author_id => 1)
|
59
|
-
#
|
60
|
-
# yields:
|
61
|
-
#
|
62
|
-
# WITH posts AS (
|
63
|
-
# SELECT * FROM history.posts WHERE ... BETWEEN valid_from AND valid_to
|
64
|
-
# ) SELECT * FROM posts
|
65
|
-
#
|
66
|
-
# PG Documentation:
|
67
|
-
# http://www.postgresql.org/docs/9.0/static/queries-with.html
|
68
|
-
#
|
69
|
-
module QueryMethods
|
70
|
-
attr_accessor :with_values
|
71
|
-
|
72
|
-
def with(name, value)
|
73
|
-
clone.tap do |relation|
|
74
|
-
relation.with_values ||= {}
|
75
|
-
value = value.to_sql if value.respond_to? :to_sql
|
76
|
-
relation.with_values[name] = value
|
77
|
-
end
|
78
|
-
end
|
79
|
-
|
80
|
-
def build_arel
|
81
|
-
super.tap {|arel| arel.with with_values if with_values.present? }
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
module Querying
|
86
|
-
delegate :with, :to => :scoped
|
87
|
-
end
|
88
|
-
|
89
|
-
# Fixes ARel's WITH visitor method with the correct SQL syntax
|
90
|
-
#
|
91
|
-
# FIXME: the .children.first is messy. This should be properly
|
92
|
-
# fixed in ARel.
|
93
|
-
#
|
94
|
-
class Visitor < Arel::Visitors::PostgreSQL
|
95
|
-
def visit_Arel_Nodes_With o
|
96
|
-
values = o.children.first.map do |name, value|
|
97
|
-
[name, ' AS (', value.is_a?(String) ? value : visit(value), ')'].join
|
98
|
-
end
|
99
|
-
"WITH #{values.join ', '}"
|
100
|
-
end
|
101
|
-
end
|
102
|
-
|
103
56
|
end
|
104
57
|
end
|
data/lib/chrono_model/railtie.rb
CHANGED
@@ -17,30 +17,5 @@ module ChronoModel
|
|
17
17
|
|
18
18
|
task 'db:schema:load' => 'db:chrono:create_schemas'
|
19
19
|
end
|
20
|
-
|
21
|
-
class SchemaDumper < ::ActiveRecord::SchemaDumper
|
22
|
-
def tables(*)
|
23
|
-
super
|
24
|
-
@connection.send(:_on_temporal_schema) { super }
|
25
|
-
end
|
26
|
-
|
27
|
-
def indexes(table, stream)
|
28
|
-
super
|
29
|
-
if @connection.is_chrono?(table)
|
30
|
-
stream.rewind
|
31
|
-
t = stream.read.sub(':force => true', '\&, :temporal => true') # HACK
|
32
|
-
stream.seek(0)
|
33
|
-
stream.truncate(0)
|
34
|
-
stream.write(t)
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
end
|
39
|
-
|
40
|
-
# I'm getting (too) used to this (dirty) override scheme.
|
41
|
-
#
|
42
|
-
silence_warnings do
|
43
|
-
::ActiveRecord::SchemaDumper = SchemaDumper
|
44
|
-
end
|
45
20
|
end
|
46
21
|
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
|
3
|
+
# Provides the TimeMachine API to non-temporal models that associate
|
4
|
+
# temporal ones.
|
5
|
+
#
|
6
|
+
module TimeGate
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
def as_of(time)
|
11
|
+
time = Conversions.time_to_utc_string(time.utc) if time.kind_of? Time
|
12
|
+
|
13
|
+
virtual_table = select(%[
|
14
|
+
#{quoted_table_name}.*, #{connection.quote(time)} AS "as_of_time"]
|
15
|
+
).to_sql
|
16
|
+
|
17
|
+
as_of = scoped.from("(#{virtual_table}) #{quoted_table_name}")
|
18
|
+
|
19
|
+
as_of.instance_variable_set(:@temporal, time)
|
20
|
+
|
21
|
+
return as_of
|
22
|
+
end
|
23
|
+
|
24
|
+
include TimeMachine::HistoryMethods::Timeline
|
25
|
+
end
|
26
|
+
|
27
|
+
def as_of(time)
|
28
|
+
self.class.as_of(time).where(:id => self.id).first!
|
29
|
+
end
|
30
|
+
|
31
|
+
def timeline
|
32
|
+
self.class.timeline(self)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|