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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3287b3ffda224e17c32b5601cc2f14aba284bcac50d7627be46300f1c7473521
4
- data.tar.gz: 9027f931d4dc6781241cd80faacf056d320307e94fb0a1ded2e8563bc7d86b59
3
+ metadata.gz: ea25e9b2c25e0268da3078ed8b9d9d992aa4d4e67020f5ae317e2cdf9fa5fd10
4
+ data.tar.gz: ac3996e32d7b6936a9d97d486bd63cab73c371eed718bec034bb420c262f36c1
5
5
  SHA512:
6
- metadata.gz: 8f0a3353940e24c88f89835fee413eef989a67e3ef1a984d8cba2611d377be6a68f0743568090cb5a58da2f6c8f2eb17d15cfa2f4880b4f0b209f4ad582b176f
7
- data.tar.gz: 975fa2efc49eacb1dc5a96de826ccc9e6db72f8adb82a4d44f72dff57581cd551581c77458c40b06686c90232aebff127006503369cee3faed0ad704160e8c21
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 databse column
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 databse column is 'positioned'
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 Positining in Forms
154
+ ##### Relative Positioning in Forms
155
155
 
156
- It can be tricky to provide the hash forms of relative positining using Rails form helpers, but it is possible. We've declared a special `Struct` for you to use for this purpose.
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: [:before]
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 intialise the `position` column with a value supplied by incoming parameters:
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
- ```erb
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 preferrably adjust your environment to support passwordless socket based connections to these two database engines. You'll also need to manually create a database named `positioning_test` in each.
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
@@ -1,3 +1,3 @@
1
1
  module Positioning
2
- VERSION = "0.1.7"
2
+ VERSION = "0.2.0"
3
3
  end
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.1.7
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-05 00:00:00.000000000 Z
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