positioning 0.1.7 → 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 +5 -0
- data/README.md +27 -9
- data/lib/positioning/advisory_lock.rb +82 -0
- data/lib/positioning/version.rb +1 -1
- data/lib/positioning.rb +10 -0
- 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,10 @@
|
|
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
|
+
|
3
8
|
## [0.1.7] - 2024-03-06
|
4
9
|
|
5
10
|
- Seperated the Concern that is included into ActiveRecord::Base into its own submodule so that Mechanisms isn't also included.
|
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.
|
@@ -151,21 +151,21 @@ other_item.id # => 11
|
|
151
151
|
item.update position: {after: 11}
|
152
152
|
```
|
153
153
|
|
154
|
-
##### Relative
|
154
|
+
##### Relative Positioning in Forms
|
155
155
|
|
156
|
-
It can be tricky to provide the hash forms of relative
|
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
157
|
|
158
|
-
Firstly you need to allow nested Strong Parameters for the `position` column like so:
|
158
|
+
Firstly you need to allow both scalar and nested Strong Parameters for the `position` column like so:
|
159
159
|
|
160
160
|
```ruby
|
161
161
|
def item_params
|
162
|
-
params.require(:item).permit :name, position:
|
162
|
+
params.require(:item).permit :name, :position, { position: :before }
|
163
163
|
end
|
164
164
|
```
|
165
165
|
|
166
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
167
|
|
168
|
-
Next, in your `new` method you may wish to
|
168
|
+
Next, in your `new` method you may wish to initialise the `position` column with a value supplied by incoming parameters:
|
169
169
|
|
170
170
|
```ruby
|
171
171
|
def new
|
@@ -177,7 +177,7 @@ You can now just pass the `before` parameter (the `id` of the item you want to a
|
|
177
177
|
|
178
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
179
|
|
180
|
-
```
|
180
|
+
```
|
181
181
|
<% if item.new_record? %>
|
182
182
|
<%= form.fields :position, model: Positioning::RelativePosition.new(item.position_before_type_cast) do |fields| %>
|
183
183
|
<%= fields.hidden_field :before %>
|
@@ -225,13 +225,31 @@ item.update list: other_list, position: {after: 11}
|
|
225
225
|
|
226
226
|
It's important to note that in the examples above, `other_item` must already belong to the `other_list` scope.
|
227
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
|
+
|
228
246
|
## Development
|
229
247
|
|
230
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.
|
231
249
|
|
232
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`.
|
233
251
|
|
234
|
-
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.
|
235
253
|
|
236
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).
|
237
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"
|
@@ -42,9 +43,18 @@ module Positioning
|
|
42
43
|
super(position)
|
43
44
|
end
|
44
45
|
|
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
|
+
|
45
52
|
before_create { Mechanisms.new(self, column).create_position }
|
46
53
|
before_update { Mechanisms.new(self, column).update_position }
|
47
54
|
after_destroy { Mechanisms.new(self, column).destroy_position }
|
55
|
+
|
56
|
+
after_commit advisory_lock
|
57
|
+
after_rollback advisory_lock
|
48
58
|
end
|
49
59
|
end
|
50
60
|
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
|