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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fa6907a0507b7978c58578596c4e6ca16b7e334eb98b3fa42cba47a0d0b89b5
4
- data.tar.gz: ed8f3126f69cb0f8780244a05d48e46abd0b64468ad47838f0d5827b4fa943ff
3
+ metadata.gz: ea25e9b2c25e0268da3078ed8b9d9d992aa4d4e67020f5ae317e2cdf9fa5fd10
4
+ data.tar.gz: ac3996e32d7b6936a9d97d486bd63cab73c371eed718bec034bb420c262f36c1
5
5
  SHA512:
6
- metadata.gz: 5d35b26bf717252dc30d828b2fff2b5b36c82ccd5b33d5480798cc57e38bacf9434120d2a478d448998c368286272a9dc2604cb8d3ebd071da0a55c8fa4ad5ba
7
- data.tar.gz: 3b9c21bb1a3fe42c0799ffe6e991b29f7c17fd4efec8ff3b159afa41cd8a6b8b1e975e47c8e69e22dfd673352b105f2c55d85887a07d90e528b2cd0c54b3a318
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 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.
@@ -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. 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` 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 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.
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
@@ -1,3 +1,3 @@
1
1
  module Positioning
2
- VERSION = "0.1.6"
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"
@@ -7,46 +8,59 @@ require "active_support/lazy_load_hooks"
7
8
  module Positioning
8
9
  class Error < StandardError; end
9
10
 
10
- extend ActiveSupport::Concern
11
+ RelativePosition = Struct.new(:before, :after, keyword_init: true)
11
12
 
12
- class_methods do
13
- def positioning_columns
14
- @positioning_columns ||= {}
15
- end
13
+ module Behaviour
14
+ extend ActiveSupport::Concern
16
15
 
17
- def positioned(on: [], column: :position)
18
- unless base_class?
19
- raise Error.new "can't be called on an abstract class or STI subclass."
16
+ class_methods do
17
+ def positioning_columns
18
+ @positioning_columns ||= {}
20
19
  end
21
20
 
22
- column = column.to_sym
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
- if positioning_columns.key? column
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
- (reflection && reflection.belongs_to?) ? reflection.foreign_key : scope_component
32
- end
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
- define_method(:"prior_#{column}") { Mechanisms.new(self, column).prior }
35
- define_method(:"subsequent_#{column}") { Mechanisms.new(self, column).subsequent }
35
+ (reflection && reflection.belongs_to?) ? reflection.foreign_key : scope_component
36
+ end
36
37
 
37
- redefine_method(:"#{column}=") do |position|
38
- send :"#{column}_will_change!"
39
- super(position)
40
- end
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
- before_create { Mechanisms.new(self, column).create_position }
43
- before_update { Mechanisms.new(self, column).update_position }
44
- after_destroy { Mechanisms.new(self, column).destroy_position }
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.1.6
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