positioning 0.4.10 → 0.5.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.
- checksums.yaml +4 -4
- data/.standard.yml +1 -1
- data/CHANGELOG.md +11 -0
- data/README.md +16 -0
- data/lib/positioning/healer.rb +0 -4
- data/lib/positioning/mechanisms.rb +21 -20
- data/lib/positioning/version.rb +1 -1
- data/lib/positioning.rb +37 -21
- data/positioning.gemspec +3 -3
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7677909862f7df18c0550419eeb1998e35fbf50cc0a9d6635e4d06edce088ea6
|
|
4
|
+
data.tar.gz: f5a9a1b51f11f1dd60433cb6c29e651ce29ad80cd2babaed4e4b01cde1af253d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ad652e8ba47212719b599cee9209237a9defe9dea61b6364b8c5f501541fb17dea37e2d6476c13aa1846ee5a086dc842135f15899bc62f13acd67e874726dfc
|
|
7
|
+
data.tar.gz: 33da3275bb9b4bdceccf5c2092a8523cd9813eb3b4ef542acc801aacb4df51946f35dc83f124a2026a43bae6399c76e1b5c081806c31fdf635c1fd5d7ee96378
|
data/.standard.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-11-11
|
|
4
|
+
|
|
5
|
+
- Require Active Record 7.2+ and Ruby 3.3+, matching what's tested. Bundler will keep apps on older versions on 0.4.10.
|
|
6
|
+
- `prior_*` and `subsequent_*` now return `nil` (rather than raising) for records without a position.
|
|
7
|
+
- A position writer defined in the model now composes with positioning's via `super`.
|
|
8
|
+
- `belongs_to` scopes are now recognised even when declared after `positioned` (e.g. in a concern included later).
|
|
9
|
+
- Don't write anything when an assigned position doesn't change. `updated_at` is no longer bumped and `saved_change_to_position?` is false in that case.
|
|
10
|
+
- Fix a destroy halted by a callback declared after `positioned` (e.g. `dependent: :restrict_with_error`), inside a transaction that's already open, leaving the record at position 0 and the records after it moved up.
|
|
11
|
+
- Add tests for behaviour that mutation testing showed was untested.
|
|
12
|
+
- Document caveats around validating the position column, halting callbacks, operations that bypass positioning, and the limits of row locking.
|
|
13
|
+
|
|
3
14
|
## [0.4.10] - 2026-11-09
|
|
4
15
|
|
|
5
16
|
- Fix MySQL deadlocks when adding a record to the end of a list while another transaction holds the list's first row and then waits on the scope lock, such as a `touch: true` chain from a child. When nothing needs to move, we no longer run the query that locked that row.
|
data/README.md
CHANGED
|
@@ -40,6 +40,8 @@ If you have a polymorphic `belongs_to` then you'll want to add the type column t
|
|
|
40
40
|
|
|
41
41
|
The Positioning gem uses `0` and negative integers to rearrange the lists it manages so don't add database validations to restrict the usage of these. You are also restricted from using `0` and negative integers as position values. If you try, the position value will become `1`. If you try to set an explicit position value that is greater than the next available list position, it will be rounded down to that value.
|
|
42
42
|
|
|
43
|
+
Likewise, don't validate the position column in your model (e.g. `validates :position, presence: true`). A new record doesn't have a position until one is assigned in a `before_create` callback, which runs after validation.
|
|
44
|
+
|
|
43
45
|
### Declaring Positioning
|
|
44
46
|
|
|
45
47
|
To declare that your model should keep track of the position of its records you can use the `positioned` method. Here are some examples:
|
|
@@ -134,6 +136,8 @@ To assign a specific position when creating or updating a record you can simply
|
|
|
134
136
|
|
|
135
137
|
Position parameters can be strings or symbols, so you can provide them from the browser.
|
|
136
138
|
|
|
139
|
+
If you define your own writer for the position column in your model (e.g. `position=`), call `super` to pass the value on to positioning.
|
|
140
|
+
|
|
137
141
|
Here are some examples:
|
|
138
142
|
|
|
139
143
|
##### Creating
|
|
@@ -280,10 +284,22 @@ If you must position child models separately, scope their position by the `type`
|
|
|
280
284
|
positioned on: [:type]
|
|
281
285
|
```
|
|
282
286
|
|
|
287
|
+
#### Callbacks
|
|
288
|
+
|
|
289
|
+
Positions are rearranged in `before_create` and `before_update` callbacks. Declare `positioned` after any `before_create` or `before_update` callbacks of your own that might halt the save (e.g. with `throw :abort`). Otherwise, if the save runs inside a transaction that's already open and your code carries on after `save` returns `false`, the outer transaction commits with the other records in the list already moved to make room for a change that didn't happen. Destroys don't need this, since the records after a destroyed record are only moved up once it has actually been destroyed.
|
|
290
|
+
|
|
291
|
+
Other records in the list are moved with `update_all`, so their callbacks don't run and their `updated_at` doesn't change. If you cache list items, consider `belongs_to :list, touch: true` so that the parent's `updated_at` (and so its cache key) changes whenever one of its items is saved or destroyed.
|
|
292
|
+
|
|
293
|
+
#### Bypassing Positioning
|
|
294
|
+
|
|
295
|
+
Positioning works through model callbacks, so anything that skips them skips positioning too: `insert_all`, `upsert_all`, `update_all`, `update_column(s)`, `delete`, `delete_all`, and associations declared with `dependent: :delete_all` or `dependent: :nullify`. (`dependent: :delete_all` on the scope's parent is fine, since the whole list gets deleted with it.) If you use these to change positions or scopes, heal the position column afterwards.
|
|
296
|
+
|
|
283
297
|
## Concurrency
|
|
284
298
|
|
|
285
299
|
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.
|
|
286
300
|
|
|
301
|
+
Locking a parent record takes a single row lock and works even when the list is empty, so where you can, scope on a `belongs_to` association. When we lock the records in a scope instead, there's nothing to lock in an empty list, so two transactions adding the first records to the same list at the same time can both find it empty and choose the same position. With a unique index on your scope and position columns, the second insert fails with `ActiveRecord::RecordNotUnique`. On MySQL, two transactions locking rows in the same list can also occasionally deadlock under heavy contention. MySQL then rolls back one of them and Rails raises `ActiveRecord::Deadlocked`. In either case you can retry the failed transaction. Note that `NULL` values never conflict in a unique index, so if part of your scope can be `NULL` (e.g. the root of a tree), index a generated column such as `coalesce(parent_id, 0)` instead.
|
|
302
|
+
|
|
287
303
|
**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.
|
|
288
304
|
|
|
289
305
|
If you have any concerns or improvements please file a GitHub issue.
|
data/lib/positioning/healer.rb
CHANGED
|
@@ -17,7 +17,6 @@ module Positioning
|
|
|
17
17
|
if associated_records.any?
|
|
18
18
|
associated_records.each(&:lock!)
|
|
19
19
|
else
|
|
20
|
-
# Relation#lock! only flags the relation, it doesn't run a query
|
|
21
20
|
@model.where(scope_record.slice(*scope_columns)).lock.ids
|
|
22
21
|
end
|
|
23
22
|
|
|
@@ -46,9 +45,6 @@ module Positioning
|
|
|
46
45
|
@model.primary_key
|
|
47
46
|
end
|
|
48
47
|
|
|
49
|
-
# Records are first parked on distinct values below anything currently in use (and below 0),
|
|
50
|
-
# then flipped into 1..n in one statement, so a unique index on the position column can't be
|
|
51
|
-
# violated part way through.
|
|
52
48
|
def sequence(scope)
|
|
53
49
|
ids = scope.reorder(@order).order(*Array(primary_key)).pluck(*Array(primary_key))
|
|
54
50
|
return if ids.empty?
|
|
@@ -6,17 +6,16 @@ module Positioning
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def prior
|
|
9
|
-
positioning_scope.where(@column => position - 1).first
|
|
9
|
+
positioning_scope.where(@column => position - 1).first unless position.nil?
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def subsequent
|
|
13
|
-
positioning_scope.where(@column => position + 1).first
|
|
13
|
+
positioning_scope.where(@column => position + 1).first unless position.nil?
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def create_position
|
|
17
17
|
lock_positioning_scope!
|
|
18
18
|
|
|
19
|
-
# A column default isn't a requested position. Only an assigned value is.
|
|
20
19
|
clear_position unless position_changed?
|
|
21
20
|
|
|
22
21
|
solidify_position
|
|
@@ -28,13 +27,16 @@ module Positioning
|
|
|
28
27
|
return unless positioning_scope_changed? || position_changed?
|
|
29
28
|
|
|
30
29
|
lock_positioning_scope!
|
|
30
|
+
position_was = self.position_was
|
|
31
31
|
|
|
32
32
|
clear_position if positioning_scope_changed? && !position_changed?
|
|
33
33
|
|
|
34
34
|
solidify_position
|
|
35
|
+
|
|
36
|
+
return forget_position_change if staying_put?
|
|
37
|
+
|
|
35
38
|
move_out_of_the_way
|
|
36
39
|
|
|
37
|
-
# A record without a position doesn't occupy a slot, so there's no gap to close
|
|
38
40
|
if position_was.nil?
|
|
39
41
|
expand(positioning_scope, position..)
|
|
40
42
|
elsif positioning_scope_changed?
|
|
@@ -48,9 +50,13 @@ module Positioning
|
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def destroy_position
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
return yield if destroyed_via_positioning_scope?
|
|
54
|
+
|
|
55
|
+
lock_positioning_scope!
|
|
56
|
+
position_was = self.position_was
|
|
57
|
+
positioning_scope = self.positioning_scope
|
|
53
58
|
|
|
59
|
+
if yield
|
|
54
60
|
move_out_of_the_way
|
|
55
61
|
contract(positioning_scope, (position_was + 1)..) unless position_was.nil?
|
|
56
62
|
end
|
|
@@ -62,27 +68,16 @@ module Positioning
|
|
|
62
68
|
@positioned.class.base_class
|
|
63
69
|
end
|
|
64
70
|
|
|
65
|
-
def with_connection
|
|
66
|
-
if base_class.respond_to? :with_connection
|
|
67
|
-
base_class.with_connection do |connection|
|
|
68
|
-
yield connection
|
|
69
|
-
end
|
|
70
|
-
else
|
|
71
|
-
yield base_class.connection
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
71
|
def primary_key
|
|
76
72
|
base_class.primary_key
|
|
77
73
|
end
|
|
78
74
|
|
|
79
75
|
def quoted_column
|
|
80
|
-
with_connection do |connection|
|
|
76
|
+
base_class.with_connection do |connection|
|
|
81
77
|
connection.quote_table_name_for_assignment base_class.table_name, @column
|
|
82
78
|
end
|
|
83
79
|
end
|
|
84
80
|
|
|
85
|
-
# The record's row as it is in the database, regardless of default scopes or in-memory key changes
|
|
86
81
|
def record_scope
|
|
87
82
|
base_class.unscoped.where primary_key => [@positioned.id_in_database]
|
|
88
83
|
end
|
|
@@ -109,12 +104,18 @@ module Positioning
|
|
|
109
104
|
@position_was = record_scope.pick(@column)
|
|
110
105
|
end
|
|
111
106
|
|
|
107
|
+
def staying_put?
|
|
108
|
+
!positioning_scope_changed? && position == position_was
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def forget_position_change
|
|
112
|
+
@positioned.clear_attribute_changes [@column]
|
|
113
|
+
end
|
|
114
|
+
|
|
112
115
|
def move_out_of_the_way
|
|
113
|
-
position_was # Memoize the original position before changing it
|
|
114
116
|
record_scope.update_all @column => 0
|
|
115
117
|
end
|
|
116
118
|
|
|
117
|
-
# Skip the second query if nothing moved, as MySQL would still lock the scope's first row to find no matches
|
|
118
119
|
def expand(scope, range)
|
|
119
120
|
if scope.where(@column => range).update_all("#{quoted_column} = #{quoted_column} * -1").positive?
|
|
120
121
|
scope.where(@column => ..-1).update_all "#{quoted_column} = #{quoted_column} * -1 + 1"
|
data/lib/positioning/version.rb
CHANGED
data/lib/positioning.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "positioning/healer"
|
|
|
4
4
|
|
|
5
5
|
require "active_support/concern"
|
|
6
6
|
require "active_support/lazy_load_hooks"
|
|
7
|
+
require "json"
|
|
7
8
|
|
|
8
9
|
module Positioning
|
|
9
10
|
class Error < StandardError; end
|
|
@@ -15,7 +16,29 @@ module Positioning
|
|
|
15
16
|
|
|
16
17
|
class_methods do
|
|
17
18
|
def positioning_columns
|
|
18
|
-
@positioning_columns
|
|
19
|
+
return @positioning_columns if @positioning_columns
|
|
20
|
+
|
|
21
|
+
resolved = true
|
|
22
|
+
|
|
23
|
+
scopes = (@positioning_scopes || {}).transform_values do |scope_components|
|
|
24
|
+
{scope_columns: [], scope_associations: []}.tap do |scope|
|
|
25
|
+
scope_components.each do |scope_component|
|
|
26
|
+
reflection = reflections[scope_component]
|
|
27
|
+
|
|
28
|
+
if reflection&.belongs_to?
|
|
29
|
+
scope[:scope_columns].concat Array(reflection.foreign_key)
|
|
30
|
+
scope[:scope_columns] << reflection.foreign_type if reflection.polymorphic?
|
|
31
|
+
scope[:scope_associations] << reflection.name
|
|
32
|
+
else
|
|
33
|
+
resolved &&= column_names.include?(scope_component)
|
|
34
|
+
scope[:scope_columns] << scope_component
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@positioning_columns = scopes if resolved
|
|
41
|
+
scopes
|
|
19
42
|
end
|
|
20
43
|
|
|
21
44
|
def positioned(on: [], column: :position)
|
|
@@ -24,36 +47,29 @@ module Positioning
|
|
|
24
47
|
end
|
|
25
48
|
|
|
26
49
|
column = column.to_sym
|
|
50
|
+
@positioning_scopes ||= {}
|
|
27
51
|
|
|
28
|
-
if
|
|
29
|
-
raise Error.new "The column `#{column}` has already been used by the scope `#{
|
|
52
|
+
if @positioning_scopes.key? column
|
|
53
|
+
raise Error.new "The column `#{column}` has already been used by the scope `#{@positioning_scopes[column]}`."
|
|
30
54
|
else
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Array.wrap(on).each do |scope_component|
|
|
34
|
-
scope_component = scope_component.to_s
|
|
35
|
-
reflection = reflections[scope_component]
|
|
36
|
-
|
|
37
|
-
if reflection&.belongs_to?
|
|
38
|
-
positioning_columns[column][:scope_columns].concat Array(reflection.foreign_key)
|
|
39
|
-
positioning_columns[column][:scope_columns] << reflection.foreign_type if reflection.polymorphic?
|
|
40
|
-
positioning_columns[column][:scope_associations] << reflection.name
|
|
41
|
-
else
|
|
42
|
-
positioning_columns[column][:scope_columns] << scope_component
|
|
43
|
-
end
|
|
44
|
-
end
|
|
55
|
+
@positioning_scopes[column] = Array.wrap(on).map(&:to_s)
|
|
56
|
+
@positioning_columns = nil
|
|
45
57
|
|
|
46
58
|
define_method(:"prior_#{column}") { Mechanisms.new(self, column).prior }
|
|
47
59
|
define_method(:"subsequent_#{column}") { Mechanisms.new(self, column).subsequent }
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
writer = Module.new do
|
|
62
|
+
define_method(:"#{column}=") do |position|
|
|
63
|
+
attribute_will_change!(column.to_s)
|
|
64
|
+
super(position)
|
|
65
|
+
end
|
|
52
66
|
end
|
|
53
67
|
|
|
68
|
+
include writer
|
|
69
|
+
|
|
54
70
|
before_create { Mechanisms.new(self, column).create_position }
|
|
55
71
|
before_update { Mechanisms.new(self, column).update_position }
|
|
56
|
-
|
|
72
|
+
around_destroy { |positioned, block| Mechanisms.new(positioned, column).destroy_position(&block) }
|
|
57
73
|
|
|
58
74
|
define_singleton_method(:"heal_#{column}_column!") do |order = column|
|
|
59
75
|
Healer.new(base_class, column, order).heal
|
data/positioning.gemspec
CHANGED
|
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.summary = "Simple positioning for Active Record models."
|
|
10
10
|
spec.homepage = "https://github.com/brendon/positioning"
|
|
11
11
|
spec.license = "MIT"
|
|
12
|
-
spec.required_ruby_version = ">= 3.
|
|
12
|
+
spec.required_ruby_version = ">= 3.3.0"
|
|
13
13
|
|
|
14
14
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
15
15
|
spec.metadata["source_code_uri"] = "https://github.com/brendon/positioning"
|
|
@@ -28,8 +28,8 @@ Gem::Specification.new do |spec|
|
|
|
28
28
|
spec.require_paths = ["lib"]
|
|
29
29
|
|
|
30
30
|
# Uncomment to register a new dependency of your gem
|
|
31
|
-
spec.add_dependency "activesupport", ">=
|
|
32
|
-
spec.add_dependency "activerecord", ">=
|
|
31
|
+
spec.add_dependency "activesupport", ">= 7.2"
|
|
32
|
+
spec.add_dependency "activerecord", ">= 7.2"
|
|
33
33
|
|
|
34
34
|
# For more information and examples about making a new gem, check out our
|
|
35
35
|
# guide at: https://bundler.io/guides/creating_gem.html
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: positioning
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brendon Muir
|
|
@@ -15,28 +15,28 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '7.2'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
25
|
+
version: '7.2'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: activerecord
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '7.2'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
39
|
+
version: '7.2'
|
|
40
40
|
email:
|
|
41
41
|
- brendon@spike.net.nz
|
|
42
42
|
executables: []
|
|
@@ -70,7 +70,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
70
70
|
requirements:
|
|
71
71
|
- - ">="
|
|
72
72
|
- !ruby/object:Gem::Version
|
|
73
|
-
version: 3.
|
|
73
|
+
version: 3.3.0
|
|
74
74
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
75
|
requirements:
|
|
76
76
|
- - ">="
|