narabikae 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +206 -11
- data/lib/narabikae/active_record_extension.rb +51 -0
- data/lib/narabikae/active_record_handler.rb +31 -0
- data/lib/narabikae/configuration.rb +19 -0
- data/lib/narabikae/option.rb +16 -0
- data/lib/narabikae/option_store.rb +25 -0
- data/lib/narabikae/position.rb +151 -0
- data/lib/narabikae/version.rb +1 -1
- data/lib/narabikae.rb +71 -2
- metadata +80 -12
- data/lib/narabikae/railtie.rb +0 -4
- data/lib/tasks/narabikae_tasks.rake +0 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 96e657bcc111a885f21934870ac5360490aa4999b6df0533af524f761ecd9244
|
4
|
+
data.tar.gz: d7eca3f77b6260eea610bb5f5dbcdd71099972f74cd88028d00c5a266bd32667
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e0bcec0651250189dcdac6c724bccf994d98350d4668e543dab8e039cc3f5ecd2891961ba2ecaa3d9bcd198094968137d4d5fb8215f7da692a849496b310cec7
|
7
|
+
data.tar.gz: 90aa217d0d397c49452febff37a6153ffc10c2f309de27348482b1f28a3dac58440306001f2110eb70c3fa063e1d3e2789846b4c5fe0377750c84260b70a2e2b
|
data/README.md
CHANGED
@@ -1,28 +1,223 @@
|
|
1
1
|
# Narabikae
|
2
|
-
Short description and motivation.
|
3
2
|
|
4
|
-
|
5
|
-
|
3
|
+
Narabikae(Japanese: 並び替え) means "reorder". Like [acts_as_list](https://github.com/brendon/acts_as_list), this gem provides automatic order management and reordering functionality for your records.
|
4
|
+
|
5
|
+
One of the key advantages of this gem is its use of the [fractional indexing algorithm](https://www.figma.com/blog/realtime-editing-of-ordered-sequences/#fractional-indexing), which greatly enhances the efficiency of reordering operations. With Narabikae, regardless of the amount of data, "only a single record" is updated during the reordering process 🎉.
|
6
6
|
|
7
7
|
## Installation
|
8
|
-
|
8
|
+
|
9
|
+
In your Gemfile
|
9
10
|
|
10
11
|
```ruby
|
11
12
|
gem "narabikae"
|
12
13
|
```
|
13
14
|
|
14
|
-
|
15
|
-
|
16
|
-
|
15
|
+
## Getting started
|
16
|
+
|
17
|
+
### Adding a column to manage order
|
18
|
+
|
19
|
+
To manage the order of records, you'll need to create a column in your database. A key feature of this gem is that it generates the order as a string!
|
20
|
+
|
21
|
+
```rb
|
22
|
+
# example
|
23
|
+
|
24
|
+
create_table :tasks do |t|
|
25
|
+
t.string :name
|
26
|
+
|
27
|
+
# for MySQL
|
28
|
+
t.string :position, null: false, limit: 200, charset: 'ascii', collation: 'ascii_bin'
|
29
|
+
|
30
|
+
# for PostgreSQL
|
31
|
+
t.string :position, null: false, limit: 200, collation: 'C'
|
32
|
+
|
33
|
+
# for SQLite3
|
34
|
+
t.string :position, null: false, collation: 'binary'
|
35
|
+
end
|
36
|
+
add_index :tasks, :position, unique: true
|
37
|
+
|
17
38
|
```
|
18
39
|
|
19
|
-
|
20
|
-
|
21
|
-
|
40
|
+
#### Key points to consider when creating the column:
|
41
|
+
|
42
|
+
- Set the collation to distinguish between uppercase and lowercase letters.
|
43
|
+
|
44
|
+
For example, if using MySQL 8.0’s default collation (utf8mb4_0900_ai_ci), which does not distinguish between uppercase and lowercase, the sort results may not behave as expected.
|
45
|
+
|
46
|
+
- It is recommended to apply both NOT NULL and UNIQUE constraints.
|
47
|
+
|
48
|
+
This ensures data integrity and efficient ordering.
|
49
|
+
|
50
|
+
- Explicitly set a character limit for the column.
|
51
|
+
|
52
|
+
Since this column will typically be indexed, it is important to set an appropriate length. This gem uses a base-62 numbering system to represent the order. In the example above, with a length limit of 200 characters, you can represent up to "62^200 unique order values", providing a huge range for ordered sequences.
|
53
|
+
|
54
|
+
### Adding configuration to your model
|
55
|
+
|
56
|
+
You only need to add one line as shown below!
|
57
|
+
|
58
|
+
```rb
|
59
|
+
class Task < ApplicationRecord
|
60
|
+
narabikae :position, size: 200
|
61
|
+
|
62
|
+
# arg1: optional
|
63
|
+
# . Specify the field you want to use for ordering.
|
64
|
+
# The default is :position.
|
65
|
+
#
|
66
|
+
# size: required
|
67
|
+
# Used for validation of the internally generated order value.
|
68
|
+
# This value should be equivalent to
|
69
|
+
# the limit set in the DB column.
|
70
|
+
end
|
22
71
|
```
|
23
72
|
|
73
|
+
Once this is done, the position will be automatically set each time a Task model instance is saved!
|
74
|
+
|
75
|
+
```rb
|
76
|
+
Task.create([
|
77
|
+
{ name: 'task-1' },
|
78
|
+
{ name: 'task-2' },
|
79
|
+
{ name: 'task-3' }
|
80
|
+
])
|
81
|
+
Task.order(:position).pluck(:name, :position)
|
82
|
+
# => [["task-1", "a0"], ["task-3", "a1"], ["task-1", "a2"]]
|
83
|
+
|
84
|
+
```
|
85
|
+
|
86
|
+
> [!NOTE]
|
87
|
+
> The position is set using the before_create callback. Therefore, do not define validations such as presence on the attributes managed by this gem!
|
88
|
+
|
89
|
+
## Usage Details
|
90
|
+
|
91
|
+
### Reorder
|
92
|
+
|
93
|
+
To insert an element after any specified item, use the `move_to_<field>_after` method.
|
94
|
+
|
95
|
+
```ruby
|
96
|
+
target = Task.create(name: 'target') # pos: 'a0'
|
97
|
+
tasks = Task.create([
|
98
|
+
{ name: 'task-1' }, # pos: 'a1'
|
99
|
+
{ name: 'task-2' } # pos: 'a2'
|
100
|
+
])
|
101
|
+
|
102
|
+
target.move_to_position_after(tasks.last)
|
103
|
+
# => true
|
104
|
+
target.position
|
105
|
+
# => 'a3'
|
106
|
+
|
107
|
+
# If no argument is passed, it will be inserted at the end of the list
|
108
|
+
tasks.first.move_to_position_after
|
109
|
+
# => true
|
110
|
+
|
111
|
+
Task.order(:position).pluck(:name, :position)
|
112
|
+
# => [["task-2", "a2"], ["target", "a3"], ["task-1", "a4"]]
|
113
|
+
```
|
114
|
+
|
115
|
+
To insert an element before any specified item, use the `move_to_<field>_before` method.
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
tasks = Task.create([
|
119
|
+
{ name: 'task-1' }, # pos: 'a0'
|
120
|
+
{ name: 'task-2' } # pos: 'a1'
|
121
|
+
])
|
122
|
+
target = Task.create(name: 'target') # pos: 'a2'
|
123
|
+
|
124
|
+
target.move_to_position_before(tasks.first)
|
125
|
+
target.position
|
126
|
+
# => 'Zz'
|
127
|
+
|
128
|
+
# If no argument is passed, it will be inserted at the start of the list
|
129
|
+
tasks.last.move_to_position_before
|
130
|
+
# => true
|
131
|
+
|
132
|
+
Task.order(:position).pluck(:name, :position)
|
133
|
+
# => [["task-2", "Zy"], ["target", "Zz"], ["task-1", "a0"]]
|
134
|
+
```
|
135
|
+
|
136
|
+
The method you will likely use most often is `move_to_<field>_between`, which moves an element between two others!
|
137
|
+
|
138
|
+
```ruby
|
139
|
+
tasks = Task.create([
|
140
|
+
{ name: 'task-1' }, # pos: 'a0'
|
141
|
+
{ name: 'task-2' } # pos: 'a1'
|
142
|
+
])
|
143
|
+
target = Task.create(name: 'target') # pos: 'a2'
|
144
|
+
|
145
|
+
target.move_to_position_between(tasks.first, tasks.last)
|
146
|
+
# => true
|
147
|
+
|
148
|
+
target.position
|
149
|
+
# => 'a0V'
|
150
|
+
|
151
|
+
# If the first argument is nil, it behaves the same as `move_to_<field>_before`
|
152
|
+
# ex: target.move_to_position_between(nil, tasks.last)
|
153
|
+
|
154
|
+
# If the second argument is nil, it behaves the same as `move_to_<field>_after`
|
155
|
+
# ex: target.move_to_position_between(tasks.first, nil)
|
156
|
+
```
|
157
|
+
|
158
|
+
### Scope
|
159
|
+
|
160
|
+
You can use this when you want to manage independent positions within specific scopes, such as foreign keys.
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
# example
|
164
|
+
class Course < ApplicationRecord
|
165
|
+
has_many :chapters, dependent: :destroy
|
166
|
+
end
|
167
|
+
|
168
|
+
class Chapter < ApplicationRecord
|
169
|
+
belongs_to :course
|
170
|
+
|
171
|
+
narabikae :position, size: 100, scope: %i[course_id]
|
172
|
+
end
|
173
|
+
|
174
|
+
course = Course.create
|
175
|
+
other_course = Course.create
|
176
|
+
|
177
|
+
course.chapters.create
|
178
|
+
other_course.chapters.create
|
179
|
+
|
180
|
+
Chapter.pluck(:course_id, :position)
|
181
|
+
# => [[1, "a0"], [2, "a0"]]
|
182
|
+
```
|
183
|
+
|
184
|
+
When the attribute declared in the scope is changed during an update, and there is no change in the value of the position field, the position will be automatically recalculated, and the record will move to the end of the list.
|
185
|
+
|
186
|
+
```ruby
|
187
|
+
course = Course.create
|
188
|
+
other_course = Course.create
|
189
|
+
|
190
|
+
course.chapters.create
|
191
|
+
chapter = course.chapters.create # pos: 'a1'
|
192
|
+
|
193
|
+
chapter.course = other_course
|
194
|
+
chapter.save
|
195
|
+
|
196
|
+
chapter.position
|
197
|
+
# => 'a0'
|
198
|
+
```
|
199
|
+
|
200
|
+
### Retry generating position
|
201
|
+
|
202
|
+
Imagine the drag-and-drop functionality found in GitHub Projects or Zenhub, where items can be reordered. If two users try to insert different tickets between the same two tickets at the same time, their positions may overlap. In this case, the system will internally retry generating a unique position (up to 10 times by default).
|
203
|
+
|
204
|
+
If you want to increase the number of retries, you can use the "challenge" option to control the retry attempts, as shown below:
|
205
|
+
|
206
|
+
```ruby
|
207
|
+
ticket.move_to_position_between(t1, t2, challenge: 15)
|
208
|
+
```
|
209
|
+
|
210
|
+
> [!NOTE]
|
211
|
+
> Currently, if two users write to the database at exactly the same time, the system may not detect the duplicate positions, and the process will continue without error. This issue will be revisited if there is demand for a more robust solution in the future.
|
212
|
+
|
213
|
+
## Questions, Feedback
|
214
|
+
|
215
|
+
Feel free to message me on Github (kazu-2020)
|
216
|
+
|
24
217
|
## Contributing
|
25
|
-
|
218
|
+
|
219
|
+
Please wait a moment... 🙏
|
26
220
|
|
27
221
|
## License
|
222
|
+
|
28
223
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Narabikae
|
2
|
+
class ActiveRecordExtension
|
3
|
+
def initialize(record, option)
|
4
|
+
@record = record
|
5
|
+
@option = option
|
6
|
+
|
7
|
+
@position_generator = Narabikae::Position.new(record, option)
|
8
|
+
end
|
9
|
+
|
10
|
+
def auto_set_position?
|
11
|
+
# check valid key for fractional_indexer
|
12
|
+
# when invalid key, raise FractionalIndexer::Error
|
13
|
+
FractionalIndexer.generate_key(prev_key: record.send(option.field))
|
14
|
+
option.scope.any? { |s| record.will_save_change_to_attribute?(s) } && !record.will_save_change_to_attribute?(option.field)
|
15
|
+
rescue FractionalIndexer::Error
|
16
|
+
true
|
17
|
+
end
|
18
|
+
|
19
|
+
def set_position
|
20
|
+
record.send("#{option.field}=", position_generator.create_last_position)
|
21
|
+
end
|
22
|
+
|
23
|
+
def move_to_after(target, **args)
|
24
|
+
new_position = position_generator.find_position_after(target, **args)
|
25
|
+
return false if new_position.blank?
|
26
|
+
|
27
|
+
record.send("#{option.field}=", new_position)
|
28
|
+
record.save
|
29
|
+
end
|
30
|
+
|
31
|
+
def move_to_before(target, **args)
|
32
|
+
new_position = position_generator.find_position_before(target, **args)
|
33
|
+
return false if new_position.blank?
|
34
|
+
|
35
|
+
record.send("#{option.field}=", new_position)
|
36
|
+
record.save
|
37
|
+
end
|
38
|
+
|
39
|
+
def move_to_between(prev_target, next_target, **args)
|
40
|
+
new_position = position_generator.find_position_between(prev_target, next_target, **args)
|
41
|
+
return false if new_position.blank?
|
42
|
+
|
43
|
+
record.send("#{option.field}=", new_position)
|
44
|
+
record.save
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
attr_reader :record, :option, :position_generator
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Narabikae
|
2
|
+
class ActiveRecordHandler
|
3
|
+
# Initializes a new instance of the ActiveRecordHandler class.
|
4
|
+
#
|
5
|
+
# @param record [Object] The ActiveRecord object.
|
6
|
+
# @param column [Symbol] The column symbol.
|
7
|
+
def initialize(record, column)
|
8
|
+
@record = record
|
9
|
+
@column = column
|
10
|
+
end
|
11
|
+
|
12
|
+
# Generates a new key for the last position
|
13
|
+
#
|
14
|
+
# @return [String] The newly generated key for the last position.
|
15
|
+
def create_last_position
|
16
|
+
FractionalIndexer.generate_key(prev_key: current_last_position)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
attr_reader :record, :column
|
22
|
+
|
23
|
+
def current_last_position
|
24
|
+
model.maximum(column)
|
25
|
+
end
|
26
|
+
|
27
|
+
def model
|
28
|
+
record.class.base_class
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Narabikae
|
2
|
+
class Configuration
|
3
|
+
# Sets the base value for FractionalIndexer configuration.
|
4
|
+
#
|
5
|
+
# @param int [Integer] The base value can be 10, 62, 94, with the default being 94.
|
6
|
+
# @return [void]
|
7
|
+
def base=(int)
|
8
|
+
FractionalIndexer.configure do |config|
|
9
|
+
config.base = "base_#{int}".to_sym
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
# @return [Array] The string of digits configured for the FractionalIndexer.
|
14
|
+
# @see https://github.com/kazu-2020/fractional_indexer?tab=readme-ov-file#configure
|
15
|
+
def digits
|
16
|
+
FractionalIndexer.configuration.digits
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Narabikae
|
2
|
+
class Option
|
3
|
+
attr_reader :field, :key_max_size, :scope
|
4
|
+
|
5
|
+
# Initializes a new instance of the Option class.
|
6
|
+
#
|
7
|
+
# @param field [Symbol]
|
8
|
+
# @param key_max_size [Integer] The maximum size of the key.
|
9
|
+
# @param scope [Array<Symbol>] The scope of the option.
|
10
|
+
def initialize(field:, key_max_size:, scope: [])
|
11
|
+
@field = field.to_sym
|
12
|
+
@key_max_size = key_max_size.to_i
|
13
|
+
@scope = scope.is_a?(Array) ? scope.map(&:to_sym) : []
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Narabikae
|
2
|
+
class OptionStore
|
3
|
+
attr_reader :store
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@store = {}
|
7
|
+
end
|
8
|
+
|
9
|
+
def register!(field, option)
|
10
|
+
if store.key?(field)
|
11
|
+
raise Narabikae::Error, "the field `#{field}` is already registered"
|
12
|
+
end
|
13
|
+
if option.scope.include?(field)
|
14
|
+
raise Narabikae::Error, "dependency loop detected: #{option.scope}"
|
15
|
+
end
|
16
|
+
if option.scope.any? { |s| store.key?(s) }
|
17
|
+
raise Narabikae::Error, "the scope `#{option.scope}` is already registered as other field"
|
18
|
+
end
|
19
|
+
|
20
|
+
store[field] = option
|
21
|
+
|
22
|
+
option
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module Narabikae
|
2
|
+
class Position
|
3
|
+
# Initializes a new instance of the Position class.
|
4
|
+
#
|
5
|
+
# @param record [Object] Active Record object.
|
6
|
+
# @param option [Option]
|
7
|
+
def initialize(record, option)
|
8
|
+
@record = record
|
9
|
+
@option = option
|
10
|
+
end
|
11
|
+
|
12
|
+
# Generates a new key for the last position
|
13
|
+
#
|
14
|
+
# @return [String] The newly generated key for the last position.
|
15
|
+
def create_last_position
|
16
|
+
FractionalIndexer.generate_key(prev_key: current_last_position)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Finds the position after the specified target.
|
20
|
+
# If generated key is invalid(ex: it already exists),
|
21
|
+
# a new key is generated until the challenge count reaches the limit.
|
22
|
+
# challenge count is 10 by default.
|
23
|
+
#
|
24
|
+
# @param target [#send(field)]
|
25
|
+
# @param args [Hash] Additional arguments.
|
26
|
+
# @option args [Integer] :challenge The number of times to attempt finding a valid position.
|
27
|
+
# @return [String, nil] The generated key for the position after the target, or nil if no valid position is found.
|
28
|
+
def find_position_after(target, **args)
|
29
|
+
merged_args = { challenge: 10 }.merge(args)
|
30
|
+
# when target is nil, try to generate key from the last position
|
31
|
+
key = FractionalIndexer.generate_key(
|
32
|
+
prev_key: target&.send(option.field) || current_last_position
|
33
|
+
)
|
34
|
+
return key if valid?(key)
|
35
|
+
|
36
|
+
(merged_args[:challenge] || 0).times do
|
37
|
+
key += random_fractional
|
38
|
+
return key if valid?(key)
|
39
|
+
end
|
40
|
+
|
41
|
+
nil
|
42
|
+
rescue FractionalIndexer::Error
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
|
46
|
+
#
|
47
|
+
# Finds the position before the target position.
|
48
|
+
# If generated key is invalid(ex: it already exists),
|
49
|
+
# a new key is generated until the challenge count reaches the limit.
|
50
|
+
# challenge count is 10 by default.
|
51
|
+
#
|
52
|
+
# @example
|
53
|
+
# position = Position.new
|
54
|
+
# position.find_position_before(target, challenge: 5)
|
55
|
+
#
|
56
|
+
# @param target [#send(field)]
|
57
|
+
# @param args [Hash] Additional arguments.
|
58
|
+
# @option args [Integer] :challenge The number of times to attempt finding a valid position.
|
59
|
+
# @return [String, nil] The generated key for the position before the target, or nil if no valid position is found.
|
60
|
+
def find_position_before(target, **args)
|
61
|
+
merged_args = { challenge: 10 }.merge(args)
|
62
|
+
# when target is nil, try to generate key from the first position
|
63
|
+
key = FractionalIndexer.generate_key(
|
64
|
+
next_key: target&.send(option.field) || current_first_position
|
65
|
+
)
|
66
|
+
return key if valid?(key)
|
67
|
+
|
68
|
+
(merged_args[:challenge] || 0).times do
|
69
|
+
key += random_fractional
|
70
|
+
return key if valid?(key)
|
71
|
+
end
|
72
|
+
|
73
|
+
nil
|
74
|
+
rescue FractionalIndexer::Error
|
75
|
+
nil
|
76
|
+
end
|
77
|
+
|
78
|
+
# Finds the position between two targets.
|
79
|
+
#
|
80
|
+
# @param prev_target [#send(field)] The previous target.
|
81
|
+
# @param next_target [#send(field)] The next target.
|
82
|
+
# @param args [Hash] Additional arguments.
|
83
|
+
# @option args [Integer] :challenge The number of times to attempt finding a valid position.
|
84
|
+
# @return [string, nil] The position between the two targets, or nil if no valid position is found.
|
85
|
+
def find_position_between(prev_target, next_target, **args)
|
86
|
+
return find_position_before(next_target, **args) if prev_target.blank?
|
87
|
+
return find_position_after(prev_target, **args) if next_target.blank?
|
88
|
+
|
89
|
+
merged_args = { challenge: 10 }.merge(args)
|
90
|
+
|
91
|
+
prev_key, next_key = [ prev_target.send(option.field), next_target.send(option.field) ].minmax
|
92
|
+
key = FractionalIndexer.generate_key(
|
93
|
+
prev_key: prev_key,
|
94
|
+
next_key: next_key,
|
95
|
+
)
|
96
|
+
return key if valid?(key)
|
97
|
+
|
98
|
+
(merged_args[:challenge] || 0).times do
|
99
|
+
key += random_fractional
|
100
|
+
return key if valid?(key)
|
101
|
+
end
|
102
|
+
|
103
|
+
nil
|
104
|
+
rescue FractionalIndexer::Error
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
attr_reader :record, :option
|
111
|
+
|
112
|
+
def capable?(key)
|
113
|
+
option.key_max_size >= key.size
|
114
|
+
end
|
115
|
+
|
116
|
+
def current_first_position
|
117
|
+
model.merge(model_scope).minimum(option.field)
|
118
|
+
end
|
119
|
+
|
120
|
+
def current_last_position
|
121
|
+
model.merge(model_scope).maximum(option.field)
|
122
|
+
end
|
123
|
+
|
124
|
+
def model
|
125
|
+
record.class.base_class
|
126
|
+
end
|
127
|
+
|
128
|
+
# generate a random fractional part
|
129
|
+
#
|
130
|
+
# @return [String] The random fractional part.
|
131
|
+
# @see https://github.com/kazu-2020/fractional_indexer?tab=readme-ov-file#fractional-part
|
132
|
+
def random_fractional
|
133
|
+
# `fractional` represents the fractional part, but to ensure that the last digit is not zero value (ex: base_62 => '0'), the range is set to [1..].
|
134
|
+
FractionalIndexer.configuration.digits[1..].sample
|
135
|
+
end
|
136
|
+
|
137
|
+
def model_scope
|
138
|
+
model.where(record.slice(*option.scope))
|
139
|
+
end
|
140
|
+
|
141
|
+
def uniq?(key)
|
142
|
+
model.where(option.field => key).merge(model_scope).empty?
|
143
|
+
end
|
144
|
+
|
145
|
+
def valid?(key)
|
146
|
+
return false if key.blank?
|
147
|
+
|
148
|
+
capable?(key) && uniq?(key)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
data/lib/narabikae/version.rb
CHANGED
data/lib/narabikae.rb
CHANGED
@@ -1,6 +1,75 @@
|
|
1
1
|
require "narabikae/version"
|
2
|
-
|
2
|
+
|
3
|
+
require "narabikae/active_record_extension"
|
4
|
+
require "narabikae/configuration"
|
5
|
+
require "narabikae/option"
|
6
|
+
require "narabikae/option_store"
|
7
|
+
require "narabikae/position"
|
8
|
+
|
9
|
+
require "fractional_indexer"
|
10
|
+
require "active_support"
|
11
|
+
require "active_record"
|
3
12
|
|
4
13
|
module Narabikae
|
5
|
-
|
14
|
+
class Error < StandardError; end
|
15
|
+
|
16
|
+
@configuration = Narabikae::Configuration.new
|
17
|
+
|
18
|
+
def self.configure
|
19
|
+
yield configuration if block_given?
|
20
|
+
|
21
|
+
configuration
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.configuration
|
25
|
+
@configuration
|
26
|
+
end
|
27
|
+
|
28
|
+
module Extension
|
29
|
+
extend ActiveSupport::Concern
|
30
|
+
|
31
|
+
class_methods do
|
32
|
+
def narabikae(field = :position, size:, scope: [])
|
33
|
+
option = narabikae_option_store.register!(
|
34
|
+
field.to_sym,
|
35
|
+
Narabikae::Option.new(field: field, key_max_size: size, scope: scope)
|
36
|
+
)
|
37
|
+
|
38
|
+
before_create do
|
39
|
+
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
40
|
+
extension.set_position
|
41
|
+
end
|
42
|
+
|
43
|
+
before_update do
|
44
|
+
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
45
|
+
extension.set_position if extension.auto_set_position?
|
46
|
+
end
|
47
|
+
|
48
|
+
define_method :"move_to_#{field}_after" do |target = nil, **args|
|
49
|
+
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
50
|
+
extension.move_to_after(target, **args)
|
51
|
+
end
|
52
|
+
|
53
|
+
define_method :"move_to_#{field}_before" do |target = nil, **args|
|
54
|
+
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
55
|
+
extension.move_to_before(target, **args)
|
56
|
+
end
|
57
|
+
|
58
|
+
define_method :"move_to_#{field}_between" do |prev_target = nil, next_target = nil, **args|
|
59
|
+
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
60
|
+
extension.move_to_between(prev_target, next_target, **args)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def narabikae_option_store
|
67
|
+
@_narabikae_option_store ||= Narabikae::OptionStore.new
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
ActiveSupport.on_load :active_record do |base|
|
74
|
+
base.include Narabikae::Extension
|
6
75
|
end
|
metadata
CHANGED
@@ -1,35 +1,99 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: narabikae
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- matazou
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-10-
|
11
|
+
date: 2024-10-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: fractional_indexer
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - "
|
17
|
+
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
20
24
|
- - ">="
|
21
25
|
- !ruby/object:Gem::Version
|
22
|
-
version:
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activerecord
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
23
34
|
type: :runtime
|
24
35
|
prerelease: false
|
25
36
|
version_requirements: !ruby/object:Gem::Requirement
|
26
37
|
requirements:
|
27
|
-
- - "
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: activesupport
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec-rails
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 5.0.0
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
28
67
|
- !ruby/object:Gem::Version
|
29
|
-
version:
|
68
|
+
version: 5.0.0
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubocop-rails-omakase
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rubocop-rspec
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
30
94
|
- - ">="
|
31
95
|
- !ruby/object:Gem::Version
|
32
|
-
version:
|
96
|
+
version: '0'
|
33
97
|
description: 'provides functionality similar to acts_as_list. However, by managing
|
34
98
|
position using a fractional indexing system, it allows database record updates during
|
35
99
|
reordering to be completed with only a single update (N = 1)!
|
@@ -44,9 +108,13 @@ files:
|
|
44
108
|
- MIT-LICENSE
|
45
109
|
- README.md
|
46
110
|
- lib/narabikae.rb
|
47
|
-
- lib/narabikae/
|
111
|
+
- lib/narabikae/active_record_extension.rb
|
112
|
+
- lib/narabikae/active_record_handler.rb
|
113
|
+
- lib/narabikae/configuration.rb
|
114
|
+
- lib/narabikae/option.rb
|
115
|
+
- lib/narabikae/option_store.rb
|
116
|
+
- lib/narabikae/position.rb
|
48
117
|
- lib/narabikae/version.rb
|
49
|
-
- lib/tasks/narabikae_tasks.rake
|
50
118
|
homepage: https://github.com/kazu-2020/narabikae
|
51
119
|
licenses:
|
52
120
|
- MIT
|
@@ -72,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
140
|
- !ruby/object:Gem::Version
|
73
141
|
version: '0'
|
74
142
|
requirements: []
|
75
|
-
rubygems_version: 3.5.
|
143
|
+
rubygems_version: 3.5.9
|
76
144
|
signing_key:
|
77
145
|
specification_version: 4
|
78
146
|
summary: provides simple position management and sorting functionality for Active
|
data/lib/narabikae/railtie.rb
DELETED