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.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- chrono_model (0.3.0)
4
+ chrono_model (0.4.0)
5
5
  activerecord (~> 3.2)
6
6
  pg
7
7
 
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
 
@@ -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 "CREATE OR REPLACE VIEW #{table} AS SELECT * FROM ONLY #{current}"
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 OR REPLACE RULE #{table}_ins AS ON INSERT TO #{table} DO INSTEAD (
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 OR REPLACE RULE #{table}_ins AS ON INSERT TO #{table} DO INSTEAD (
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 OR REPLACE RULE #{table}_upd AS ON UPDATE TO #{table} DO INSTEAD (
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
- # DELETE - save the current data in the history and eventually delete the data
424
- # from the temporal table.
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 OR REPLACE RULE #{table}_del AS ON DELETE TO #{table} DO INSTEAD (
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';
@@ -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
- namespace :db do
6
- namespace :chrono do
7
- task :create_schemas do
8
- ActiveRecord::Base.connection.chrono_create_schemas!
9
- end
10
- end
11
- end
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.
@@ -1,3 +1,3 @@
1
1
  module ChronoModel
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -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.3.1
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-23 00:00:00.000000000 Z
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: &70658630 !ruby/object:Gem::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: *70658630
24
+ version_requirements: *79419830
25
25
  - !ruby/object:Gem::Dependency
26
26
  name: pg
27
- requirement: &70658260 !ruby/object:Gem::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: *70658260
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: