chrono_model 0.3.1 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile.lock +1 -1
- data/README.md +4 -0
- data/lib/chrono_model/adapter.rb +59 -8
- data/lib/chrono_model/railtie.rb +12 -8
- data/lib/chrono_model/time_machine.rb +4 -0
- data/lib/chrono_model/version.rb +1 -1
- data/spec/time_machine_spec.rb +50 -0
- metadata +6 -6
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -181,6 +181,10 @@ them in your output, use `rake VERBOSE=true`.
|
|
181
181
|
|
182
182
|
* The schema dumper is WAY TOO hacky.
|
183
183
|
|
184
|
+
* Savepoints are disabled, because there is
|
185
|
+
[currently](http://archives.postgresql.org/pgsql-hackers/2012-08/msg01094.php)
|
186
|
+
no way to identify a subtransaction belonging to the current transaction.
|
187
|
+
|
184
188
|
|
185
189
|
## Contributing
|
186
190
|
|
data/lib/chrono_model/adapter.rb
CHANGED
@@ -309,6 +309,17 @@ module ChronoModel
|
|
309
309
|
end
|
310
310
|
end
|
311
311
|
|
312
|
+
# Disable savepoints support, as they break history keeping.
|
313
|
+
# http://archives.postgresql.org/pgsql-hackers/2012-08/msg01094.php
|
314
|
+
#
|
315
|
+
def supports_savepoints?
|
316
|
+
false
|
317
|
+
end
|
318
|
+
|
319
|
+
def create_savepoint; end
|
320
|
+
def rollback_to_savepoint; end
|
321
|
+
def release_savepoint; end
|
322
|
+
|
312
323
|
private
|
313
324
|
# Create the history table in the history schema
|
314
325
|
def chrono_create_history_for(table)
|
@@ -364,7 +375,8 @@ module ChronoModel
|
|
364
375
|
|
365
376
|
# SELECT - return only current data
|
366
377
|
#
|
367
|
-
execute "
|
378
|
+
execute "DROP VIEW #{table}" if table_exists? table
|
379
|
+
execute "CREATE VIEW #{table} AS SELECT *, xmin AS __xid FROM ONLY #{current}"
|
368
380
|
|
369
381
|
columns = columns(table).map(&:name)
|
370
382
|
sequence = serial_sequence(current, pk) # For INSERT
|
@@ -378,13 +390,13 @@ module ChronoModel
|
|
378
390
|
#
|
379
391
|
if sequence.present?
|
380
392
|
execute <<-SQL
|
381
|
-
CREATE
|
393
|
+
CREATE RULE #{table}_ins AS ON INSERT TO #{table} DO INSTEAD (
|
382
394
|
|
383
395
|
INSERT INTO #{current} ( #{fields} ) VALUES ( #{values} );
|
384
396
|
|
385
397
|
INSERT INTO #{history} ( #{pk}, #{fields}, valid_from )
|
386
398
|
VALUES ( currval('#{sequence}'), #{values}, timezone('UTC', now()) )
|
387
|
-
RETURNING #{pk}, #{fields}
|
399
|
+
RETURNING #{pk}, #{fields}, xmin
|
388
400
|
)
|
389
401
|
SQL
|
390
402
|
else
|
@@ -392,7 +404,7 @@ module ChronoModel
|
|
392
404
|
values_with_pk = "new.#{pk}, " << values
|
393
405
|
|
394
406
|
execute <<-SQL
|
395
|
-
CREATE
|
407
|
+
CREATE RULE #{table}_ins AS ON INSERT TO #{table} DO INSTEAD (
|
396
408
|
|
397
409
|
INSERT INTO #{current} ( #{fields_with_pk} ) VALUES ( #{values_with_pk} );
|
398
410
|
|
@@ -405,9 +417,27 @@ module ChronoModel
|
|
405
417
|
|
406
418
|
# UPDATE - set the last history entry validity to now, save the current data
|
407
419
|
# in a new history entry and update the temporal table with the new data.
|
420
|
+
|
421
|
+
# If this is the first statement of a transaction, inferred by the last
|
422
|
+
# transaction ID that updated the row, create a new row in the history.
|
423
|
+
#
|
424
|
+
# The current transaction ID is returned by txid_current() as a 64-bit
|
425
|
+
# signed integer, while the last transaction ID that changed a row is
|
426
|
+
# stored into a 32-bit unsigned integer in the __xid column. As XIDs
|
427
|
+
# wrap over time, txid_current() adds an "epoch" counter in the most
|
428
|
+
# significant bits (http://bit.ly/W2Srt7) of the int - thus here we
|
429
|
+
# remove it by and'ing with 2^32-1.
|
430
|
+
#
|
431
|
+
# XID are 32-bit unsigned integers, and by design cannot be casted nor
|
432
|
+
# compared to anything else, adding a CAST or an operator requires
|
433
|
+
# super-user privileges, so here we do a double-cast from varchar to
|
434
|
+
# int8, to finally compare it with the current XID. We're using 64bit
|
435
|
+
# integers as in PG there is no 32-bit unsigned data type.
|
408
436
|
#
|
409
437
|
execute <<-SQL
|
410
|
-
CREATE
|
438
|
+
CREATE RULE #{table}_upd_first AS ON UPDATE TO #{table}
|
439
|
+
WHERE old.__xid::char(10)::int8 <> (txid_current() & (2^32-1)::int8)
|
440
|
+
DO INSTEAD (
|
411
441
|
|
412
442
|
UPDATE #{history} SET valid_to = timezone('UTC', now())
|
413
443
|
WHERE #{pk} = old.#{pk} AND valid_to = '9999-12-31';
|
@@ -420,11 +450,32 @@ module ChronoModel
|
|
420
450
|
)
|
421
451
|
SQL
|
422
452
|
|
423
|
-
#
|
424
|
-
#
|
453
|
+
# Else, update the already present history row with new data. This logic
|
454
|
+
# makes possible to "squash" together changes made in a transaction in a
|
455
|
+
# single history row, assuring timestamps consistency.
|
425
456
|
#
|
426
457
|
execute <<-SQL
|
427
|
-
CREATE
|
458
|
+
CREATE RULE #{table}_upd_next AS ON UPDATE TO #{table} DO INSTEAD (
|
459
|
+
UPDATE #{history} SET #{updates}
|
460
|
+
WHERE #{pk} = old.#{pk} AND valid_from = timezone('UTC', now());
|
461
|
+
|
462
|
+
UPDATE ONLY #{current} SET #{updates}
|
463
|
+
WHERE #{pk} = old.#{pk}
|
464
|
+
)
|
465
|
+
SQL
|
466
|
+
|
467
|
+
# DELETE - save the current data in the history and eventually delete the
|
468
|
+
# data from the temporal table.
|
469
|
+
# The first DELETE is required to remove history for records INSERTed and
|
470
|
+
# DELETEd in the same transaction.
|
471
|
+
#
|
472
|
+
execute <<-SQL
|
473
|
+
CREATE RULE #{table}_del AS ON DELETE TO #{table} DO INSTEAD (
|
474
|
+
|
475
|
+
DELETE FROM #{history}
|
476
|
+
WHERE #{pk} = old.#{pk}
|
477
|
+
AND valid_from = timezone('UTC', now())
|
478
|
+
AND valid_to = '9999-12-31';
|
428
479
|
|
429
480
|
UPDATE #{history} SET valid_to = timezone('UTC', now())
|
430
481
|
WHERE #{pk} = old.#{pk} AND valid_to = '9999-12-31';
|
data/lib/chrono_model/railtie.rb
CHANGED
@@ -1,17 +1,21 @@
|
|
1
1
|
module ChronoModel
|
2
2
|
class Railtie < ::Rails::Railtie
|
3
|
+
initializer :chrono_create_schemas do
|
4
|
+
ActiveRecord::Base.connection.chrono_create_schemas!
|
5
|
+
end
|
6
|
+
|
3
7
|
rake_tasks do
|
4
8
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
9
|
+
namespace :db do
|
10
|
+
namespace :chrono do
|
11
|
+
task :create_schemas do
|
12
|
+
puts 'create schemas'
|
13
|
+
ActiveRecord::Base.connection.chrono_create_schemas!
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
12
17
|
|
13
18
|
task 'db:schema:load' => 'db:chrono:create_schemas'
|
14
|
-
task 'db:migrate' => 'db:chrono:create_schemas'
|
15
19
|
end
|
16
20
|
|
17
21
|
class SchemaDumper < ::ActiveRecord::SchemaDumper
|
@@ -137,6 +137,10 @@ module ChronoModel
|
|
137
137
|
self.kind_of? self.class.history
|
138
138
|
end
|
139
139
|
|
140
|
+
def attributes(*)
|
141
|
+
super.tap {|x| x.delete('__xid')}
|
142
|
+
end
|
143
|
+
|
140
144
|
module ClassMethods
|
141
145
|
# Returns an ActiveRecord::Relation on the history of this model as
|
142
146
|
# it was +time+ ago.
|
data/lib/chrono_model/version.rb
CHANGED
data/spec/time_machine_spec.rb
CHANGED
@@ -306,4 +306,54 @@ describe ChronoModel::TimeMachine do
|
|
306
306
|
end
|
307
307
|
end
|
308
308
|
|
309
|
+
# Transactions
|
310
|
+
context 'Within transactions' do
|
311
|
+
context 'multiple updates to an existing record' do
|
312
|
+
let!(:r1) do
|
313
|
+
Foo.create!(:name => 'xact test').tap do |record|
|
314
|
+
Foo.transaction do
|
315
|
+
record.update_attribute 'name', 'lost into oblivion'
|
316
|
+
record.update_attribute 'name', 'does work'
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
it "generate only a single history record" do
|
322
|
+
r1.history.should have(2).entries
|
323
|
+
|
324
|
+
r1.history.first.name.should == 'xact test'
|
325
|
+
r1.history.last.name.should == 'does work'
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
context 'insertion and subsequent update' do
|
330
|
+
let!(:r2) do
|
331
|
+
Foo.transaction do
|
332
|
+
Foo.create!(:name => 'lost into oblivion').tap do |record|
|
333
|
+
record.update_attribute 'name', 'I am Bar'
|
334
|
+
record.update_attribute 'name', 'I am Foo'
|
335
|
+
end
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
it 'generates a single history record' do
|
340
|
+
r2.history.should have(1).entry
|
341
|
+
|
342
|
+
r2.history.first.name.should == 'I am Foo'
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
context 'insertion and subsequent deletion' do
|
347
|
+
let!(:r3) do
|
348
|
+
Foo.transaction do
|
349
|
+
Foo.create!(:name => 'it never happened').destroy
|
350
|
+
end
|
351
|
+
end
|
352
|
+
|
353
|
+
it 'does not generate any history' do
|
354
|
+
Foo.history.where(:id => r3.id).should be_empty
|
355
|
+
end
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
309
359
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: chrono_model
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,11 +9,11 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-10-
|
12
|
+
date: 2012-10-30 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activerecord
|
16
|
-
requirement: &
|
16
|
+
requirement: &79419830 !ruby/object:Gem::Requirement
|
17
17
|
none: false
|
18
18
|
requirements:
|
19
19
|
- - ~>
|
@@ -21,10 +21,10 @@ dependencies:
|
|
21
21
|
version: '3.2'
|
22
22
|
type: :runtime
|
23
23
|
prerelease: false
|
24
|
-
version_requirements: *
|
24
|
+
version_requirements: *79419830
|
25
25
|
- !ruby/object:Gem::Dependency
|
26
26
|
name: pg
|
27
|
-
requirement: &
|
27
|
+
requirement: &79493370 !ruby/object:Gem::Requirement
|
28
28
|
none: false
|
29
29
|
requirements:
|
30
30
|
- - ! '>='
|
@@ -32,7 +32,7 @@ dependencies:
|
|
32
32
|
version: '0'
|
33
33
|
type: :runtime
|
34
34
|
prerelease: false
|
35
|
-
version_requirements: *
|
35
|
+
version_requirements: *79493370
|
36
36
|
description: Give your models as-of date temporal extensions. Built entirely for PostgreSQL
|
37
37
|
>= 9.0
|
38
38
|
email:
|