Skip to content

Commit c280ff5

Browse files
committed
Add pg_xmin_optimistic_locking plugin for optimistic locking for all models without database changes
This implements similar support to the optimistic_locking plugin, but without the need for database changes to add a lock version column. Instead, it uses the xmin system column, which is automatically updated every time the row is updated. This allows you to load the plugin into a base class and have all models under the base class support optimistic locking. Internally, this splits off an optimistic_locking_base internal plugin from optimistic_locking, makes optimistic_locking and mssql_optimistic_locking use it, and uses it as the basis for the new pg_xmin_optimistic_locking plugin.
1 parent d5f9d11 commit c280ff5

File tree

8 files changed

+410
-80
lines changed

8 files changed

+410
-80
lines changed

CHANGELOG

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
=== master
22

3+
* Add pg_xmin_optimistic_locking plugin for optimistic locking for all models without database changes (jeremyevans)
4+
35
* Recognize the xid PostgreSQL type as an integer type in the jdbc/postgresql adapter (jeremyevans)
46

57
* Make set_column_allow_null method reversible in migrations (enescakir) (#2060)

lib/sequel/plugins/mssql_optimistic_locking.rb

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -26,57 +26,27 @@ module Plugins
2626
module MssqlOptimisticLocking
2727
# Load the instance_filters plugin into the model.
2828
def self.apply(model, opts=OPTS)
29-
model.plugin :instance_filters
29+
model.plugin(:optimistic_locking_base)
3030
end
3131

32-
# Set the lock_column to the :lock_column option (default: :timestamp)
32+
# Set the lock column
3333
def self.configure(model, opts=OPTS)
34-
model.lock_column = opts[:lock_column] || :timestamp
34+
model.lock_column = opts[:lock_column] || model.lock_column || :timestamp
3535
end
36-
37-
module ClassMethods
38-
# The timestamp/rowversion column containing the version for the current row.
39-
attr_accessor :lock_column
40-
41-
Plugins.inherited_instance_variables(self, :@lock_column=>nil)
42-
end
43-
36+
4437
module InstanceMethods
45-
# Add the lock column instance filter to the object before destroying it.
46-
def before_destroy
47-
lock_column_instance_filter
48-
super
49-
end
50-
51-
# Add the lock column instance filter to the object before updating it.
52-
def before_update
53-
lock_column_instance_filter
54-
super
55-
end
56-
5738
private
5839

59-
# Add the lock column instance filter to the object.
60-
def lock_column_instance_filter
61-
lc = model.lock_column
62-
instance_filter(lc=>Sequel.blob(get_column_value(lc)))
63-
end
64-
65-
# Clear the instance filters when refreshing, so that attempting to
66-
# refresh after a failed save removes the previous lock column filter
67-
# (the new one will be added before updating).
68-
def _refresh(ds)
69-
clear_instance_filters
70-
super
40+
# Make the instance filter value a blob.
41+
def lock_column_instance_filter_value
42+
Sequel.blob(super)
7143
end
7244

7345
# Remove the lock column from the columns to update.
7446
# SQL Server automatically updates the lock column value, and does not like
7547
# it to be assigned.
7648
def _save_update_all_columns_hash
77-
v = @values.dup
78-
cc = changed_columns
79-
Array(primary_key).each{|x| v.delete(x) unless cc.include?(x)}
49+
v = super
8050
v.delete(model.lock_column)
8151
v
8252
end

lib/sequel/plugins/optimistic_locking.rb

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,64 +12,31 @@ module Plugins
1212
# p1 = Person[1]
1313
# p2 = Person[1]
1414
# p1.update(name: 'Jim') # works
15-
# p2.update(name: 'Bob') # raises Sequel::Plugins::OptimisticLocking::Error
15+
# p2.update(name: 'Bob') # raises Sequel::NoExistingObject
1616
#
1717
# In order for this plugin to work, you need to make sure that the database
18-
# table has a +lock_version+ column (or other column you name via the lock_column
19-
# class level accessor) that defaults to 0.
18+
# table has a +lock_version+ column that defaults to 0. To change the column
19+
# used, provide a +:lock_column+ option when loading the plugin:
20+
#
21+
# plugin :optimistic_locking, lock_column: :version
2022
#
2123
# This plugin relies on the instance_filters plugin.
2224
module OptimisticLocking
2325
# Exception class raised when trying to update or destroy a stale object.
2426
Error = Sequel::NoExistingObject
2527

26-
# Load the instance_filters plugin into the model.
2728
def self.apply(model, opts=OPTS)
28-
model.plugin :instance_filters
29+
model.plugin(:optimistic_locking_base)
2930
end
3031

31-
# Set the lock_column to the :lock_column option, or :lock_version if
32-
# that option is not given.
32+
# Set the lock column
3333
def self.configure(model, opts=OPTS)
34-
model.lock_column = opts[:lock_column] || :lock_version
34+
model.lock_column = opts[:lock_column] || model.lock_column || :lock_version
3535
end
36-
37-
module ClassMethods
38-
# The column holding the version of the lock
39-
attr_accessor :lock_column
40-
41-
Plugins.inherited_instance_variables(self, :@lock_column=>nil)
42-
end
43-
36+
4437
module InstanceMethods
45-
# Add the lock column instance filter to the object before destroying it.
46-
def before_destroy
47-
lock_column_instance_filter
48-
super
49-
end
50-
51-
# Add the lock column instance filter to the object before updating it.
52-
def before_update
53-
lock_column_instance_filter
54-
super
55-
end
56-
5738
private
5839

59-
# Add the lock column instance filter to the object.
60-
def lock_column_instance_filter
61-
lc = model.lock_column
62-
instance_filter(lc=>get_column_value(lc))
63-
end
64-
65-
# Clear the instance filters when refreshing, so that attempting to
66-
# refresh after a failed save removes the previous lock column filter
67-
# (the new one will be added before updating).
68-
def _refresh(ds)
69-
clear_instance_filters
70-
super
71-
end
72-
7340
# Only update the row if it has the same lock version, and increment the
7441
# lock version.
7542
def _update_columns(columns)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen-string-literal: true
2+
3+
module Sequel
4+
module Plugins
5+
# Base for other optimistic locking plugins
6+
module OptimisticLockingBase
7+
# Load the instance_filters plugin into the model.
8+
def self.apply(model)
9+
model.plugin :instance_filters
10+
end
11+
12+
module ClassMethods
13+
# The column holding the version of the lock
14+
attr_accessor :lock_column
15+
16+
Plugins.inherited_instance_variables(self, :@lock_column=>nil)
17+
end
18+
19+
module InstanceMethods
20+
# Add the lock column instance filter to the object before destroying it.
21+
def before_destroy
22+
lock_column_instance_filter
23+
super
24+
end
25+
26+
# Add the lock column instance filter to the object before updating it.
27+
def before_update
28+
lock_column_instance_filter
29+
super
30+
end
31+
32+
private
33+
34+
# Add the lock column instance filter to the object.
35+
def lock_column_instance_filter
36+
instance_filter(model.lock_column=>lock_column_instance_filter_value)
37+
end
38+
39+
# Use the current value of the lock column
40+
def lock_column_instance_filter_value
41+
public_send(model.lock_column)
42+
end
43+
44+
# Clear the instance filters when refreshing, so that attempting to
45+
# refresh after a failed save removes the previous lock column filter
46+
# (the new one will be added before updating).
47+
def _refresh(ds)
48+
clear_instance_filters
49+
super
50+
end
51+
end
52+
end
53+
end
54+
end
55+
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen-string-literal: true
2+
3+
module Sequel
4+
module Plugins
5+
# This plugin implements optimistic locking mechanism on PostgreSQL based
6+
# on the xmin of the row. The xmin system column is automatically set to
7+
# the current transaction id whenever the row is inserted or updated:
8+
#
9+
# class Person < Sequel::Model
10+
# plugin :pg_xmin_optimistic_locking
11+
# end
12+
# p1 = Person[1]
13+
# p2 = Person[1]
14+
# p1.update(name: 'Jim') # works
15+
# p2.update(name: 'Bob') # raises Sequel::NoExistingObject
16+
#
17+
# The advantage of pg_xmin_optimistic_locking plugin compared to the
18+
# regular optimistic_locking plugin as that it does not require any
19+
# additional columns setup on the model. This allows it to be loaded
20+
# in the base model and have all subclasses automatically use
21+
# optimistic locking. The disadvantage is that testing can be
22+
# more difficult if you are modifying the underlying row between
23+
# when a model is retrieved and when it is saved.
24+
#
25+
# This plugin may not work with the class_table_inheritance plugin.
26+
#
27+
# This plugin relies on the instance_filters plugin.
28+
module PgXminOptimisticLocking
29+
WILDCARD = LiteralString.new('*').freeze
30+
31+
# Define the xmin column accessor
32+
def self.apply(model)
33+
model.instance_exec do
34+
plugin(:optimistic_locking_base)
35+
@lock_column = :xmin
36+
def_column_accessor(:xmin)
37+
end
38+
end
39+
40+
# Update the dataset to append the xmin column if it is usable
41+
# and there is a dataset for the model.
42+
def self.configure(model)
43+
model.instance_exec do
44+
set_dataset(@dataset) if @dataset
45+
end
46+
end
47+
48+
module ClassMethods
49+
private
50+
51+
# Ensure the dataset selects the xmin column if doing so
52+
def convert_input_dataset(ds)
53+
append_xmin_column_if_usable(super)
54+
end
55+
56+
# If the xmin column is not already selected, and selecting it does not
57+
# raise an error, append it to the selections.
58+
def append_xmin_column_if_usable(ds)
59+
select = ds.opts[:select]
60+
61+
unless select && select.include?(:xmin)
62+
xmin_ds = ds.select_append(:xmin)
63+
begin
64+
columns = xmin_ds.columns!
65+
rescue Sequel::DatabaseConnectionError, Sequel::DatabaseDisconnectError
66+
raise
67+
rescue Sequel::DatabaseError
68+
# ignore, could be view, subquery, table returning function, etc.
69+
else
70+
ds = xmin_ds if columns.include?(:xmin)
71+
end
72+
end
73+
74+
ds
75+
end
76+
end
77+
78+
module InstanceMethods
79+
private
80+
81+
# Only set the lock column instance filter if there is an xmin value.
82+
def lock_column_instance_filter
83+
super if @values[:xmin]
84+
end
85+
86+
# Include xmin value when inserting initial row
87+
def _insert_dataset
88+
super.returning(WILDCARD, :xmin)
89+
end
90+
91+
# Remove the xmin from the columns to update.
92+
# PostgreSQL automatically updates the xmin value, and it cannot be assigned.
93+
def _save_update_all_columns_hash
94+
v = super
95+
v.delete(:xmin)
96+
v
97+
end
98+
99+
# Add an RETURNING clause to fetch the updated xmin when updating the row.
100+
def _update_without_checking(columns)
101+
ds = _update_dataset
102+
rows = ds.clone(ds.send(:default_server_opts, :sql=>ds.returning(:xmin).update_sql(columns))).all
103+
values[:xmin] = rows.first[:xmin] unless rows.empty?
104+
rows.length
105+
end
106+
end
107+
end
108+
end
109+
end

spec/adapters/postgres_spec.rb

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5650,3 +5650,82 @@ def c2.name; 'Bar' end
56505650
@m1.all.must_equal [{:i1=>1, :a=>3}]
56515651
end
56525652
end if DB.server_version >= 150000
5653+
5654+
describe "pg_xmin_optimistic_locking plugin" do
5655+
before do
5656+
@db = DB
5657+
@db.create_table! :items do
5658+
primary_key :id
5659+
String :name, :size => 20
5660+
end
5661+
end
5662+
after do
5663+
@db.drop_table?(:items)
5664+
end
5665+
5666+
it "should not allow stale updates" do
5667+
c = Class.new(Sequel::Model(:items))
5668+
c.plugin :pg_xmin_optimistic_locking
5669+
o = c.create(:name=>'test')
5670+
o2 = c.first
5671+
xmin = c.dataset.naked.get(:xmin)
5672+
xmin.wont_equal nil
5673+
xmin.must_equal o.xmin
5674+
xmin.must_equal o2.xmin
5675+
o.name = 'test2'
5676+
o.save
5677+
xmin.wont_equal o.xmin
5678+
xmin.wont_equal c.dataset.naked.get(:xmin)
5679+
proc{o2.save}.must_raise(Sequel::NoExistingObject)
5680+
end
5681+
5682+
it "should work with subclasses" do
5683+
c = Class.new(Sequel::Model)
5684+
c.plugin :pg_xmin_optimistic_locking
5685+
sc = c::Model(:items)
5686+
o = sc.create(:name=>'test')
5687+
o2 = sc.first
5688+
xmin = sc.dataset.naked.get(:xmin)
5689+
xmin.wont_equal nil
5690+
xmin.must_equal o.xmin
5691+
xmin.must_equal o2.xmin
5692+
o.name = 'test2'
5693+
o.save
5694+
xmin.wont_equal o.xmin
5695+
xmin.wont_equal sc.dataset.naked.get(:xmin)
5696+
proc{o2.save}.must_raise(Sequel::NoExistingObject)
5697+
end
5698+
5699+
it "should allow updates when xmin is not selected" do
5700+
c = Class.new(Sequel::Model(:items))
5701+
c.plugin :pg_xmin_optimistic_locking
5702+
c.create(:name=>'test')
5703+
o = c.select_all(:items).first
5704+
o2 = c.first
5705+
o.xmin.must_be_nil
5706+
o.name = 'test2'
5707+
o.save
5708+
o.xmin.wont_be_nil
5709+
o.xmin.must_equal c.dataset.naked.get(:xmin)
5710+
proc{o2.save}.must_raise(Sequel::NoExistingObject)
5711+
end
5712+
5713+
it "should not select xmin when selecting from subquery" do
5714+
c = Class.new(Sequel::Model(@db[:items].from_self))
5715+
c.plugin :pg_xmin_optimistic_locking
5716+
@db[:items].insert(:name=>'test')
5717+
c.first.values[:xmin].must_be_nil
5718+
end
5719+
5720+
it "should not select xmin when selecting from view" do
5721+
begin
5722+
@db.create_view(:items_view, @db[:items])
5723+
c = Class.new(Sequel::Model(:items_view))
5724+
c.plugin :pg_xmin_optimistic_locking
5725+
@db[:items].insert(:name=>'test')
5726+
c.first.values[:xmin].must_be_nil
5727+
ensure
5728+
@db.drop_view(:items_view)
5729+
end
5730+
end
5731+
end

0 commit comments

Comments
 (0)