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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0ee169113edb44c11902cb1bd27fdb5f156d1c4ca4ff2a8e75b250d71bc3d52
4
- data.tar.gz: 8d7ff6671fd40a3f08c33dd0c8070c427132919123925ed1d9fa28a43ca32e28
3
+ metadata.gz: 98446468efcfe543a72a5bf354b5e8c8408951dfc15072968df64500dc9e0275
4
+ data.tar.gz: a814a905f38b39064d8871a883b3bb4d68b2c446f0cec33d93f4c37671fcc33c
5
5
  SHA512:
6
- metadata.gz: 8d2faaf09b6173c3d4c8d2a052f3377bdecfc3594a9fad426043296f8e2bc880519d528774e5481a5361414a32ee06082a6f5de55f6e15f0cf82718a6f87bda9
7
- data.tar.gz: f2230cf14ee341c776f617eb1eb592013d907efbf18db02ea570f6d210363b46dd5b40be7b901a82ceef3b609f1e3a6e5fcd4a443f7350aca1150734a7ee1984
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, especially those that seek the next position integer available are vulnerable to race conditions. To this end, we've introduced an Advisory Lock to ensure that our model callbacks that determine and assign positions run sequentially. In short, an advisory lock prevents more than one process from running a particular block of code at the same time. The lock occurs in the database (or in the case of SQLite, on the filesystem), so as long as all of your processes are using the same database, the lock will prevent multiple positioning callbacks from executing on the same table and positioning column combination at the same time.
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
- You're encouraged to review the Advisory Lock code to ensure it fits with your environment:
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.
@@ -7,19 +7,36 @@ module Positioning
7
7
  end
8
8
 
9
9
  def heal
10
- if positioning_columns.present?
11
- @model.select(*positioning_columns).distinct.each do |scope_record|
12
- sequence @model.where(scope_record.slice(*positioning_columns))
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
- sequence @model
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 positioning_columns
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
- base_class.connection.quote_table_name_for_assignment base_class.table_name, @column
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(primary_key => [@positioned.id])
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 positioning_columns
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(*positioning_columns)
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(*positioning_columns)
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
- positioning_columns.any? do |scope_component|
179
- @positioned.attribute_changed?(scope_component)
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 && positioning_columns.any? do |scope_component|
185
- @positioned.destroyed_by_association.foreign_key == scope_component
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
@@ -1,3 +1,3 @@
1
1
  module Positioning
2
- VERSION = "0.2.6"
2
+ VERSION = "0.4.0"
3
3
  end
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, advisory_lock: true)
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] = Array.wrap(on).map do |scope_component|
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
- (reflection && reflection.belongs_to?) ? reflection.foreign_key : scope_component
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
- advisory_locker.acquire do
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.2.6
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-08-21 00:00:00.000000000 Z
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