positioning 0.1.6 → 0.2.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 +10 -0
- data/README.md +64 -8
- data/lib/positioning/advisory_lock.rb +82 -0
- data/lib/positioning/version.rb +1 -1
- data/lib/positioning.rb +41 -27
- 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: ea25e9b2c25e0268da3078ed8b9d9d992aa4d4e67020f5ae317e2cdf9fa5fd10
|
4
|
+
data.tar.gz: ac3996e32d7b6936a9d97d486bd63cab73c371eed718bec034bb420c262f36c1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2f0fd43a324b0629b689658b26ecec629e8103823c2a275ee24c303c2583a91b09e9599a09d5759e5236ce9cc888e7320bff5c04771ab60b2c697340bbbf1b41
|
7
|
+
data.tar.gz: b752f5801180797144f8bce5ea5437495769d95522fc67213a89d953ca71fd4212c4ff08240b42c30d554d847fa56611cf84e743869d682a1d52276fec074ae6
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.2.0] - 2024-03-12
|
4
|
+
|
5
|
+
- Add an Advisory Lock to ensure isolation for the entirety of the create, update, and destroy cycles.
|
6
|
+
- Add SQLite Advisory Lock support using a file lock.
|
7
|
+
|
8
|
+
## [0.1.7] - 2024-03-06
|
9
|
+
|
10
|
+
- Seperated the Concern that is included into ActiveRecord::Base into its own submodule so that Mechanisms isn't also included.
|
11
|
+
- Added the RelativePosition Struct and documentation to make it easier to supply relative positions via form helpers.
|
12
|
+
|
3
13
|
## [0.1.6] - 2024-03-05
|
4
14
|
|
5
15
|
- Allow the position to be passed as a JSON object so that we can pass in complex positions from the browser more easily.
|
data/README.md
CHANGED
@@ -39,11 +39,11 @@ The Positioning gem uses `0` and negative integers to rearrange the lists it man
|
|
39
39
|
To declare that your model should keep track of the position of its records you can use the `positioned` method. Here are some examples:
|
40
40
|
|
41
41
|
```ruby
|
42
|
-
# The scope is global (all records will belong to the same list) and the
|
42
|
+
# The scope is global (all records will belong to the same list) and the database column
|
43
43
|
# is 'positioned'
|
44
44
|
positioned
|
45
45
|
|
46
|
-
# The scope is on the belongs_to relationship 'list' and the
|
46
|
+
# The scope is on the belongs_to relationship 'list' and the database column is 'positioned'
|
47
47
|
# We check if the scope is a belongs_to relationship and use its declared foreign_key as
|
48
48
|
# the scope value. In this case it would be 'list_id' since we haven't overridden the
|
49
49
|
# default foreign key.
|
@@ -82,11 +82,13 @@ If you don't provide a position when creating a record, your record will be adde
|
|
82
82
|
|
83
83
|
To assign a specific position when creating or updating a record you can simply declare a specific value for the database column tracking the position of records (by default this is `position`). The valid options for this column are:
|
84
84
|
|
85
|
-
* A specific integer value
|
86
|
-
* `:first` places the record at the start of the list.
|
87
|
-
* `:last` places the record at the end of the list.
|
88
|
-
* `nil` also places the record at the end of the list.
|
89
|
-
* `before:` and `after:` allow you to define the position relative to other records in the list. You can define the relative record by its primary key (usually `id`) or by providing the record itself. You can also provide `nil` in which case the item will be placed at the start or end of the list (see below).
|
85
|
+
* A specific integer value as an `Integer` or a `String`. Values are automatically clamped to between `1` and the next available position at the end of the list (inclusive). You should use explicit position values as a last resort, instead you can use:
|
86
|
+
* `:first` or `"first"` places the record at the start of the list.
|
87
|
+
* `:last` or `"last"` places the record at the end of the list.
|
88
|
+
* `nil` and `""` also places the record at the end of the list.
|
89
|
+
* `before:` and `after:` allow you to define the position relative to other records in the list. You can define the relative record by its primary key (usually `id`) or by providing the record itself. You can also provide `nil` or `""` in which case the item will be placed at the start or end of the list (see below).
|
90
|
+
|
91
|
+
**You can provide the position value as a JSON string and it will be decoded first. This could be useful if you have no other way to provide `before:` or `after:` as a hash (e.g. `"{\"after\":33}"`). See below for a technique to provide `before:` and `after:` using form helpers.**
|
90
92
|
|
91
93
|
Position parameters can be strings or symbols, so you can provide them from the browser.
|
92
94
|
|
@@ -149,6 +151,42 @@ other_item.id # => 11
|
|
149
151
|
item.update position: {after: 11}
|
150
152
|
```
|
151
153
|
|
154
|
+
##### Relative Positioning in Forms
|
155
|
+
|
156
|
+
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.
|
157
|
+
|
158
|
+
Firstly you need to allow both scalar and nested Strong Parameters for the `position` column like so:
|
159
|
+
|
160
|
+
```ruby
|
161
|
+
def item_params
|
162
|
+
params.require(:item).permit :name, :position, { position: :before }
|
163
|
+
end
|
164
|
+
```
|
165
|
+
|
166
|
+
In the example above we're always declaring what item (by its `id`) we want to position our item **before**. You could change this to `:after` if you'd rather.
|
167
|
+
|
168
|
+
Next, in your `new` method you may wish to initialise the `position` column with a value supplied by incoming parameters:
|
169
|
+
|
170
|
+
```ruby
|
171
|
+
def new
|
172
|
+
item.position = { before: params[:before] }
|
173
|
+
end
|
174
|
+
```
|
175
|
+
|
176
|
+
You can now just pass the `before` parameter (the `id` of the item you want to add this record before) via the URL to the `new` action. For example: `items/new?before=22`.
|
177
|
+
|
178
|
+
In the form itself, so that your intended position survives a failed `create` attempt and form redisplay you can declare the `position` value like so:
|
179
|
+
|
180
|
+
```
|
181
|
+
<% if item.new_record? %>
|
182
|
+
<%= form.fields :position, model: Positioning::RelativePosition.new(item.position_before_type_cast) do |fields| %>
|
183
|
+
<%= fields.hidden_field :before %>
|
184
|
+
<% end %>
|
185
|
+
<% end %>
|
186
|
+
```
|
187
|
+
|
188
|
+
The key part here is `Positioning::RelativePosition.new(item.position_before_type_cast)`. `Positioning::RelativePosition` is a `Struct` that can take `before` and `after` as parameters. You should only provide one or the other. Because `position` is an `Integer` column, the hash structure is obliterated when it is assigned but we can still access it with `position_before_type_cast`. Remember to adjust the method if your position column has a different name (e.g. `category_position_before_type_cast`). The `Struct` provides the correct methods for `fields` to display the nested value.
|
189
|
+
|
152
190
|
#### Destroying
|
153
191
|
|
154
192
|
When a record is destroyed, the positions of relative items in the scope will be shuffled to close the gap left by the destroyed record. If we detect that records are being destroyed via a scope dependency (e.g. `has_many :items, dependent: :destroy`) then we skip closing the gaps because all records in the scope will eventually be destroyed anyway.
|
@@ -187,13 +225,31 @@ item.update list: other_list, position: {after: 11}
|
|
187
225
|
|
188
226
|
It's important to note that in the examples above, `other_item` must already belong to the `other_list` scope.
|
189
227
|
|
228
|
+
## Concurrency
|
229
|
+
|
230
|
+
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.
|
231
|
+
|
232
|
+
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:
|
233
|
+
|
234
|
+
```yaml
|
235
|
+
default_transaction_mode: EXCLUSIVE
|
236
|
+
```
|
237
|
+
|
238
|
+
You may also want to try `IMMEDIATE` as a less aggressive alternative.
|
239
|
+
|
240
|
+
You're encouraged to review the Advisory Lock code to ensure it fits with your environment:
|
241
|
+
|
242
|
+
https://github.com/brendon/positioning/blob/main/lib/positioning/advisory_lock.rb
|
243
|
+
|
244
|
+
If you have any concerns or improvements please file a GitHub issue.
|
245
|
+
|
190
246
|
## Development
|
191
247
|
|
192
248
|
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.
|
193
249
|
|
194
250
|
This gem is tested against SQLite, PostgreSQL and MySQL. The default database for testing is MySQL. You can target other databases by prepending the environment variable `DB=sqlite` or `DB=postgresql` before `rake test`. For example: `DB=sqlite rake test`.
|
195
251
|
|
196
|
-
The PostgreSQL and MySQL environments are configured under `test/support/database.yml`. You can edit this file, or
|
252
|
+
The PostgreSQL and MySQL environments are configured under `test/support/database.yml`. You can edit this file, or preferably adjust your environment to support password-less socket based connections to these two database engines. You'll also need to manually create a database named `positioning_test` in each.
|
197
253
|
|
198
254
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
199
255
|
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require "fileutils"
|
2
|
+
require "openssl"
|
3
|
+
|
4
|
+
module Positioning
|
5
|
+
class AdvisoryLock
|
6
|
+
Adapter = Struct.new(:initialise, :aquire, :release, keyword_init: true)
|
7
|
+
|
8
|
+
attr_reader :base_class
|
9
|
+
|
10
|
+
def initialize(base_class, column)
|
11
|
+
@base_class = base_class
|
12
|
+
@column = column.to_s
|
13
|
+
|
14
|
+
@adapters = {
|
15
|
+
"Mysql2" => Adapter.new(
|
16
|
+
initialise: -> {},
|
17
|
+
aquire: -> { connection.execute "SELECT GET_LOCK(#{connection.quote(lock_name)}, -1)" },
|
18
|
+
release: -> { connection.execute "SELECT RELEASE_LOCK(#{connection.quote(lock_name)})" }
|
19
|
+
),
|
20
|
+
"PostgreSQL" => Adapter.new(
|
21
|
+
initialise: -> {},
|
22
|
+
aquire: -> { connection.execute "SELECT pg_advisory_lock(#{lock_name.hex & 0x7FFFFFFFFFFFFFFF})" },
|
23
|
+
release: -> { connection.execute "SELECT pg_advisory_unlock(#{lock_name.hex & 0x7FFFFFFFFFFFFFFF})" }
|
24
|
+
),
|
25
|
+
"SQLite" => Adapter.new(
|
26
|
+
initialise: -> {
|
27
|
+
FileUtils.mkdir_p "#{Dir.pwd}/tmp"
|
28
|
+
filename = "#{Dir.pwd}/tmp/#{lock_name}.lock"
|
29
|
+
@file ||= File.open filename, File::RDWR | File::CREAT, 0o644
|
30
|
+
},
|
31
|
+
aquire: -> {
|
32
|
+
@file.flock File::LOCK_EX
|
33
|
+
},
|
34
|
+
release: -> {
|
35
|
+
@file.flock File::LOCK_UN
|
36
|
+
}
|
37
|
+
)
|
38
|
+
}
|
39
|
+
|
40
|
+
@adapters.default = Adapter.new(initialise: -> {}, aquire: -> {}, release: -> {})
|
41
|
+
|
42
|
+
adapter.initialise.call
|
43
|
+
end
|
44
|
+
|
45
|
+
def aquire(record)
|
46
|
+
adapter.aquire.call
|
47
|
+
end
|
48
|
+
|
49
|
+
def release(record)
|
50
|
+
adapter.release.call
|
51
|
+
end
|
52
|
+
|
53
|
+
alias_method :before_create, :aquire
|
54
|
+
alias_method :before_update, :aquire
|
55
|
+
alias_method :before_destroy, :aquire
|
56
|
+
alias_method :after_commit, :release
|
57
|
+
alias_method :after_rollback, :release
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def connection
|
62
|
+
base_class.connection
|
63
|
+
end
|
64
|
+
|
65
|
+
def adapter_name
|
66
|
+
connection.adapter_name
|
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
|
data/lib/positioning/version.rb
CHANGED
data/lib/positioning.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require_relative "positioning/version"
|
2
|
+
require_relative "positioning/advisory_lock"
|
2
3
|
require_relative "positioning/mechanisms"
|
3
4
|
|
4
5
|
require "active_support/concern"
|
@@ -7,46 +8,59 @@ require "active_support/lazy_load_hooks"
|
|
7
8
|
module Positioning
|
8
9
|
class Error < StandardError; end
|
9
10
|
|
10
|
-
|
11
|
+
RelativePosition = Struct.new(:before, :after, keyword_init: true)
|
11
12
|
|
12
|
-
|
13
|
-
|
14
|
-
@positioning_columns ||= {}
|
15
|
-
end
|
13
|
+
module Behaviour
|
14
|
+
extend ActiveSupport::Concern
|
16
15
|
|
17
|
-
|
18
|
-
|
19
|
-
|
16
|
+
class_methods do
|
17
|
+
def positioning_columns
|
18
|
+
@positioning_columns ||= {}
|
20
19
|
end
|
21
20
|
|
22
|
-
|
21
|
+
def positioned(on: [], column: :position)
|
22
|
+
unless base_class?
|
23
|
+
raise Error.new "can't be called on an abstract class or STI subclass."
|
24
|
+
end
|
23
25
|
|
24
|
-
|
25
|
-
raise Error.new "The column `#{column}` has already been used by the scope `#{positioning_columns[column]}`."
|
26
|
-
else
|
27
|
-
positioning_columns[column] = Array.wrap(on).map do |scope_component|
|
28
|
-
scope_component = scope_component.to_s
|
29
|
-
reflection = reflections[scope_component]
|
26
|
+
column = column.to_sym
|
30
27
|
|
31
|
-
|
32
|
-
|
28
|
+
if positioning_columns.key? column
|
29
|
+
raise Error.new "The column `#{column}` has already been used by the scope `#{positioning_columns[column]}`."
|
30
|
+
else
|
31
|
+
positioning_columns[column] = Array.wrap(on).map do |scope_component|
|
32
|
+
scope_component = scope_component.to_s
|
33
|
+
reflection = reflections[scope_component]
|
33
34
|
|
34
|
-
|
35
|
-
|
35
|
+
(reflection && reflection.belongs_to?) ? reflection.foreign_key : scope_component
|
36
|
+
end
|
36
37
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
38
|
+
define_method(:"prior_#{column}") { Mechanisms.new(self, column).prior }
|
39
|
+
define_method(:"subsequent_#{column}") { Mechanisms.new(self, column).subsequent }
|
40
|
+
|
41
|
+
redefine_method(:"#{column}=") do |position|
|
42
|
+
send :"#{column}_will_change!"
|
43
|
+
super(position)
|
44
|
+
end
|
41
45
|
|
42
|
-
|
43
|
-
|
44
|
-
|
46
|
+
advisory_lock = AdvisoryLock.new(base_class, column)
|
47
|
+
|
48
|
+
before_create advisory_lock
|
49
|
+
before_update advisory_lock
|
50
|
+
before_destroy advisory_lock
|
51
|
+
|
52
|
+
before_create { Mechanisms.new(self, column).create_position }
|
53
|
+
before_update { Mechanisms.new(self, column).update_position }
|
54
|
+
after_destroy { Mechanisms.new(self, column).destroy_position }
|
55
|
+
|
56
|
+
after_commit advisory_lock
|
57
|
+
after_rollback advisory_lock
|
58
|
+
end
|
45
59
|
end
|
46
60
|
end
|
47
61
|
end
|
48
62
|
end
|
49
63
|
|
50
64
|
ActiveSupport.on_load :active_record do
|
51
|
-
ActiveRecord::Base.send :include, Positioning
|
65
|
+
ActiveRecord::Base.send :include, Positioning::Behaviour
|
52
66
|
end
|
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.2.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-03-
|
11
|
+
date: 2024-03-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -122,6 +122,7 @@ files:
|
|
122
122
|
- README.md
|
123
123
|
- Rakefile
|
124
124
|
- lib/positioning.rb
|
125
|
+
- lib/positioning/advisory_lock.rb
|
125
126
|
- lib/positioning/mechanisms.rb
|
126
127
|
- lib/positioning/version.rb
|
127
128
|
- positioning.gemspec
|