positioning 0.2.6 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +6 -39
- data/lib/positioning/healer.rb +23 -6
- data/lib/positioning/mechanisms.rb +44 -10
- data/lib/positioning/version.rb +1 -1
- data/lib/positioning.rb +19 -16
- metadata +2 -3
- data/lib/positioning/advisory_lock.rb +0 -82
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 98446468efcfe543a72a5bf354b5e8c8408951dfc15072968df64500dc9e0275
|
4
|
+
data.tar.gz: a814a905f38b39064d8871a883b3bb4d68b2c446f0cec33d93f4c37671fcc33c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 339a65265898c0b0e7b0e1758e708f57b4ee49d3b8fad1e93489058e6cbc047022e8d0cd42fcbe7793dad1a52dc04784e4122ce9e53d4360988f77f296f45c9f
|
7
|
+
data.tar.gz: 5443a095efe22bb69a079d51ae3bea37b78bd7035a3f0e07580c45006bbff98d44af7aeb17771622a4d33e98f8d0b3b957929a0b6a84ff54d3696684c3534ff2
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,14 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.4.0] - 2024-10-12
|
4
|
+
|
5
|
+
- BREAKING CHANGE: Advisory Lock has been removed. If you explicitly define `advisory_lock: false` in your `positioned` call, you'll need to remove this.
|
6
|
+
- CAUTION: The Advisory Lock replacement is row locking. Where `belongs_to` associations exist, we lock the associated record(s), and that limits the locking scope down to the record's current scope, and potentially the scope it belonged to before a change in scope. If there are no `belongs_to` associations then the records that belong to the current (and potentially new) scope are locked, or all the records in the table are locked if there is no scope. Please report any deadlock issues.
|
7
|
+
|
8
|
+
## [0.3.0] - 2024-10-12
|
9
|
+
|
10
|
+
- POSSIBLY BREAKING: Clear all position columns on a duplicate created with `dup`.
|
11
|
+
|
3
12
|
## [0.2.6] - 2024-08-21
|
4
13
|
|
5
14
|
- Implement list healing so that existing lists can be fixed up when implementing `positioned` or if the list somehow gets corrupted.
|
data/README.md
CHANGED
@@ -185,6 +185,10 @@ other_item.id # => 11
|
|
185
185
|
item.update position: {after: 11}
|
186
186
|
```
|
187
187
|
|
188
|
+
##### Duplicating (`dup`)
|
189
|
+
|
190
|
+
When you call `dup` on an instance in the list, all position columns on the duplicate will be set to `nil` so that when this duplicate is saved it will be added either to the end of the current scopes (if unchanged) or to the end of any new scopes. Of course you can then override the position of the duplicate before you save it if necessary.
|
191
|
+
|
188
192
|
##### Relative Positioning in Forms
|
189
193
|
|
190
194
|
It can be tricky to provide the hash forms of relative positioning using Rails form helpers, but it is possible. We've declared a special `Struct` for you to use for this purpose.
|
@@ -261,49 +265,12 @@ It's important to note that in the examples above, `other_item` must already bel
|
|
261
265
|
|
262
266
|
## Concurrency
|
263
267
|
|
264
|
-
The queries that this gem runs
|
265
|
-
|
266
|
-
If you are using SQLite, you'll want to add the following line to your database.yml file in order to increase the exclusivity of Active Record's default write transactions:
|
267
|
-
|
268
|
-
```yaml
|
269
|
-
default_transaction_mode: EXCLUSIVE
|
270
|
-
```
|
271
|
-
|
272
|
-
You may also want to try `IMMEDIATE` as a less aggressive alternative.
|
268
|
+
The queries that this gem runs (especially those that seek the next position integer available) are vulnerable to race conditions. To this end, we lock the scope records to ensure that our model callbacks that determine and assign positions run sequentially. Previously we used an Advisory Lock for this purpose but this was difficult to test and a bit overkill in most situations. Where a scope doesn't exist, we lock all the records in the table.
|
273
269
|
|
274
|
-
|
275
|
-
|
276
|
-
https://github.com/brendon/positioning/blob/main/lib/positioning/advisory_lock.rb
|
270
|
+
**Please Note SQLite Users:** Row locking isn't supported by SQLite. Since writes are non-concurrent by default, the worst you'll probably see are errors about the database being locked under high load.
|
277
271
|
|
278
272
|
If you have any concerns or improvements please file a GitHub issue.
|
279
273
|
|
280
|
-
### Opting out of Advisory Lock
|
281
|
-
|
282
|
-
There are cases where Advisory Lock may be unwanted or unnecessary, for instance, if you already lock the parent record in **every** operation that will touch the database on the positioned item, **everywhere** in your application.
|
283
|
-
|
284
|
-
Example of such scenario in your application:
|
285
|
-
|
286
|
-
```ruby
|
287
|
-
list = List.create(name: "List")
|
288
|
-
|
289
|
-
list.with_lock do
|
290
|
-
item_a = list.items.create(name: "Item A")
|
291
|
-
item_b = list.items.create(name: "Item B")
|
292
|
-
item_c = list.items.create(name: "Item C")
|
293
|
-
|
294
|
-
item_c.update(position: {before: item_a})
|
295
|
-
|
296
|
-
item_a.destroy
|
297
|
-
end
|
298
|
-
```
|
299
|
-
|
300
|
-
Therefore, making sure you already have another mechanism to avoid race conditions, you can opt out of Advisory Lock by setting `advisory_lock` to `false` when declaring positioning:
|
301
|
-
|
302
|
-
```ruby
|
303
|
-
belongs_to :list
|
304
|
-
positioned on: :list, advisory_lock: false
|
305
|
-
```
|
306
|
-
|
307
274
|
## Development
|
308
275
|
|
309
276
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/lib/positioning/healer.rb
CHANGED
@@ -7,19 +7,36 @@ module Positioning
|
|
7
7
|
end
|
8
8
|
|
9
9
|
def heal
|
10
|
-
if
|
11
|
-
@model.select(*
|
12
|
-
|
10
|
+
if scope_columns.present?
|
11
|
+
@model.select(*scope_columns).distinct.each do |scope_record|
|
12
|
+
@model.transaction do
|
13
|
+
if scope_associations.present?
|
14
|
+
scope_associations.each do |scope_association|
|
15
|
+
scope_record.send(scope_association).lock!
|
16
|
+
end
|
17
|
+
else
|
18
|
+
@model.where(scope_record.slice(*scope_columns)).lock!
|
19
|
+
end
|
20
|
+
|
21
|
+
sequence @model.where(scope_record.slice(*scope_columns))
|
22
|
+
end
|
13
23
|
end
|
14
24
|
else
|
15
|
-
|
25
|
+
@model.transaction do
|
26
|
+
@model.all.lock!
|
27
|
+
sequence @model
|
28
|
+
end
|
16
29
|
end
|
17
30
|
end
|
18
31
|
|
19
32
|
private
|
20
33
|
|
21
|
-
def
|
22
|
-
@model.positioning_columns[@column]
|
34
|
+
def scope_columns
|
35
|
+
@model.positioning_columns[@column][:scope_columns]
|
36
|
+
end
|
37
|
+
|
38
|
+
def scope_associations
|
39
|
+
@model.positioning_columns[@column][:scope_associations]
|
23
40
|
end
|
24
41
|
|
25
42
|
def sequence(scope)
|
@@ -14,6 +14,8 @@ module Positioning
|
|
14
14
|
end
|
15
15
|
|
16
16
|
def create_position
|
17
|
+
lock_positioning_scope!
|
18
|
+
|
17
19
|
solidify_position
|
18
20
|
|
19
21
|
expand(positioning_scope, position..)
|
@@ -22,6 +24,8 @@ module Positioning
|
|
22
24
|
def update_position
|
23
25
|
return unless positioning_scope_changed? || position_changed?
|
24
26
|
|
27
|
+
lock_positioning_scope!
|
28
|
+
|
25
29
|
clear_position if positioning_scope_changed? && !position_changed?
|
26
30
|
|
27
31
|
solidify_position
|
@@ -39,6 +43,8 @@ module Positioning
|
|
39
43
|
|
40
44
|
def destroy_position
|
41
45
|
unless destroyed_via_positioning_scope?
|
46
|
+
lock_positioning_scope!
|
47
|
+
|
42
48
|
move_out_of_the_way
|
43
49
|
contract(positioning_scope, (position_was + 1)..)
|
44
50
|
end
|
@@ -50,16 +56,28 @@ module Positioning
|
|
50
56
|
@positioned.class.base_class
|
51
57
|
end
|
52
58
|
|
59
|
+
def with_connection
|
60
|
+
if base_class.respond_to? :with_connection
|
61
|
+
base_class.with_connection do |connection|
|
62
|
+
yield connection
|
63
|
+
end
|
64
|
+
else
|
65
|
+
yield base_class.connection
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
53
69
|
def primary_key
|
54
70
|
base_class.primary_key
|
55
71
|
end
|
56
72
|
|
57
73
|
def quoted_column
|
58
|
-
|
74
|
+
with_connection do |connection|
|
75
|
+
connection.quote_table_name_for_assignment base_class.table_name, @column
|
76
|
+
end
|
59
77
|
end
|
60
78
|
|
61
79
|
def record_scope
|
62
|
-
base_class.where
|
80
|
+
base_class.where primary_key => [@positioned.id]
|
63
81
|
end
|
64
82
|
|
65
83
|
def position
|
@@ -158,16 +176,32 @@ module Positioning
|
|
158
176
|
(positioning_scope.maximum(@column) || 0) + (in_positioning_scope? ? 0 : 1)
|
159
177
|
end
|
160
178
|
|
161
|
-
def
|
162
|
-
base_class.positioning_columns[@column]
|
179
|
+
def scope_columns
|
180
|
+
base_class.positioning_columns[@column][:scope_columns]
|
181
|
+
end
|
182
|
+
|
183
|
+
def scope_associations
|
184
|
+
base_class.positioning_columns[@column][:scope_associations]
|
163
185
|
end
|
164
186
|
|
165
187
|
def positioning_scope
|
166
|
-
base_class.where @positioned.slice(*
|
188
|
+
base_class.where @positioned.slice(*scope_columns)
|
189
|
+
end
|
190
|
+
|
191
|
+
def lock_positioning_scope!
|
192
|
+
if scope_associations.present?
|
193
|
+
scope_associations.each do |scope_association|
|
194
|
+
record_scope.first.send(scope_association).lock! if @positioned.persisted? && positioning_scope_changed?
|
195
|
+
@positioned.send(scope_association).lock!
|
196
|
+
end
|
197
|
+
else
|
198
|
+
positioning_scope_was.lock! if @positioned.persisted? && positioning_scope_changed?
|
199
|
+
positioning_scope.lock!
|
200
|
+
end
|
167
201
|
end
|
168
202
|
|
169
203
|
def positioning_scope_was
|
170
|
-
base_class.where record_scope.first.slice(*
|
204
|
+
base_class.where record_scope.first.slice(*scope_columns)
|
171
205
|
end
|
172
206
|
|
173
207
|
def in_positioning_scope?
|
@@ -175,14 +209,14 @@ module Positioning
|
|
175
209
|
end
|
176
210
|
|
177
211
|
def positioning_scope_changed?
|
178
|
-
|
179
|
-
@positioned.attribute_changed?(
|
212
|
+
scope_columns.any? do |scope_column|
|
213
|
+
@positioned.attribute_changed?(scope_column)
|
180
214
|
end
|
181
215
|
end
|
182
216
|
|
183
217
|
def destroyed_via_positioning_scope?
|
184
|
-
@positioned.destroyed_by_association &&
|
185
|
-
@positioned.destroyed_by_association.foreign_key ==
|
218
|
+
@positioned.destroyed_by_association && scope_columns.any? do |scope_column|
|
219
|
+
@positioned.destroyed_by_association.foreign_key == scope_column
|
186
220
|
end
|
187
221
|
end
|
188
222
|
end
|
data/lib/positioning/version.rb
CHANGED
data/lib/positioning.rb
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
require_relative "positioning/version"
|
2
|
-
require_relative "positioning/advisory_lock"
|
3
2
|
require_relative "positioning/mechanisms"
|
4
3
|
require_relative "positioning/healer"
|
5
4
|
|
@@ -19,7 +18,7 @@ module Positioning
|
|
19
18
|
@positioning_columns ||= {}
|
20
19
|
end
|
21
20
|
|
22
|
-
def positioned(on: [], column: :position
|
21
|
+
def positioned(on: [], column: :position)
|
23
22
|
unless base_class?
|
24
23
|
raise Error.new "can't be called on an abstract class or STI subclass."
|
25
24
|
end
|
@@ -29,11 +28,18 @@ module Positioning
|
|
29
28
|
if positioning_columns.key? column
|
30
29
|
raise Error.new "The column `#{column}` has already been used by the scope `#{positioning_columns[column]}`."
|
31
30
|
else
|
32
|
-
positioning_columns[column] =
|
31
|
+
positioning_columns[column] = {scope_columns: [], scope_associations: []}
|
32
|
+
|
33
|
+
Array.wrap(on).each do |scope_component|
|
33
34
|
scope_component = scope_component.to_s
|
34
35
|
reflection = reflections[scope_component]
|
35
36
|
|
36
|
-
|
37
|
+
if reflection&.belongs_to?
|
38
|
+
positioning_columns[column][:scope_columns] << reflection.foreign_key
|
39
|
+
positioning_columns[column][:scope_associations] << reflection.name
|
40
|
+
else
|
41
|
+
positioning_columns[column][:scope_columns] << scope_component
|
42
|
+
end
|
37
43
|
end
|
38
44
|
|
39
45
|
define_method(:"prior_#{column}") { Mechanisms.new(self, column).prior }
|
@@ -44,27 +50,24 @@ module Positioning
|
|
44
50
|
super(position)
|
45
51
|
end
|
46
52
|
|
47
|
-
advisory_locker = AdvisoryLock.new(base_class, column, advisory_lock)
|
48
|
-
|
49
|
-
before_create { advisory_locker.acquire }
|
50
|
-
before_update { advisory_locker.acquire }
|
51
|
-
before_destroy { advisory_locker.acquire }
|
52
|
-
|
53
53
|
before_create { Mechanisms.new(self, column).create_position }
|
54
54
|
before_update { Mechanisms.new(self, column).update_position }
|
55
55
|
before_destroy { Mechanisms.new(self, column).destroy_position }
|
56
56
|
|
57
|
-
after_commit { advisory_locker.release }
|
58
|
-
after_rollback { advisory_locker.release }
|
59
|
-
|
60
57
|
define_singleton_method(:"heal_#{column}_column!") do |order = column|
|
61
|
-
|
62
|
-
Healer.new(self, column, order).heal
|
63
|
-
end
|
58
|
+
Healer.new(self, column, order).heal
|
64
59
|
end
|
65
60
|
end
|
66
61
|
end
|
67
62
|
end
|
63
|
+
|
64
|
+
def initialize_dup(other)
|
65
|
+
super
|
66
|
+
|
67
|
+
self.class.positioning_columns.keys.each do |positioning_column|
|
68
|
+
send :"#{positioning_column}=", nil
|
69
|
+
end
|
70
|
+
end
|
68
71
|
end
|
69
72
|
end
|
70
73
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: positioning
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Brendon Muir
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-11-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -122,7 +122,6 @@ files:
|
|
122
122
|
- README.md
|
123
123
|
- Rakefile
|
124
124
|
- lib/positioning.rb
|
125
|
-
- lib/positioning/advisory_lock.rb
|
126
125
|
- lib/positioning/healer.rb
|
127
126
|
- lib/positioning/mechanisms.rb
|
128
127
|
- lib/positioning/version.rb
|
@@ -1,82 +0,0 @@
|
|
1
|
-
require "fileutils"
|
2
|
-
require "openssl"
|
3
|
-
|
4
|
-
module Positioning
|
5
|
-
class AdvisoryLock
|
6
|
-
Adapter = Struct.new(:initialise, :acquire, :release, keyword_init: true)
|
7
|
-
|
8
|
-
attr_reader :base_class
|
9
|
-
|
10
|
-
def initialize(base_class, column, enabled)
|
11
|
-
@base_class = base_class
|
12
|
-
@column = column.to_s
|
13
|
-
@enabled = enabled
|
14
|
-
|
15
|
-
@adapters = {
|
16
|
-
"mysql2" => Adapter.new(
|
17
|
-
initialise: -> {},
|
18
|
-
acquire: -> { connection.execute "SELECT GET_LOCK(#{connection.quote(lock_name)}, -1)" },
|
19
|
-
release: -> { connection.execute "SELECT RELEASE_LOCK(#{connection.quote(lock_name)})" }
|
20
|
-
),
|
21
|
-
"postgresql" => Adapter.new(
|
22
|
-
initialise: -> {},
|
23
|
-
acquire: -> { connection.execute "SELECT pg_advisory_lock(#{lock_name.hex & 0x7FFFFFFFFFFFFFFF})" },
|
24
|
-
release: -> { connection.execute "SELECT pg_advisory_unlock(#{lock_name.hex & 0x7FFFFFFFFFFFFFFF})" }
|
25
|
-
),
|
26
|
-
"sqlite3" => Adapter.new(
|
27
|
-
initialise: -> {
|
28
|
-
FileUtils.mkdir_p "#{Dir.pwd}/tmp"
|
29
|
-
filename = "#{Dir.pwd}/tmp/#{lock_name}.lock"
|
30
|
-
@file ||= File.open filename, File::RDWR | File::CREAT, 0o644
|
31
|
-
},
|
32
|
-
acquire: -> {
|
33
|
-
@file.flock File::LOCK_EX
|
34
|
-
},
|
35
|
-
release: -> {
|
36
|
-
@file.flock File::LOCK_UN
|
37
|
-
}
|
38
|
-
)
|
39
|
-
}
|
40
|
-
|
41
|
-
@adapters.default = Adapter.new(initialise: -> {}, acquire: -> {}, release: -> {})
|
42
|
-
|
43
|
-
adapter.initialise.call if @enabled
|
44
|
-
end
|
45
|
-
|
46
|
-
def acquire
|
47
|
-
adapter.acquire.call if @enabled
|
48
|
-
|
49
|
-
if block_given?
|
50
|
-
yield
|
51
|
-
adapter.release.call if @enabled
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
def release
|
56
|
-
adapter.release.call if @enabled
|
57
|
-
end
|
58
|
-
|
59
|
-
private
|
60
|
-
|
61
|
-
def connection
|
62
|
-
base_class.connection
|
63
|
-
end
|
64
|
-
|
65
|
-
def adapter_name
|
66
|
-
base_class.connection_db_config.adapter
|
67
|
-
end
|
68
|
-
|
69
|
-
def adapter
|
70
|
-
@adapters[adapter_name]
|
71
|
-
end
|
72
|
-
|
73
|
-
def lock_name
|
74
|
-
lock_name = ["positioning"]
|
75
|
-
lock_name << connection.current_database if connection.respond_to?(:current_database)
|
76
|
-
lock_name << base_class.table_name
|
77
|
-
lock_name << @column
|
78
|
-
|
79
|
-
OpenSSL::Digest::MD5.hexdigest(lock_name.join("."))[0...32]
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|