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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecb51b68f80bde257ed294934fd122a1fabff3acb3438767253afb614664d318
4
- data.tar.gz: c688039c633220e0afb1b640375fed1dffaf312cd5e418eaa62fb4c5b60b0779
3
+ metadata.gz: 7677909862f7df18c0550419eeb1998e35fbf50cc0a9d6635e4d06edce088ea6
4
+ data.tar.gz: f5a9a1b51f11f1dd60433cb6c29e651ce29ad80cd2babaed4e4b01cde1af253d
5
5
  SHA512:
6
- metadata.gz: c588024c1fb254446fd782d03c04c57363bf49cb85911ecee07d525797b31a40b7f0b7389f71a372dcd23e084f234dfdb4d613242e5f88dc3ba382abf882f028
7
- data.tar.gz: 640dd4e8e75db84a9053e54f57066ea4ab5f80216b84e5754b953851056f9409d0dc979f3d6a7038245e05b35b0f1e390153e5567aab6fe9f4a10fbde964e475
6
+ metadata.gz: 8ad652e8ba47212719b599cee9209237a9defe9dea61b6364b8c5f501541fb17dea37e2d6476c13aa1846ee5a086dc842135f15899bc62f13acd67e874726dfc
7
+ data.tar.gz: 33da3275bb9b4bdceccf5c2092a8523cd9813eb3b4ef542acc801aacb4df51946f35dc83f124a2026a43bae6399c76e1b5c081806c31fdf635c1fd5d7ee96378
data/.standard.yml CHANGED
@@ -1,3 +1,3 @@
1
1
  # For available configuration options, see:
2
2
  # https://github.com/testdouble/standard
3
- ruby_version: 3.0
3
+ ruby_version: 3.3
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.
@@ -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
- unless destroyed_via_positioning_scope?
52
- lock_positioning_scope!
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"
@@ -1,3 +1,3 @@
1
1
  module Positioning
2
- VERSION = "0.4.10"
2
+ VERSION = "0.5.0"
3
3
  end
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 positioning_columns.key? column
29
- raise Error.new "The column `#{column}` has already been used by the scope `#{positioning_columns[column]}`."
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
- positioning_columns[column] = {scope_columns: [], scope_associations: []}
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
- redefine_method(:"#{column}=") do |position|
50
- send :"#{column}_will_change!"
51
- super(position)
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
- before_destroy { Mechanisms.new(self, column).destroy_position }
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.0.0"
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", ">= 6.1"
32
- spec.add_dependency "activerecord", ">= 6.1"
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.10
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: '6.1'
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: '6.1'
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: '6.1'
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: '6.1'
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.0.0
73
+ version: 3.3.0
74
74
  required_rubygems_version: !ruby/object:Gem::Requirement
75
75
  requirements:
76
76
  - - ">="