positioning 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +109 -0
- data/LICENSE.txt +21 -0
- data/README.md +206 -0
- data/Rakefile +12 -0
- data/lib/positioning/mechanisms.rb +177 -0
- data/lib/positioning/version.rb +3 -0
- data/lib/positioning.rb +43 -0
- data/positioning.gemspec +40 -0
- data/sig/positioning.rbs +4 -0
- metadata +156 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d2088ce4125fed14b6422ec4e02e1ca5a203d724f0cfaafc043ff7b7c0465a03
|
4
|
+
data.tar.gz: 9aa3da89865ab6e2336bcb00a7259b97bd69bc3c639b0495c551a1feb33c24fe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 737adf268dda74000333e2f3d316c58d162d13385a219e3b48668fffb04a7a0b55f197dba5d19b58b2f32b8f1496c10c8dc432b293fffde1142053719facfdc2
|
7
|
+
data.tar.gz: 8165738f13801f4d8bb8f749c19afe0939a9d6b2e5907038c9b10d7777f7af6daf6f463eac1fc981288c3fba21c69ce19bfa722988d814cdac68509926a40233
|
data/.standard.yml
ADDED
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
positioning (0.1.0)
|
5
|
+
activerecord (>= 6.1)
|
6
|
+
activesupport (>= 6.1)
|
7
|
+
|
8
|
+
GEM
|
9
|
+
remote: https://rubygems.org/
|
10
|
+
specs:
|
11
|
+
activemodel (7.1.3)
|
12
|
+
activesupport (= 7.1.3)
|
13
|
+
activerecord (7.1.3)
|
14
|
+
activemodel (= 7.1.3)
|
15
|
+
activesupport (= 7.1.3)
|
16
|
+
timeout (>= 0.4.0)
|
17
|
+
activesupport (7.1.3)
|
18
|
+
base64
|
19
|
+
bigdecimal
|
20
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
21
|
+
connection_pool (>= 2.2.5)
|
22
|
+
drb
|
23
|
+
i18n (>= 1.6, < 2)
|
24
|
+
minitest (>= 5.1)
|
25
|
+
mutex_m
|
26
|
+
tzinfo (~> 2.0)
|
27
|
+
ast (2.4.2)
|
28
|
+
base64 (0.2.0)
|
29
|
+
bigdecimal (3.1.6)
|
30
|
+
concurrent-ruby (1.2.3)
|
31
|
+
connection_pool (2.4.1)
|
32
|
+
drb (2.2.0)
|
33
|
+
ruby2_keywords
|
34
|
+
i18n (1.14.1)
|
35
|
+
concurrent-ruby (~> 1.0)
|
36
|
+
json (2.7.1)
|
37
|
+
language_server-protocol (3.17.0.3)
|
38
|
+
lint_roller (1.1.0)
|
39
|
+
minitest (5.22.1)
|
40
|
+
minitest-hooks (1.5.1)
|
41
|
+
minitest (> 5.3)
|
42
|
+
mocha (2.1.0)
|
43
|
+
ruby2_keywords (>= 0.0.5)
|
44
|
+
mutex_m (0.2.0)
|
45
|
+
mysql2 (0.5.6)
|
46
|
+
parallel (1.24.0)
|
47
|
+
parser (3.3.0.5)
|
48
|
+
ast (~> 2.4.1)
|
49
|
+
racc
|
50
|
+
pg (1.5.5)
|
51
|
+
racc (1.7.3)
|
52
|
+
rainbow (3.1.1)
|
53
|
+
rake (13.1.0)
|
54
|
+
regexp_parser (2.9.0)
|
55
|
+
rexml (3.2.6)
|
56
|
+
rubocop (1.59.0)
|
57
|
+
json (~> 2.3)
|
58
|
+
language_server-protocol (>= 3.17.0)
|
59
|
+
parallel (~> 1.10)
|
60
|
+
parser (>= 3.2.2.4)
|
61
|
+
rainbow (>= 2.2.2, < 4.0)
|
62
|
+
regexp_parser (>= 1.8, < 3.0)
|
63
|
+
rexml (>= 3.2.5, < 4.0)
|
64
|
+
rubocop-ast (>= 1.30.0, < 2.0)
|
65
|
+
ruby-progressbar (~> 1.7)
|
66
|
+
unicode-display_width (>= 2.4.0, < 3.0)
|
67
|
+
rubocop-ast (1.30.0)
|
68
|
+
parser (>= 3.2.1.0)
|
69
|
+
rubocop-performance (1.20.2)
|
70
|
+
rubocop (>= 1.48.1, < 2.0)
|
71
|
+
rubocop-ast (>= 1.30.0, < 2.0)
|
72
|
+
ruby-progressbar (1.13.0)
|
73
|
+
ruby2_keywords (0.0.5)
|
74
|
+
sqlite3 (1.7.2-arm64-darwin)
|
75
|
+
sqlite3 (1.7.2-x86_64-linux)
|
76
|
+
standard (1.33.0)
|
77
|
+
language_server-protocol (~> 3.17.0.2)
|
78
|
+
lint_roller (~> 1.0)
|
79
|
+
rubocop (~> 1.59.0)
|
80
|
+
standard-custom (~> 1.0.0)
|
81
|
+
standard-performance (~> 1.3)
|
82
|
+
standard-custom (1.0.2)
|
83
|
+
lint_roller (~> 1.0)
|
84
|
+
rubocop (~> 1.50)
|
85
|
+
standard-performance (1.3.1)
|
86
|
+
lint_roller (~> 1.1)
|
87
|
+
rubocop-performance (~> 1.20.2)
|
88
|
+
timeout (0.4.1)
|
89
|
+
tzinfo (2.0.6)
|
90
|
+
concurrent-ruby (~> 1.0)
|
91
|
+
unicode-display_width (2.5.0)
|
92
|
+
|
93
|
+
PLATFORMS
|
94
|
+
arm64-darwin-21
|
95
|
+
x86_64-linux
|
96
|
+
|
97
|
+
DEPENDENCIES
|
98
|
+
minitest (~> 5.0)
|
99
|
+
minitest-hooks (~> 1.5.1)
|
100
|
+
mocha (~> 2.1.0)
|
101
|
+
mysql2 (~> 0.5.6)
|
102
|
+
pg (~> 1.5.5)
|
103
|
+
positioning!
|
104
|
+
rake (~> 13.0)
|
105
|
+
sqlite3 (~> 1.7.2)
|
106
|
+
standard (~> 1.3)
|
107
|
+
|
108
|
+
BUNDLED WITH
|
109
|
+
2.3.8
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Brendon Muir
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,206 @@
|
|
1
|
+
# Positioning
|
2
|
+
|
3
|
+
The aim of this gem is to allow you to easily position Active Record model instances within a scope of your choosing. In an ideal world this gem will give your model instances sequential integer positions beginning with `1`. Attempts are made to make all changes within a transaction so that position integers remain consistent. To this end, directly assigning a position is discouraged, instead you can move items by declaring an item's prior or subsequent item in the list and your item will be moved to be relative to that item.
|
4
|
+
|
5
|
+
Positioning supports multiple lists per model with global, simple, and complex scopes.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'positioning'
|
13
|
+
```
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle install
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install positioning
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
In the simplest case our database column should be named `position` and not allow `NULL` as a value:
|
26
|
+
|
27
|
+
`add_column :items, :position, :integer, null: false`
|
28
|
+
|
29
|
+
You should also add an index to ensure that the `position` column value is unique within its scope:
|
30
|
+
|
31
|
+
`add_index :items, [:list_id, :position], unique: true`
|
32
|
+
|
33
|
+
The above assumes that your items are scoped to a parent table called `lists`.
|
34
|
+
|
35
|
+
The Positioning gem uses `0` and negative integers to rearrange the lists it manages so don't add database validations to restrict the usage of these. You are also restricted from using `0` and negative integers as position values. If you try, the position value will become `1`. If you try to set an explicit position value that is greater than the next available list position, it will be rounded down to that value.
|
36
|
+
|
37
|
+
### Declaring Positioning
|
38
|
+
|
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
|
+
|
41
|
+
```ruby
|
42
|
+
# The scope is global (all records will belong to the same list) and the databse column
|
43
|
+
# is 'positioned'
|
44
|
+
positioned
|
45
|
+
|
46
|
+
# The scope is on the belongs_to relationship 'list' and the databse column is 'positioned'
|
47
|
+
# We check if the scope is a belongs_to relationship and use its declared foreign_key as
|
48
|
+
# the scope value. In this case it would be 'list_id' since we haven't overridden the
|
49
|
+
# default foreign key.
|
50
|
+
belongs_to :list
|
51
|
+
positioned on: :list
|
52
|
+
|
53
|
+
# If you want to change the database column used to record positions you can do so via the
|
54
|
+
# ':column' parameter. This is most useful when you are keeping track of more than one
|
55
|
+
# list on a model.
|
56
|
+
belongs_to :list
|
57
|
+
belongs_to :category
|
58
|
+
positioned on: :list
|
59
|
+
positioned on: :category, column: :category_position
|
60
|
+
|
61
|
+
# A scope need not be a belongs_to relationship; it can be any column in the database table.
|
62
|
+
positioned on: :type
|
63
|
+
|
64
|
+
# Finally, you can have more complex scopes defined as an array of relationships and/or
|
65
|
+
# columns.
|
66
|
+
belongs_to :list
|
67
|
+
belongs_to :category
|
68
|
+
positioned on: [:list, :category, :enabled]
|
69
|
+
```
|
70
|
+
|
71
|
+
### Manipulating Positioning
|
72
|
+
|
73
|
+
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`:
|
74
|
+
|
75
|
+
#### Accessing Relative List Items
|
76
|
+
|
77
|
+
The two instance methods that we add are for finding the prior and subsequent items relative to the current item in the list. These methods are named after the database column used to track positioning. By default the methods are named `prior_position` and `subsequent_position`. In the example above where we used the column `category_position` then the methods would be named `prior_category_position` and `subsequent_category_position`.
|
78
|
+
|
79
|
+
#### Assigning Positions
|
80
|
+
|
81
|
+
If you don't provide a position when creating a record, your record will be added to the end of the list.
|
82
|
+
|
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
|
+
|
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).
|
90
|
+
|
91
|
+
Position parameters can be strings or symbols, so you can provide them from the browser.
|
92
|
+
|
93
|
+
Here are some examples:
|
94
|
+
|
95
|
+
##### Creating
|
96
|
+
|
97
|
+
```ruby
|
98
|
+
# Added to the third position, other records are moved out of the way
|
99
|
+
list.items.create name: 'Item', position: 3
|
100
|
+
|
101
|
+
# Added to the end of the list
|
102
|
+
list.items.create name: 'Item'
|
103
|
+
list.items.create name: 'Item', position: :last
|
104
|
+
list.items.create name: 'Item', position: nil
|
105
|
+
list.items.create name: 'Item', position: {before: nil}
|
106
|
+
|
107
|
+
# Added to the start of the list
|
108
|
+
list.items.create name: 'Item', position: :first
|
109
|
+
list.items.create name: 'Item', position: {after: nil}
|
110
|
+
|
111
|
+
# Added before other_item
|
112
|
+
list.items.create name: 'Item', position: {before: other_item}
|
113
|
+
# or
|
114
|
+
other_item.id # => 22
|
115
|
+
list.items.create name: 'Item', position: {before: 22}
|
116
|
+
|
117
|
+
# Added after other_item
|
118
|
+
list.items.create name: 'Item', position: {after: other_item}
|
119
|
+
# or
|
120
|
+
other_item.id # => 11
|
121
|
+
list.items.create name: 'Item', position: {after: 11}
|
122
|
+
```
|
123
|
+
|
124
|
+
##### Updating
|
125
|
+
|
126
|
+
```ruby
|
127
|
+
# Moved to the third position, other records are moved out of the way
|
128
|
+
item.update position: 3
|
129
|
+
|
130
|
+
# Moved to the end of the list
|
131
|
+
item.update position: :last
|
132
|
+
item.update position: nil
|
133
|
+
item.update position: {before: nil}
|
134
|
+
|
135
|
+
# Moved to the start of the list
|
136
|
+
item.update position: :first
|
137
|
+
item.update position: {after: nil}
|
138
|
+
|
139
|
+
# Moved to before other_item
|
140
|
+
item.update position: {before: other_item}
|
141
|
+
# or
|
142
|
+
other_item.id # => 22
|
143
|
+
item.update position: {before: 22}
|
144
|
+
|
145
|
+
# Moved to after other_item
|
146
|
+
item.update position: {after: other_item}
|
147
|
+
# or
|
148
|
+
other_item.id # => 11
|
149
|
+
item.update position: {after: 11}
|
150
|
+
```
|
151
|
+
|
152
|
+
#### Destroying
|
153
|
+
|
154
|
+
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.
|
155
|
+
|
156
|
+
#### Scopes
|
157
|
+
Positioning handles things for you when you change the scope of a record. If you move a record from one scope to another, the gap in the position column will be healed in the scope the record is leaving, and by default (unless you specify an explicit position) the record will be added to the end of the list in the new scope.
|
158
|
+
|
159
|
+
Here are some examples of scope management:
|
160
|
+
|
161
|
+
```ruby
|
162
|
+
# Moved to being the third item in other_list
|
163
|
+
item.update list: other_list, position: 3
|
164
|
+
|
165
|
+
# Moved to the end of other_list
|
166
|
+
item.update list: other_list
|
167
|
+
item.update list: other_list, position: :last
|
168
|
+
item.update list: other_list, position: nil
|
169
|
+
item.update list: other_list, position: {before: nil}
|
170
|
+
|
171
|
+
# Moved to the start of other_list
|
172
|
+
item.update list: other_list, position: :first
|
173
|
+
item.update list: other_list, position: {after: nil}
|
174
|
+
|
175
|
+
# Moved to before other_item in other_list
|
176
|
+
item.update list: other_list, position: {before: other_item}
|
177
|
+
# or
|
178
|
+
other_item.id # => 22
|
179
|
+
item.update list: other_list, position: {before: 22}
|
180
|
+
|
181
|
+
# Moved to after other_item in other_list
|
182
|
+
item.update list: other_list, position: {after: other_item}
|
183
|
+
# or
|
184
|
+
other_item.id # => 11
|
185
|
+
item.update list: other_list, position: {after: 11}
|
186
|
+
```
|
187
|
+
|
188
|
+
It's important to note that in the examples above, `other_item` must already belong to the `other_list` scope.
|
189
|
+
|
190
|
+
## Development
|
191
|
+
|
192
|
+
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
|
+
|
194
|
+
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
|
+
|
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.
|
197
|
+
|
198
|
+
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
|
+
|
200
|
+
## Contributing
|
201
|
+
|
202
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/brendon/positioning.
|
203
|
+
|
204
|
+
## License
|
205
|
+
|
206
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,177 @@
|
|
1
|
+
module Positioning
|
2
|
+
class Mechanisms
|
3
|
+
def initialize(positioned, column)
|
4
|
+
@positioned = positioned
|
5
|
+
@column = column.to_sym
|
6
|
+
end
|
7
|
+
|
8
|
+
def prior
|
9
|
+
positioning_scope.where("#{@column}": position - 1).first
|
10
|
+
end
|
11
|
+
|
12
|
+
def subsequent
|
13
|
+
positioning_scope.where("#{@column}": position + 1).first
|
14
|
+
end
|
15
|
+
|
16
|
+
def create_position
|
17
|
+
solidify_position
|
18
|
+
|
19
|
+
expand(positioning_scope, position..)
|
20
|
+
end
|
21
|
+
|
22
|
+
def update_position
|
23
|
+
# If we're changing scope but not explicitly setting the position then we set the position
|
24
|
+
# to nil so that the item gets placed at the end of the list.
|
25
|
+
self.position = nil if positioning_scope_changed? && !position_changed?
|
26
|
+
|
27
|
+
solidify_position
|
28
|
+
|
29
|
+
# The update strategy is to temporarily set our position to 0, then shift everything out of the way of
|
30
|
+
# our new desired position before finalising it.
|
31
|
+
if positioning_scope_changed? || position_changed?
|
32
|
+
record_scope = base_class.where("#{primary_key_column}": primary_key)
|
33
|
+
|
34
|
+
position_was = record_scope.pick(@column)
|
35
|
+
record_scope.update_all "#{@column}": 0
|
36
|
+
|
37
|
+
if positioning_scope_changed?
|
38
|
+
positioning_scope_was = base_class.where record_scope.first.slice(*positioning_columns)
|
39
|
+
|
40
|
+
contract(positioning_scope_was, position_was..)
|
41
|
+
expand(positioning_scope, position..)
|
42
|
+
|
43
|
+
# If the position integer was set to the same as its prior value but the scope has changed then
|
44
|
+
# we need to tell Rails that it has changed so that it gets updated from the temporary 0 value.
|
45
|
+
position_will_change!
|
46
|
+
elsif position_was > position
|
47
|
+
expand(positioning_scope, position..position_was)
|
48
|
+
else
|
49
|
+
contract(positioning_scope, position_was..position)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def destroy_position
|
55
|
+
contract(positioning_scope, (position + 1)..) unless destroyed_via_positioning_scope?
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def base_class
|
61
|
+
@positioned.class.base_class
|
62
|
+
end
|
63
|
+
|
64
|
+
def primary_key_column
|
65
|
+
base_class.primary_key
|
66
|
+
end
|
67
|
+
|
68
|
+
def primary_key
|
69
|
+
@positioned.send primary_key_column
|
70
|
+
end
|
71
|
+
|
72
|
+
def position
|
73
|
+
@positioned.send @column
|
74
|
+
end
|
75
|
+
|
76
|
+
def position=(position)
|
77
|
+
@positioned.send :"#{@column}=", position
|
78
|
+
end
|
79
|
+
|
80
|
+
def position_changed?
|
81
|
+
@positioned.send :"#{@column}_changed?"
|
82
|
+
end
|
83
|
+
|
84
|
+
def position_will_change!
|
85
|
+
@positioned.send :"#{@column}_will_change!"
|
86
|
+
end
|
87
|
+
|
88
|
+
def expand(scope, range)
|
89
|
+
scope.where("#{@column}": range).update_all "#{@column} = #{@column} * -1"
|
90
|
+
scope.where("#{@column}": ..-1).update_all "#{@column} = #{@column} * -1 + 1"
|
91
|
+
end
|
92
|
+
|
93
|
+
def contract(scope, range)
|
94
|
+
scope.where("#{@column}": range).update_all "#{@column} = #{@column} * -1"
|
95
|
+
scope.where("#{@column}": ..-1).update_all "#{@column} = #{@column} * -1 - 1"
|
96
|
+
end
|
97
|
+
|
98
|
+
def solidify_position
|
99
|
+
position_before_type_cast = @positioned.read_attribute_before_type_cast @column
|
100
|
+
position_before_type_cast.to_sym if position_before_type_cast.is_a? String
|
101
|
+
position_before_type_cast.symbolize_keys! if position_before_type_cast.is_a? Hash
|
102
|
+
|
103
|
+
case position_before_type_cast
|
104
|
+
when Integer
|
105
|
+
self.position = position_before_type_cast.clamp(1..last_position)
|
106
|
+
when :first, {after: nil}
|
107
|
+
self.position = 1
|
108
|
+
when nil, :last, {before: nil}
|
109
|
+
self.position = last_position
|
110
|
+
when Hash
|
111
|
+
relative_position, relative_record_or_primary_key = *position_before_type_cast.first
|
112
|
+
|
113
|
+
unless [:before, :after].include? relative_position
|
114
|
+
raise Error.new, "relative `#{@column}` must be either :before, :after"
|
115
|
+
end
|
116
|
+
|
117
|
+
relative_primary_key = if relative_record_or_primary_key.is_a? base_class
|
118
|
+
relative_record_or_primary_key.send(primary_key_column)
|
119
|
+
else
|
120
|
+
relative_record_or_primary_key
|
121
|
+
end
|
122
|
+
|
123
|
+
relative_record_scope = positioning_scope.where("#{primary_key_column}": relative_primary_key)
|
124
|
+
|
125
|
+
unless relative_record_scope.exists?
|
126
|
+
raise Error.new, "relative `#{@column}` record must be in the same scope"
|
127
|
+
end
|
128
|
+
|
129
|
+
position_was = base_class.where("#{primary_key_column}": primary_key).pick(@column)
|
130
|
+
|
131
|
+
solidified_position = relative_record_scope.pick(@column)
|
132
|
+
solidified_position += 1 if relative_position == :after
|
133
|
+
solidified_position -= 1 if in_positioning_scope? && position_was < solidified_position
|
134
|
+
|
135
|
+
self.position = solidified_position
|
136
|
+
end
|
137
|
+
|
138
|
+
unless position.is_a? Integer
|
139
|
+
raise Error.new,
|
140
|
+
"`#{@column}` must be an Integer, :first, :last, before: #{base_class.name}, " \
|
141
|
+
"after: #{base_class.name}, or nil"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def last_position
|
146
|
+
(positioning_scope.maximum(@column) || 0) + (in_positioning_scope? ? 0 : 1)
|
147
|
+
end
|
148
|
+
|
149
|
+
def positioning_columns
|
150
|
+
@positioned.class.positioning_columns[@column]
|
151
|
+
end
|
152
|
+
|
153
|
+
def positioning_scope
|
154
|
+
@positioned.class.where(
|
155
|
+
positioning_columns.to_h { |scope_component|
|
156
|
+
[scope_component, @positioned.send(scope_component)]
|
157
|
+
}
|
158
|
+
).order(@column)
|
159
|
+
end
|
160
|
+
|
161
|
+
def in_positioning_scope?
|
162
|
+
@positioned.persisted? && positioning_scope.exists?(primary_key)
|
163
|
+
end
|
164
|
+
|
165
|
+
def positioning_scope_changed?
|
166
|
+
positioning_columns.any? do |scope_component|
|
167
|
+
@positioned.attribute_changed?(scope_component)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def destroyed_via_positioning_scope?
|
172
|
+
@positioned.destroyed_by_association && positioning_columns.any? do |scope_component|
|
173
|
+
@positioned.destroyed_by_association.foreign_key == scope_component
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
data/lib/positioning.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require_relative "positioning/version"
|
2
|
+
require_relative "positioning/mechanisms"
|
3
|
+
|
4
|
+
require "active_support/concern"
|
5
|
+
require "active_support/lazy_load_hooks"
|
6
|
+
|
7
|
+
module Positioning
|
8
|
+
class Error < StandardError; end
|
9
|
+
|
10
|
+
extend ActiveSupport::Concern
|
11
|
+
|
12
|
+
class_methods do
|
13
|
+
def positioning_columns
|
14
|
+
@positioning_columns ||= {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def positioned(on: [], column: :position)
|
18
|
+
column = column.to_sym
|
19
|
+
|
20
|
+
if positioning_columns.key? column
|
21
|
+
raise Error.new "The column `#{column}` has already been used by the scope `#{positioning_columns[column]}`."
|
22
|
+
else
|
23
|
+
positioning_columns[column] = Array.wrap(on).map do |scope_component|
|
24
|
+
scope_component = scope_component.to_s
|
25
|
+
reflection = reflections[scope_component]
|
26
|
+
|
27
|
+
(reflection && reflection.belongs_to?) ? reflection.foreign_key : scope_component
|
28
|
+
end
|
29
|
+
|
30
|
+
define_method(:"prior_#{column}") { Mechanisms.new(self, column).prior }
|
31
|
+
define_method(:"subsequent_#{column}") { Mechanisms.new(self, column).subsequent }
|
32
|
+
|
33
|
+
before_create { Mechanisms.new(self, column).create_position }
|
34
|
+
before_update { Mechanisms.new(self, column).update_position }
|
35
|
+
after_destroy { Mechanisms.new(self, column).destroy_position }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
ActiveSupport.on_load :active_record do
|
42
|
+
ActiveRecord::Base.send :include, Positioning
|
43
|
+
end
|
data/positioning.gemspec
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
require_relative "lib/positioning/version"
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "positioning"
|
5
|
+
spec.version = Positioning::VERSION
|
6
|
+
spec.authors = ["Brendon Muir"]
|
7
|
+
spec.email = ["brendon@spike.net.nz"]
|
8
|
+
|
9
|
+
spec.summary = "Simple positioning for Active Record models."
|
10
|
+
spec.homepage = "https://github.com/brendon/positioning"
|
11
|
+
spec.license = "MIT"
|
12
|
+
spec.required_ruby_version = ">= 3.0.0"
|
13
|
+
|
14
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
15
|
+
spec.metadata["source_code_uri"] = "https://github.com/brendon/positioning"
|
16
|
+
spec.metadata["changelog_uri"] = "https://github.com/brendon/positioning/CHANGELOG.md"
|
17
|
+
|
18
|
+
# Specify which files should be added to the gem when it is released.
|
19
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
20
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
21
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
22
|
+
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
|
23
|
+
end
|
24
|
+
end
|
25
|
+
spec.bindir = "exe"
|
26
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
27
|
+
spec.require_paths = ["lib"]
|
28
|
+
|
29
|
+
# Uncomment to register a new dependency of your gem
|
30
|
+
spec.add_dependency "activesupport", ">= 6.1"
|
31
|
+
spec.add_dependency "activerecord", ">= 6.1"
|
32
|
+
spec.add_development_dependency "minitest-hooks", "~> 1.5.1"
|
33
|
+
spec.add_development_dependency "mocha", "~> 2.1.0"
|
34
|
+
spec.add_development_dependency "mysql2", "~> 0.5.6"
|
35
|
+
spec.add_development_dependency "pg", "~> 1.5.5"
|
36
|
+
spec.add_development_dependency "sqlite3", "~> 1.7.2"
|
37
|
+
|
38
|
+
# For more information and examples about making a new gem, check out our
|
39
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
40
|
+
end
|
data/sig/positioning.rbs
ADDED
metadata
ADDED
@@ -0,0 +1,156 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: positioning
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Brendon Muir
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-02-24 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '6.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '6.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activerecord
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6.1'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6.1'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest-hooks
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 1.5.1
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 1.5.1
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: mocha
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 2.1.0
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 2.1.0
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: mysql2
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 0.5.6
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 0.5.6
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pg
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 1.5.5
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 1.5.5
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: sqlite3
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 1.7.2
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 1.7.2
|
111
|
+
description:
|
112
|
+
email:
|
113
|
+
- brendon@spike.net.nz
|
114
|
+
executables: []
|
115
|
+
extensions: []
|
116
|
+
extra_rdoc_files: []
|
117
|
+
files:
|
118
|
+
- ".standard.yml"
|
119
|
+
- CHANGELOG.md
|
120
|
+
- Gemfile
|
121
|
+
- Gemfile.lock
|
122
|
+
- LICENSE.txt
|
123
|
+
- README.md
|
124
|
+
- Rakefile
|
125
|
+
- lib/positioning.rb
|
126
|
+
- lib/positioning/mechanisms.rb
|
127
|
+
- lib/positioning/version.rb
|
128
|
+
- positioning.gemspec
|
129
|
+
- sig/positioning.rbs
|
130
|
+
homepage: https://github.com/brendon/positioning
|
131
|
+
licenses:
|
132
|
+
- MIT
|
133
|
+
metadata:
|
134
|
+
homepage_uri: https://github.com/brendon/positioning
|
135
|
+
source_code_uri: https://github.com/brendon/positioning
|
136
|
+
changelog_uri: https://github.com/brendon/positioning/CHANGELOG.md
|
137
|
+
post_install_message:
|
138
|
+
rdoc_options: []
|
139
|
+
require_paths:
|
140
|
+
- lib
|
141
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: 3.0.0
|
146
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
147
|
+
requirements:
|
148
|
+
- - ">="
|
149
|
+
- !ruby/object:Gem::Version
|
150
|
+
version: '0'
|
151
|
+
requirements: []
|
152
|
+
rubygems_version: 3.2.32
|
153
|
+
signing_key:
|
154
|
+
specification_version: 4
|
155
|
+
summary: Simple positioning for Active Record models.
|
156
|
+
test_files: []
|