positioning 0.2.5 → 0.3.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 +34 -0
- data/lib/positioning/advisory_lock.rb +17 -17
- data/lib/positioning/healer.rb +31 -0
- data/lib/positioning/mechanisms.rb +7 -7
- data/lib/positioning/version.rb +1 -1
- data/lib/positioning.rb +20 -9
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2b3dd60b08d2b1488b5fd6c88681f83cd37b46dd2eb0f27557ca4fb131928c9b
|
4
|
+
data.tar.gz: 442d3030455cadbcaa2e4fe0f97bc4631d9081194aff31c67242ec5675bc5f8e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2c199c7f7d79ca57833593e3bfbe2e47161875943aabef8247c9cabb7b35e5c22297b6b69f298fc2c3df8ee0e9c5f1d99dc6c41e8e90b979cddd205c2d4b91b6
|
7
|
+
data.tar.gz: f6e070723c2178c5b7e4907e43ae8fcf2a9f50611b37179fc618ffa220205382f01f8aaa907a0a0cea1bd2aa0e14baf623401678bebc5e7b5ccbc80f2f3f31eb
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,14 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.3.0] - 2024-08-21
|
4
|
+
|
5
|
+
- POSSIBLY BREAKING: Clear all position columns on a duplicate created with `dup`.
|
6
|
+
|
7
|
+
## [0.2.6] - 2024-08-21
|
8
|
+
|
9
|
+
- Implement list healing so that existing lists can be fixed up when implementing `positioned` or if the list somehow gets corrupted.
|
10
|
+
- Tidy up Advisory Lock code.
|
11
|
+
|
3
12
|
## [0.2.5] - 2024-08-10
|
4
13
|
|
5
14
|
- Implemented composite primary key support. Thanks @jackozi for the original PR and the nudge to get this done!
|
data/README.md
CHANGED
@@ -72,6 +72,36 @@ belongs_to :list
|
|
72
72
|
positioned on: :list, advisory_lock: false
|
73
73
|
```
|
74
74
|
|
75
|
+
### Initialising a List
|
76
|
+
|
77
|
+
If you are adding `positioning` to a model with existing database records, or you're migrating from another gem like `acts_as_list` or `ranked-model` and have an existing position column, you will need to do some work to ensure you have well formed position values for your records. `positioning` has a helper method per `positioned` declaration that allows you to 'heal' the position column, ensuring that positions are positive integers starting at 1 with no gaps.
|
78
|
+
|
79
|
+
For example, in the usual case:
|
80
|
+
|
81
|
+
```
|
82
|
+
belongs_to :list
|
83
|
+
positioned on: :list
|
84
|
+
```
|
85
|
+
|
86
|
+
you'll have a method called `heal_position_column!`. You can call this method and it will cycle through every existing scope combination in your database (every list with items in this case) and reset those items' position based on their current position order by default. You can pass in a custom order if you don't trust (or don't have) an existing order column. The custom order is passed through to the Active Record `reorder` method, so you can provide anything that that method accepts:
|
87
|
+
|
88
|
+
```
|
89
|
+
Item.heal_position_column! name: :desc
|
90
|
+
```
|
91
|
+
|
92
|
+
You may need to introduce your database constraints after healing your position column:
|
93
|
+
|
94
|
+
* We recommend a `null: false` constraint on the position column but if your existing column has `NULL` values, you'll need to fix those first. The heal method will heal `NULL` positions but depending on your database engine `NULL` positioned items might be placed at the start of the returned records or at the end (if positioning on the position column). Some databases allow this behaviour to be customised.
|
95
|
+
* We also recommend a unique index on the scope columns and the position column. If you have repeated position integers per scope you'll need to use the heal method to fix these first before applying the unique index in a separate migration step.
|
96
|
+
|
97
|
+
The heal method name is named after the column used to store position values. By default this is `position` but if you override it then the method name will change:
|
98
|
+
|
99
|
+
```
|
100
|
+
positioned on: :category, column: :category_position
|
101
|
+
```
|
102
|
+
|
103
|
+
will have a class method named `heal_category_position_column!`.
|
104
|
+
|
75
105
|
### Manipulating Positioning
|
76
106
|
|
77
107
|
The tools for manipulating the position of records in your list have been kept intentionally terse. Priority has also been given to minimal pollution of the model namespace. Only two class methods are defined on all models (`positioning_columns` and `positioned`), and two instance methods are defined on models that call `positioned`:
|
@@ -155,6 +185,10 @@ other_item.id # => 11
|
|
155
185
|
item.update position: {after: 11}
|
156
186
|
```
|
157
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
|
+
|
158
192
|
##### Relative Positioning in Forms
|
159
193
|
|
160
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.
|
@@ -3,23 +3,24 @@ require "openssl"
|
|
3
3
|
|
4
4
|
module Positioning
|
5
5
|
class AdvisoryLock
|
6
|
-
Adapter = Struct.new(:initialise, :
|
6
|
+
Adapter = Struct.new(:initialise, :acquire, :release, keyword_init: true)
|
7
7
|
|
8
8
|
attr_reader :base_class
|
9
9
|
|
10
|
-
def initialize(base_class, column)
|
10
|
+
def initialize(base_class, column, enabled)
|
11
11
|
@base_class = base_class
|
12
12
|
@column = column.to_s
|
13
|
+
@enabled = enabled
|
13
14
|
|
14
15
|
@adapters = {
|
15
16
|
"mysql2" => Adapter.new(
|
16
17
|
initialise: -> {},
|
17
|
-
|
18
|
+
acquire: -> { connection.execute "SELECT GET_LOCK(#{connection.quote(lock_name)}, -1)" },
|
18
19
|
release: -> { connection.execute "SELECT RELEASE_LOCK(#{connection.quote(lock_name)})" }
|
19
20
|
),
|
20
21
|
"postgresql" => Adapter.new(
|
21
22
|
initialise: -> {},
|
22
|
-
|
23
|
+
acquire: -> { connection.execute "SELECT pg_advisory_lock(#{lock_name.hex & 0x7FFFFFFFFFFFFFFF})" },
|
23
24
|
release: -> { connection.execute "SELECT pg_advisory_unlock(#{lock_name.hex & 0x7FFFFFFFFFFFFFFF})" }
|
24
25
|
),
|
25
26
|
"sqlite3" => Adapter.new(
|
@@ -28,7 +29,7 @@ module Positioning
|
|
28
29
|
filename = "#{Dir.pwd}/tmp/#{lock_name}.lock"
|
29
30
|
@file ||= File.open filename, File::RDWR | File::CREAT, 0o644
|
30
31
|
},
|
31
|
-
|
32
|
+
acquire: -> {
|
32
33
|
@file.flock File::LOCK_EX
|
33
34
|
},
|
34
35
|
release: -> {
|
@@ -37,24 +38,23 @@ module Positioning
|
|
37
38
|
)
|
38
39
|
}
|
39
40
|
|
40
|
-
@adapters.default = Adapter.new(initialise: -> {},
|
41
|
+
@adapters.default = Adapter.new(initialise: -> {}, acquire: -> {}, release: -> {})
|
41
42
|
|
42
|
-
adapter.initialise.call
|
43
|
+
adapter.initialise.call if @enabled
|
43
44
|
end
|
44
45
|
|
45
|
-
def
|
46
|
-
adapter.
|
47
|
-
end
|
46
|
+
def acquire
|
47
|
+
adapter.acquire.call if @enabled
|
48
48
|
|
49
|
-
|
50
|
-
|
49
|
+
if block_given?
|
50
|
+
yield
|
51
|
+
adapter.release.call if @enabled
|
52
|
+
end
|
51
53
|
end
|
52
54
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
alias_method :after_commit, :release
|
57
|
-
alias_method :after_rollback, :release
|
55
|
+
def release
|
56
|
+
adapter.release.call if @enabled
|
57
|
+
end
|
58
58
|
|
59
59
|
private
|
60
60
|
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Positioning
|
2
|
+
class Healer
|
3
|
+
def initialize(model, column, order)
|
4
|
+
@model = model
|
5
|
+
@column = column.to_sym
|
6
|
+
@order = order
|
7
|
+
end
|
8
|
+
|
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))
|
13
|
+
end
|
14
|
+
else
|
15
|
+
sequence @model
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def positioning_columns
|
22
|
+
@model.positioning_columns[@column]
|
23
|
+
end
|
24
|
+
|
25
|
+
def sequence(scope)
|
26
|
+
scope.reorder(@order).each.with_index(1) do |record, index|
|
27
|
+
record.update_columns @column => index
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -6,11 +6,11 @@ module Positioning
|
|
6
6
|
end
|
7
7
|
|
8
8
|
def prior
|
9
|
-
positioning_scope.where(
|
9
|
+
positioning_scope.where(@column => position - 1).first
|
10
10
|
end
|
11
11
|
|
12
12
|
def subsequent
|
13
|
-
positioning_scope.where(
|
13
|
+
positioning_scope.where(@column => position + 1).first
|
14
14
|
end
|
15
15
|
|
16
16
|
def create_position
|
@@ -84,17 +84,17 @@ module Positioning
|
|
84
84
|
|
85
85
|
def move_out_of_the_way
|
86
86
|
position_was # Memoize the original position before changing it
|
87
|
-
record_scope.update_all
|
87
|
+
record_scope.update_all @column => 0
|
88
88
|
end
|
89
89
|
|
90
90
|
def expand(scope, range)
|
91
|
-
scope.where(
|
92
|
-
scope.where(
|
91
|
+
scope.where(@column => range).update_all "#{quoted_column} = #{quoted_column} * -1"
|
92
|
+
scope.where(@column => ..-1).update_all "#{quoted_column} = #{quoted_column} * -1 + 1"
|
93
93
|
end
|
94
94
|
|
95
95
|
def contract(scope, range)
|
96
|
-
scope.where(
|
97
|
-
scope.where(
|
96
|
+
scope.where(@column => range).update_all "#{quoted_column} = #{quoted_column} * -1"
|
97
|
+
scope.where(@column => ..-1).update_all "#{quoted_column} = #{quoted_column} * -1 - 1"
|
98
98
|
end
|
99
99
|
|
100
100
|
def solidify_position
|
data/lib/positioning/version.rb
CHANGED
data/lib/positioning.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
require_relative "positioning/version"
|
2
2
|
require_relative "positioning/advisory_lock"
|
3
3
|
require_relative "positioning/mechanisms"
|
4
|
+
require_relative "positioning/healer"
|
4
5
|
|
5
6
|
require "active_support/concern"
|
6
7
|
require "active_support/lazy_load_hooks"
|
@@ -43,25 +44,35 @@ module Positioning
|
|
43
44
|
super(position)
|
44
45
|
end
|
45
46
|
|
46
|
-
|
47
|
-
advisory_lock_callback = AdvisoryLock.new(base_class, column)
|
47
|
+
advisory_locker = AdvisoryLock.new(base_class, column, advisory_lock)
|
48
48
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
end
|
49
|
+
before_create { advisory_locker.acquire }
|
50
|
+
before_update { advisory_locker.acquire }
|
51
|
+
before_destroy { advisory_locker.acquire }
|
53
52
|
|
54
53
|
before_create { Mechanisms.new(self, column).create_position }
|
55
54
|
before_update { Mechanisms.new(self, column).update_position }
|
56
55
|
before_destroy { Mechanisms.new(self, column).destroy_position }
|
57
56
|
|
58
|
-
|
59
|
-
|
60
|
-
|
57
|
+
after_commit { advisory_locker.release }
|
58
|
+
after_rollback { advisory_locker.release }
|
59
|
+
|
60
|
+
define_singleton_method(:"heal_#{column}_column!") do |order = column|
|
61
|
+
advisory_locker.acquire do
|
62
|
+
Healer.new(self, column, order).heal
|
63
|
+
end
|
61
64
|
end
|
62
65
|
end
|
63
66
|
end
|
64
67
|
end
|
68
|
+
|
69
|
+
def initialize_dup(other)
|
70
|
+
super
|
71
|
+
|
72
|
+
self.class.positioning_columns.keys.each do |positioning_column|
|
73
|
+
send :"#{positioning_column}=", nil
|
74
|
+
end
|
75
|
+
end
|
65
76
|
end
|
66
77
|
end
|
67
78
|
|
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.3.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-10-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -123,6 +123,7 @@ files:
|
|
123
123
|
- Rakefile
|
124
124
|
- lib/positioning.rb
|
125
125
|
- lib/positioning/advisory_lock.rb
|
126
|
+
- lib/positioning/healer.rb
|
126
127
|
- lib/positioning/mechanisms.rb
|
127
128
|
- lib/positioning/version.rb
|
128
129
|
- positioning.gemspec
|