Skip to content

Commit 18f4c4b

Browse files
committed
Ditch CTEs in favour of sub-selects
1 parent 39ab32a commit 18f4c4b

File tree

5 files changed

+83
-123
lines changed

5 files changed

+83
-123
lines changed

README.md

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,16 @@ structure by e.g. keeping the view rules in sync or dropping/recreating it when
2828
migrations. A schema dumper is available as well.
2929

3030
Data extraction at a single point in time and even `JOIN`s between temporal and non-temporal data
31-
is implemented using
32-
[Common Table Expressions](http://www.postgresql.org/docs/9.0/static/queries-with.html)
33-
(WITH queries) and a `WHERE date >= valid_from AND date < valid_to` clause, generated automatically
34-
by the provided `TimeMachine` module to be included in your models.
31+
is implemented using sub-selects and a `WHERE` generated by the provided `TimeMachine` module to
32+
be included in your models.
3533

36-
Optimal temporal timestamps indexing is provided using GiST spatial search.
34+
The `WHERE` is optimized using a spatial GiST index in which time is represented represented by
35+
boxes and the filtering is done using the overlapping (`&&`) geometry operator.
3736

3837
All timestamps are (forcibly) stored in the UTC time zone, bypassing the `AR::Base.config.default_timezone`
3938
setting.
4039

41-
See [README.sql](https://github.com/ifad/chronomodel/blob/master/README.sql) for the plain SQL
40+
See [README.sql](https://github.com/ifad/chronomodel/blob/master/README.sql) for the plain SQL
4241
defining this temporal schema for a single table.
4342

4443

@@ -121,35 +120,50 @@ will make an `as_of` class method available to your model. E.g.:
121120

122121
Will execute:
123122

124-
WITH countries AS (
125-
SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
126-
) SELECT * FROM countries
123+
SELECT "countries".* FROM (
124+
SELECT "history"."countries".* FROM "history"."countries"
125+
WHERE box(
126+
point( date_part( 'epoch', '#{1.year.ago.utc}'::timestamp ), 0 ),
127+
point( date_part( 'epoch', '#{1.year.ago.utc}'::timestamp ), 0 )
128+
) &&
129+
box(
130+
point( date_part( 'epoch', "history"."addresses"."valid_from" ), 0 ),
131+
point( date_part( 'epoch', "history"."addresses"."valid_to" ), 0 ),
132+
)
133+
) AS "countries"
127134

128135
This work on associations using temporal extensions as well:
129136

130137
Country.as_of(1.year.ago).first.compositions
131138

132139
Will execute:
133140

134-
WITH countries AS (
135-
SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
136-
) SELECT * FROM countries LIMIT 1
141+
# ... countries history query ...
142+
LIMIT 1
143+
144+
SELECT * FROM (
145+
SELECT "history"."compositions".* FROM "history"."compositions"
146+
WHERE box(
147+
point( date_part( 'epoch', '#{above_timestamp}'::timestamp ), 0 ),
148+
point( date_part( 'epoch', '#{above_timestamp}'::timestamp ), 0 )
149+
) &&
150+
box(
151+
point( date_part( 'epoch', "history"."addresses"."valid_from" ), 0 ),
152+
point( date_part( 'epoch', "history"."addresses"."valid_to" ), 0 ),
153+
)
154+
) AS "compositions" WHERE country_id = X
137155

138-
WITH compositions AS (
139-
SELECT * FROM history.countries WHERE #{above_timestamp} >= valid_from AND #{above_timestamp} < valid_to
140-
) SELECT * FROM compositions WHERE country_id = X
141-
142156
And `.joins` works as well:
143157

144158
Country.as_of(1.month.ago).joins(:compositions)
145-
159+
146160
Will execute:
147161

148-
WITH countries AS (
149-
SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
150-
), compositions AS (
151-
SELECT * FROM history.countries WHERE #{above_timestamp} >= valid_from AND #{above_timestamp} < valid_to
152-
) SELECT * FROM countries INNER JOIN countries ON compositions.country_id = countries.id
162+
SELECT "countries".* FROM (
163+
# .. countries history query ..
164+
) AS "countries" INNER JOIN (
165+
# .. compositions history query ..
166+
) AS "compositions" ON compositions.country_id = countries.id
153167

154168
More methods are provided, see the
155169
[TimeMachine](https://github.com/ifad/chronomodel/blob/master/lib/chrono_model/time_machine.rb) source
@@ -168,23 +182,30 @@ them in your output, use `rake VERBOSE=true`.
168182

169183
* `.includes` still doesn't work, but it'll fixed soon.
170184

171-
* Some monkeypatching has been necessary both to `ActiveRecord::Relation` and
172-
to `Arel::Visitors::ToSql` to fix a bug with `WITH` queries generation. This
173-
will be reported to the upstream with a pull request after extensive testing.
185+
* The history queries are very verbose, they should be factored out in a
186+
per-table stored procedure.
174187

175188
* The migration statements extension is implemented using a Man-in-the-middle
176189
class that inherits from the PostgreSQL adapter, and that relies on some
177-
private APIs. This should be made more maintainable, maybe by implementing
178-
an extension framework for connection adapters. This library will (clearly)
179-
never be merged into Rails, as it is against its principle of treating the
180-
SQL database as a dummy data store.
190+
private APIs. This should be made more maintainable, maybe by requiring
191+
the use of `adapter: chronomodel` or `adapter: chrono_postgresql`.
181192

182193
* The schema dumper is WAY TOO hacky.
183194

184195
* Savepoints are disabled, because there is
185196
[currently](http://archives.postgresql.org/pgsql-hackers/2012-08/msg01094.php)
186197
no way to identify a subtransaction belonging to the current transaction.
187198

199+
* The choice of using subqueries instead of [Common Table Expressions](http://www.postgresql.org/docs/9.0/static/queries-with.html)
200+
was dictated by the fact that CTEs [currently acts as an optimization
201+
fence](http://archives.postgresql.org/pgsql-hackers/2012-09/msg00700.php).
202+
If it will be possible [to opt-out of the
203+
fence](http://archives.postgresql.org/pgsql-hackers/2012-10/msg00024.php)
204+
in the future, they will be probably be used again as they were [in the
205+
past](https://github.com/ifad/chronomodel/commit/39ab32a), because the
206+
resulting queries are much more readable, and do not inhibit using .from()
207+
from ARel.
208+
188209

189210
## Contributing
190211

lib/chrono_model.rb

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,4 @@ class Error < ActiveRecord::ActiveRecordError #:nodoc:
2626
# We need to override the "scoped" method on AR::Association for temporal
2727
# associations to work as well
2828
ActiveRecord::Associations::Association = ChronoModel::Patches::Association
29-
30-
# This implements correct WITH syntax on PostgreSQL
31-
Arel::Visitors::PostgreSQL = ChronoModel::Patches::Visitor
32-
33-
# This adds .with support to ActiveRecord::Relation
34-
ActiveRecord::Relation.instance_eval { include ChronoModel::Patches::QueryMethods }
35-
ActiveRecord::Base.extend ChronoModel::Patches::Querying
3629
end

lib/chrono_model/patches.rb

Lines changed: 10 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -13,96 +13,31 @@ module Patches
1313
#
1414
class Association < ActiveRecord::Associations::Association
1515

16-
# Add temporal Common Table Expressions (WITH queries) to the resulting
17-
# scope, checking whether either the association class or the through
18-
# association one are ChronoModels.
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.
1919
def scoped
20-
return super unless _chrono_record?
21-
22-
ctes = {}
20+
scoped = super
21+
return scoped unless _chrono_record?
2322

2423
klass = reflection.options[:polymorphic] ?
2524
owner.public_send(reflection.foreign_type).constantize :
2625
reflection.klass
2726

28-
if klass.chrono?
29-
ctes.update _chrono_ctes_for(klass)
30-
end
31-
32-
if respond_to?(:through_reflection) && through_reflection.klass.chrono?
33-
ctes.update _chrono_ctes_for(through_reflection.klass)
27+
history = if klass.chrono?
28+
klass.history
29+
elsif respond_to?(:through_reflection) && through_reflection.klass.chrono?
30+
through_reflection.klass.history
3431
end
3532

36-
scoped = super
37-
ctes.each {|table, cte| scoped = scoped.with(table, cte) }
38-
return scoped.readonly
33+
return scoped.readonly.from(history.virtual_table_at(owner.as_of_time))
3934
end
4035

4136
private
42-
def _chrono_ctes_for(klass)
43-
klass.as_of(owner.as_of_time).with_values
44-
end
45-
4637
def _chrono_record?
4738
owner.respond_to?(:as_of_time) && owner.as_of_time.present?
4839
end
4940
end
5041

51-
# Adds the WITH queries (Common Table Expressions) support to
52-
# ActiveRecord::Relation.
53-
#
54-
# \name is the CTE you want
55-
# \value can be a plain SQL query or another AR::Relation
56-
#
57-
# Example:
58-
#
59-
# Post.with('posts',
60-
# Post.from('history.posts').
61-
# where('? BETWEEN valid_from AND valid_to', 1.month.ago)
62-
# ).where(:author_id => 1)
63-
#
64-
# yields:
65-
#
66-
# WITH posts AS (
67-
# SELECT * FROM history.posts WHERE ... BETWEEN valid_from AND valid_to
68-
# ) SELECT * FROM posts
69-
#
70-
# PG Documentation:
71-
# http://www.postgresql.org/docs/9.0/static/queries-with.html
72-
#
73-
module QueryMethods
74-
attr_accessor :with_values
75-
76-
def with(name, value)
77-
clone.tap do |relation|
78-
relation.with_values ||= {}
79-
value = value.to_sql if value.respond_to? :to_sql
80-
relation.with_values[name] = value
81-
end
82-
end
83-
84-
def build_arel
85-
super.tap {|arel| arel.with with_values if with_values.present? }
86-
end
87-
end
88-
89-
module Querying
90-
delegate :with, :to => :scoped
91-
end
92-
93-
# Fixes ARel's WITH visitor method with the correct SQL syntax
94-
#
95-
# FIXME: the .children.first is messy. This should be properly
96-
# fixed in ARel.
97-
#
98-
class Visitor < Arel::Visitors::PostgreSQL
99-
def visit_Arel_Nodes_With o
100-
values = o.children.first.map do |name, value|
101-
[name, ' AS (', value.is_a?(String) ? value : visit(value), ')'].join
102-
end
103-
"WITH #{values.join ', '}"
104-
end
105-
end
106-
10742
end
10843
end

lib/chrono_model/time_gate.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ module TimeGate
99
module ClassMethods
1010
def as_of(time)
1111
time = Conversions.time_to_utc_string(time.utc) if time.kind_of? Time
12-
as_of = scoped.with(table_name,
13-
select(%[ #{quoted_table_name}.*, #{connection.quote(time)} AS "as_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}")
1418

1519
as_of.instance_variable_set(:@temporal, time)
1620

lib/chrono_model/time_machine.rb

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,7 @@ module HistoryMethods
219219
# Fetches as of +time+ records.
220220
#
221221
def as_of(time, scope = nil)
222-
time = Conversions.time_to_utc_string(time.utc) if time.kind_of? Time
223-
224-
as_of = superclass.unscoped.readonly.
225-
with(superclass.table_name, at(time))
222+
as_of = superclass.unscoped.readonly.from(virtual_table_at(time))
226223

227224
# Add default scopes back if we're passed nil or a
228225
# specific scope, because we're .unscopeing above.
@@ -242,9 +239,18 @@ def as_of(time, scope = nil)
242239
return as_of
243240
end
244241

242+
def virtual_table_at(time, name = nil)
243+
name = name ? connection.quote_table_name(name) :
244+
superclass.quoted_table_name
245+
246+
"(#{at(time).to_sql}) #{name}"
247+
end
248+
245249
# Fetches history record at the given time
246250
#
247251
def at(time)
252+
time = Conversions.time_to_utc_string(time.utc) if time.kind_of? Time
253+
248254
from, to = quoted_history_fields
249255
unscoped.
250256
select("#{quoted_table_name}.*, '#{time}' AS as_of_time").
@@ -346,12 +352,13 @@ module QueryMethods
346352
def build_arel
347353
super.tap do |arel|
348354

349-
# Extract joined tables and add emporal WITH if appropriate
350-
arel.join_sources.map {|j|
351-
j.to_sql =~ /JOIN "([\w-]+)"(?:\s+"([\w-]+)")? ON/ && [$1, $2]
352-
}.compact.each do |table, alias_|
353-
next unless (model = TimeMachine.chrono_models[table])
354-
with(alias_ || table, model.history.at(@temporal))
355+
arel.join_sources.each do |join|
356+
model = TimeMachine.chrono_models[join.left.table_name]
357+
next unless model
358+
359+
join.left = Arel::Nodes::SqlLiteral.new(
360+
model.history.virtual_table_at(@temporal, join.left.table_alias || join.left.table_name)
361+
)
355362
end if @temporal
356363

357364
end

0 commit comments

Comments
 (0)