chrono_model 0.3.1 → 0.4.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.
@@ -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: