narabikae 0.2.1 → 0.3.1
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 +172 -3
- data/lib/narabikae/active_record_extension.rb +32 -8
- data/lib/narabikae/configuration.rb +1 -1
- data/lib/narabikae/option.rb +10 -4
- data/lib/narabikae/position.rb +109 -38
- data/lib/narabikae/version.rb +1 -1
- data/lib/narabikae.rb +36 -6
- metadata +79 -24
- data/lib/narabikae/active_record_handler.rb +0 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc30700880dd0d9d8f7e463026575efd31fe4accee18068e7481332259a035e7
|
|
4
|
+
data.tar.gz: d973c99b9a1349a2e162bb4c5574bafac62c48f5726e73426a701896d28729d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d5da939a280875622f4b373e1c6a04343803cc47e346038f509f308cfba4bfc8d5674b7824218e98c738a876ee081cc2e18f1483104b62d000337d9b845b0c4c
|
|
7
|
+
data.tar.gz: 9f0a9b5871ccc39f3e1611bcbaf8c7638afd01fdb5569cc3db8495b390873854588bc4e8e8f6d3a540ccfa2c784b1a82b8b344575f4027087fddb26e3a659c45
|
data/README.md
CHANGED
|
@@ -4,7 +4,76 @@
|
|
|
4
4
|
|
|
5
5
|
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.
|
|
6
6
|
|
|
7
|
-
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
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
## Why Narabikae?
|
|
10
|
+
|
|
11
|
+
| Feature | acts_as_list | Narabikae |
|
|
12
|
+
|---------|--------------|-----------|
|
|
13
|
+
| Records updated on reorder | O(n) | O(1) |
|
|
14
|
+
| Position type | Integer | String |
|
|
15
|
+
| Algorithm | Sequential numbering | Fractional Indexing |
|
|
16
|
+
| Best for | Small lists, infrequent reordering | Large lists, frequent reordering |
|
|
17
|
+
|
|
18
|
+
**Example:** When moving an item to position 1 in a list of 10,000 items:
|
|
19
|
+
- **acts_as_list**: Updates up to 10,000 records to shift positions
|
|
20
|
+
- **Narabikae**: Updates only 1 record
|
|
21
|
+
|
|
22
|
+
## Table of Contents
|
|
23
|
+
|
|
24
|
+
- [Quick Start](#quick-start)
|
|
25
|
+
- [Installation](#installation)
|
|
26
|
+
- [Getting Started](#getting-started)
|
|
27
|
+
- [Adding a column to manage order](#adding-a-column-to-manage-order)
|
|
28
|
+
- [Adding configuration to your model](#adding-configuration-to-your-model)
|
|
29
|
+
- [Usage Details](#usage-details)
|
|
30
|
+
- [Methods Overview](#methods-overview)
|
|
31
|
+
- [Reorder](#reorder)
|
|
32
|
+
- [Set without saving](#set-without-saving)
|
|
33
|
+
- [Form-friendly setters](#form-friendly-setters)
|
|
34
|
+
- [Scope](#scope)
|
|
35
|
+
- [Retry generating position](#retry-generating-position)
|
|
36
|
+
- [Development](#development)
|
|
37
|
+
- [Contributing](#contributing)
|
|
38
|
+
- [License](#license)
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
Get up and running in 3 steps:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# 1. Add to Gemfile
|
|
46
|
+
gem "narabikae"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# 2. Create migration
|
|
51
|
+
add_column :tasks, :position, :string, null: false
|
|
52
|
+
add_index :tasks, :position, unique: true
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# 3. Add to your model
|
|
57
|
+
class Task < ApplicationRecord
|
|
58
|
+
narabikae :position, size: 255
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That's it! Your model now has automatic position management:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
Task.create([{ name: 'Task A' }, { name: 'Task B' }, { name: 'Task C' }])
|
|
66
|
+
Task.order(:position).pluck(:name)
|
|
67
|
+
# => ["Task A", "Task B", "Task C"]
|
|
68
|
+
|
|
69
|
+
# Move Task A after Task C
|
|
70
|
+
task_a = Task.find_by(name: 'Task A')
|
|
71
|
+
task_c = Task.find_by(name: 'Task C')
|
|
72
|
+
task_a.move_to_position_after(task_c)
|
|
73
|
+
|
|
74
|
+
Task.order(:position).pluck(:name)
|
|
75
|
+
# => ["Task B", "Task C", "Task A"]
|
|
76
|
+
```
|
|
8
77
|
|
|
9
78
|
## Installation
|
|
10
79
|
|
|
@@ -43,7 +112,7 @@ add_index :tasks, :position, unique: true
|
|
|
43
112
|
|
|
44
113
|
- Set the collation to distinguish between uppercase and lowercase letters.
|
|
45
114
|
|
|
46
|
-
For example, if using MySQL 8.0
|
|
115
|
+
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.
|
|
47
116
|
|
|
48
117
|
- It is recommended to apply both NOT NULL and UNIQUE constraints.
|
|
49
118
|
|
|
@@ -69,6 +138,10 @@ class Task < ApplicationRecord
|
|
|
69
138
|
# Used for validation of the internally generated order value.
|
|
70
139
|
# This value should be equivalent to
|
|
71
140
|
# the limit set in the DB column.
|
|
141
|
+
#
|
|
142
|
+
# default_position: optional
|
|
143
|
+
# Set where new/auto-set records are inserted.
|
|
144
|
+
# Accepts :first or :last (default).
|
|
72
145
|
end
|
|
73
146
|
```
|
|
74
147
|
|
|
@@ -88,6 +161,16 @@ Task.order(:position).pluck(:name, :position)
|
|
|
88
161
|
> [!NOTE]
|
|
89
162
|
> The position is set using the before_create callback. Therefore, do not define validations such as presence on the attributes managed by this gem!
|
|
90
163
|
|
|
164
|
+
#### Default position
|
|
165
|
+
|
|
166
|
+
By default, new/auto-set records are inserted at the end of the list. To insert at the beginning instead:
|
|
167
|
+
|
|
168
|
+
```rb
|
|
169
|
+
class Task < ApplicationRecord
|
|
170
|
+
narabikae :position, size: 200, default_position: :first
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
91
174
|
## Usage Details
|
|
92
175
|
|
|
93
176
|
### Reorder
|
|
@@ -157,6 +240,45 @@ target.position
|
|
|
157
240
|
# ex: target.move_to_position_between(tasks.first, nil)
|
|
158
241
|
```
|
|
159
242
|
|
|
243
|
+
### Set without saving
|
|
244
|
+
|
|
245
|
+
If you want to set the new position value and save later (for example, in a form), use `set_<field>_after/before/between`. These methods only assign the new position value and do not persist the record.
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
target.set_position_after(tasks.last)
|
|
249
|
+
target.position
|
|
250
|
+
# => 'a3'
|
|
251
|
+
target.save
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
You can also use setter-style aliases:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
target.position_after = tasks.last
|
|
258
|
+
target.position_between = [tasks.first, tasks.last]
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Form-friendly setters
|
|
262
|
+
|
|
263
|
+
The setter aliases can be used directly in forms or `assign_attributes`. They accept a target record or a position key (string). For `*_between=`, you can pass an array or hash. Primary key inputs are not accepted; do your own lookup and pass the record or its position.
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
# position key input (e.g., from a hidden field)
|
|
267
|
+
task.assign_attributes(position_after: tasks.last.position)
|
|
268
|
+
|
|
269
|
+
# between using an array
|
|
270
|
+
task.position_between = [tasks.first, tasks.last]
|
|
271
|
+
|
|
272
|
+
# between using a hash (string or symbol keys)
|
|
273
|
+
task.position_between = { prev: tasks.first.position, next: tasks.last.position }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
If you need retries, use the method form and pass `challenge` there:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
task.set_position_between(tasks.first, tasks.last, challenge: 15)
|
|
280
|
+
```
|
|
281
|
+
|
|
160
282
|
### Scope
|
|
161
283
|
|
|
162
284
|
You can use this when you want to manage independent positions within specific scopes, such as foreign keys.
|
|
@@ -170,7 +292,7 @@ end
|
|
|
170
292
|
class Chapter < ApplicationRecord
|
|
171
293
|
belongs_to :course
|
|
172
294
|
|
|
173
|
-
narabikae :position, size: 100, scope:
|
|
295
|
+
narabikae :position, size: 100, scope: :course_id
|
|
174
296
|
end
|
|
175
297
|
|
|
176
298
|
course = Course.create
|
|
@@ -216,6 +338,53 @@ ticket.move_to_position_between(t1, t2, challenge: 15)
|
|
|
216
338
|
|
|
217
339
|
Feel free to message me on Github (kazu-2020)
|
|
218
340
|
|
|
341
|
+
## Development
|
|
342
|
+
|
|
343
|
+
### Supported versions (tested in CI)
|
|
344
|
+
|
|
345
|
+
- Ruby 3.2, 3.3, 3.4, 4.0
|
|
346
|
+
- Rails 7.1, 7.2, 8.0, 8.1, and Rails main (via `railties` from `rails/rails`)
|
|
347
|
+
|
|
348
|
+
### Test suite
|
|
349
|
+
|
|
350
|
+
Tests are Minitest-based and run against a dummy Rails app located at `test/dummy`.
|
|
351
|
+
|
|
352
|
+
Database targets:
|
|
353
|
+
|
|
354
|
+
- `TARGET_DB=mysql` (default)
|
|
355
|
+
- `TARGET_DB=postgres`
|
|
356
|
+
- `TARGET_DB=sqlite`
|
|
357
|
+
|
|
358
|
+
To spin up database services locally:
|
|
359
|
+
|
|
360
|
+
```sh
|
|
361
|
+
docker compose up -d
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Run the full matrix locally (all databases):
|
|
365
|
+
|
|
366
|
+
```sh
|
|
367
|
+
bundle exec rake test
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Run a single database:
|
|
371
|
+
|
|
372
|
+
```sh
|
|
373
|
+
bundle exec rake test:postgres
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Run tests directly with Rails for a specific target:
|
|
377
|
+
|
|
378
|
+
```sh
|
|
379
|
+
TARGET_DB=sqlite bin/rails test
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
To test against a specific Rails version:
|
|
383
|
+
|
|
384
|
+
```sh
|
|
385
|
+
BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile TARGET_DB=mysql bundle exec rake test
|
|
386
|
+
```
|
|
387
|
+
|
|
219
388
|
## Contributing
|
|
220
389
|
|
|
221
390
|
Please wait a moment... 🙏
|
|
@@ -11,36 +11,60 @@ module Narabikae
|
|
|
11
11
|
# check valid key for fractional_indexer
|
|
12
12
|
# when invalid key, raise FractionalIndexer::Error
|
|
13
13
|
FractionalIndexer.generate_key(prev_key: record.send(option.field))
|
|
14
|
-
|
|
14
|
+
record.send(option.field).nil? ||
|
|
15
|
+
(option.scope.any? { |s| record.will_save_change_to_attribute?(s) } && !record.will_save_change_to_attribute?(option.field))
|
|
15
16
|
rescue FractionalIndexer::Error
|
|
16
17
|
true
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
def set_position
|
|
20
|
-
|
|
20
|
+
def set_position(position = option.default_position)
|
|
21
|
+
new_position =
|
|
22
|
+
case position
|
|
23
|
+
when :first
|
|
24
|
+
position_generator.create_first_position
|
|
25
|
+
when :last
|
|
26
|
+
position_generator.create_last_position
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
record.send("#{option.field}=", new_position)
|
|
21
30
|
end
|
|
22
31
|
|
|
23
|
-
def
|
|
32
|
+
def set_after(target, **args)
|
|
24
33
|
new_position = position_generator.find_position_after(target, **args)
|
|
25
34
|
return false if new_position.blank?
|
|
26
35
|
|
|
27
36
|
record.send("#{option.field}=", new_position)
|
|
28
|
-
record.save
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
def
|
|
39
|
+
def set_before(target, **args)
|
|
32
40
|
new_position = position_generator.find_position_before(target, **args)
|
|
33
41
|
return false if new_position.blank?
|
|
34
42
|
|
|
35
43
|
record.send("#{option.field}=", new_position)
|
|
36
|
-
record.save
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
def
|
|
46
|
+
def set_between(prev_target, next_target, **args)
|
|
40
47
|
new_position = position_generator.find_position_between(prev_target, next_target, **args)
|
|
41
48
|
return false if new_position.blank?
|
|
42
49
|
|
|
43
50
|
record.send("#{option.field}=", new_position)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def move_to_after(target, **args)
|
|
54
|
+
return false unless set_after(target, **args)
|
|
55
|
+
|
|
56
|
+
record.save
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def move_to_before(target, **args)
|
|
60
|
+
return false unless set_before(target, **args)
|
|
61
|
+
|
|
62
|
+
record.save
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def move_to_between(prev_target, next_target, **args)
|
|
66
|
+
return false unless set_between(prev_target, next_target, **args)
|
|
67
|
+
|
|
44
68
|
record.save
|
|
45
69
|
end
|
|
46
70
|
|
|
@@ -2,7 +2,7 @@ module Narabikae
|
|
|
2
2
|
class Configuration
|
|
3
3
|
# Sets the base value for FractionalIndexer configuration.
|
|
4
4
|
#
|
|
5
|
-
# @param int [Integer] The base value can be 10, 62, 94, with the default being
|
|
5
|
+
# @param int [Integer] The base value can be 10, 62, 94, with the default being 62.
|
|
6
6
|
# @return [void]
|
|
7
7
|
def base=(int)
|
|
8
8
|
FractionalIndexer.configure do |config|
|
data/lib/narabikae/option.rb
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
module Narabikae
|
|
2
2
|
class Option
|
|
3
|
-
attr_reader :field, :key_max_size, :scope
|
|
3
|
+
attr_reader :field, :key_max_size, :scope, :default_position
|
|
4
4
|
|
|
5
5
|
# Initializes a new instance of the Option class.
|
|
6
6
|
#
|
|
7
7
|
# @param field [Symbol]
|
|
8
8
|
# @param key_max_size [Integer] The maximum size of the key.
|
|
9
|
-
# @param scope [Array<Symbol>] The scope of the option.
|
|
10
|
-
|
|
9
|
+
# @param scope [Symbol, Array<Symbol>] The scope of the option.
|
|
10
|
+
# @param default_position [Symbol] The default position when creating or auto setting.
|
|
11
|
+
def initialize(field:, key_max_size:, scope: [], default_position: :last)
|
|
11
12
|
@field = field.to_sym
|
|
12
13
|
@key_max_size = key_max_size.to_i
|
|
13
|
-
@scope =
|
|
14
|
+
@scope = Array.wrap(scope).map(&:to_sym)
|
|
15
|
+
@default_position = (default_position || :last).to_sym
|
|
16
|
+
|
|
17
|
+
unless %i[first last].include?(@default_position)
|
|
18
|
+
raise ArgumentError, "default_position must be :first or :last"
|
|
19
|
+
end
|
|
14
20
|
end
|
|
15
21
|
end
|
|
16
22
|
end
|
data/lib/narabikae/position.rb
CHANGED
|
@@ -16,23 +16,32 @@ module Narabikae
|
|
|
16
16
|
FractionalIndexer.generate_key(prev_key: current_last_position)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Generates a new key for the first position
|
|
20
|
+
#
|
|
21
|
+
# @return [String] The newly generated key for the first position.
|
|
22
|
+
def create_first_position
|
|
23
|
+
FractionalIndexer.generate_key(next_key: current_first_position)
|
|
24
|
+
end
|
|
25
|
+
|
|
19
26
|
# 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
27
|
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
28
|
+
# Uses an optimized neighbor-aware approach: queries the database for the
|
|
29
|
+
# next record's position and generates a key between the target and its
|
|
30
|
+
# neighbor. This avoids blind key generation and reduces collision retries.
|
|
31
|
+
#
|
|
32
|
+
# Falls back to retry with random fractional for concurrent write race conditions.
|
|
33
|
+
#
|
|
34
|
+
# @param target [ActiveRecord::Base, String]
|
|
35
|
+
# @param challenge [Integer] The number of times to attempt finding a valid position.
|
|
27
36
|
# @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,
|
|
29
|
-
merged_args = { challenge: 10 }.merge(args)
|
|
37
|
+
def find_position_after(target, challenge: 10)
|
|
30
38
|
# when target is nil, try to generate key from the last position
|
|
31
|
-
target_key = target
|
|
32
|
-
|
|
39
|
+
target_key = extract_target_key(target) || current_last_position
|
|
40
|
+
next_key = find_next_position_key(target_key)
|
|
41
|
+
key = FractionalIndexer.generate_key(prev_key: target_key, next_key: next_key)
|
|
33
42
|
return key if valid?(key)
|
|
34
43
|
|
|
35
|
-
(
|
|
44
|
+
(challenge || 0).times do |i|
|
|
36
45
|
key = FractionalIndexer.generate_key(prev_key: target_key, next_key: key)
|
|
37
46
|
key += random_fractional
|
|
38
47
|
return key if valid?(key)
|
|
@@ -43,28 +52,25 @@ module Narabikae
|
|
|
43
52
|
nil
|
|
44
53
|
end
|
|
45
54
|
|
|
46
|
-
#
|
|
47
55
|
# 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
56
|
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
57
|
+
# Uses an optimized neighbor-aware approach: queries the database for the
|
|
58
|
+
# previous record's position and generates a key between the neighbor and
|
|
59
|
+
# the target. This avoids blind key generation and reduces collision retries.
|
|
60
|
+
#
|
|
61
|
+
# Falls back to retry with random fractional for concurrent write race conditions.
|
|
55
62
|
#
|
|
56
|
-
# @param target [
|
|
57
|
-
# @param
|
|
58
|
-
# @option args [Integer] :challenge The number of times to attempt finding a valid position.
|
|
63
|
+
# @param target [ActiveRecord::Base, String]
|
|
64
|
+
# @param challenge [Integer] The number of times to attempt finding a valid position.
|
|
59
65
|
# @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,
|
|
61
|
-
merged_args = { challenge: 10 }.merge(args)
|
|
66
|
+
def find_position_before(target, challenge: 10)
|
|
62
67
|
# when target is nil, try to generate key from the first position
|
|
63
|
-
target_key = target
|
|
64
|
-
|
|
68
|
+
target_key = extract_target_key(target) || current_first_position
|
|
69
|
+
prev_key = find_prev_position_key(target_key)
|
|
70
|
+
key = FractionalIndexer.generate_key(prev_key: prev_key, next_key: target_key)
|
|
65
71
|
return key if valid?(key)
|
|
66
72
|
|
|
67
|
-
(
|
|
73
|
+
(challenge || 0).times do |i|
|
|
68
74
|
key = FractionalIndexer.generate_key(prev_key: key, next_key: target_key)
|
|
69
75
|
key += random_fractional
|
|
70
76
|
return key if valid?(key)
|
|
@@ -77,25 +83,24 @@ module Narabikae
|
|
|
77
83
|
|
|
78
84
|
# Finds the position between two targets.
|
|
79
85
|
#
|
|
80
|
-
# @param prev_target [
|
|
81
|
-
# @param next_target [
|
|
82
|
-
# @param
|
|
83
|
-
# @option args [Integer] :challenge The number of times to attempt finding a valid position.
|
|
86
|
+
# @param prev_target [ActiveRecord::Base, String] The previous target.
|
|
87
|
+
# @param next_target [ActiveRecord::Base, String] The next target.
|
|
88
|
+
# @param challenge [Integer] The number of times to attempt finding a valid position.
|
|
84
89
|
# @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,
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
def find_position_between(prev_target, next_target, challenge: 10)
|
|
91
|
+
prev_key = extract_target_key(prev_target)
|
|
92
|
+
next_key = extract_target_key(next_target)
|
|
93
|
+
return find_position_before(next_target, challenge: challenge) if prev_key.blank?
|
|
94
|
+
return find_position_after(prev_target, challenge: challenge) if next_key.blank?
|
|
88
95
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
prev_key, next_key = [ prev_target.send(option.field), next_target.send(option.field) ].minmax
|
|
96
|
+
prev_key, next_key = [ prev_key, next_key ].minmax
|
|
92
97
|
key = FractionalIndexer.generate_key(
|
|
93
98
|
prev_key: prev_key,
|
|
94
99
|
next_key: next_key,
|
|
95
100
|
)
|
|
96
101
|
return key if valid?(key)
|
|
97
102
|
|
|
98
|
-
(
|
|
103
|
+
(challenge || 0).times do |i|
|
|
99
104
|
key = FractionalIndexer.generate_key(prev_key: key, next_key: next_key)
|
|
100
105
|
key += random_fractional
|
|
101
106
|
return key if valid?(key)
|
|
@@ -122,8 +127,36 @@ module Narabikae
|
|
|
122
127
|
model.merge(model_scope).maximum(option.field)
|
|
123
128
|
end
|
|
124
129
|
|
|
130
|
+
# Finds the position key of the next record after the given key within scope.
|
|
131
|
+
# Uses an indexed query for O(log n) lookup.
|
|
132
|
+
#
|
|
133
|
+
# @param key [String] The position key to search after.
|
|
134
|
+
# @return [String, nil] The next position key, or nil if no record exists after.
|
|
135
|
+
def find_next_position_key(key)
|
|
136
|
+
return nil if key.nil?
|
|
137
|
+
|
|
138
|
+
model.merge(model_scope)
|
|
139
|
+
.where(model.arel_table[option.field].gt(key))
|
|
140
|
+
.order(option.field => :asc)
|
|
141
|
+
.pick(option.field)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Finds the position key of the previous record before the given key within scope.
|
|
145
|
+
# Uses an indexed query for O(log n) lookup.
|
|
146
|
+
#
|
|
147
|
+
# @param key [String] The position key to search before.
|
|
148
|
+
# @return [String, nil] The previous position key, or nil if no record exists before.
|
|
149
|
+
def find_prev_position_key(key)
|
|
150
|
+
return nil if key.nil?
|
|
151
|
+
|
|
152
|
+
model.merge(model_scope)
|
|
153
|
+
.where(model.arel_table[option.field].lt(key))
|
|
154
|
+
.order(option.field => :desc)
|
|
155
|
+
.pick(option.field)
|
|
156
|
+
end
|
|
157
|
+
|
|
125
158
|
def model
|
|
126
|
-
record.class.base_class
|
|
159
|
+
record.class.base_class.unscoped
|
|
127
160
|
end
|
|
128
161
|
|
|
129
162
|
# generate a random fractional part
|
|
@@ -148,5 +181,43 @@ module Narabikae
|
|
|
148
181
|
|
|
149
182
|
capable?(key) && uniq?(key)
|
|
150
183
|
end
|
|
184
|
+
|
|
185
|
+
def extract_target_key(target)
|
|
186
|
+
return if target.nil?
|
|
187
|
+
return target if target.is_a?(String)
|
|
188
|
+
unless target.is_a?(ActiveRecord::Base)
|
|
189
|
+
raise Narabikae::Error, "target must be an ActiveRecord object or position key string"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
record_table = table_name_for_class(record)
|
|
193
|
+
target_table = table_name_for_class(target)
|
|
194
|
+
unless target_table && record_table && target_table == record_table
|
|
195
|
+
raise Narabikae::Error,
|
|
196
|
+
"target model mismatch: expected table #{record_table || 'unknown'}, got #{target_table || 'unknown'}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
mismatched_columns = mismatched_scope_columns(target)
|
|
200
|
+
if mismatched_columns.any?
|
|
201
|
+
raise Narabikae::Error, "target scope mismatch for columns: #{mismatched_columns.join(', ')}"
|
|
202
|
+
end
|
|
203
|
+
raise Narabikae::Error, "target missing #{option.field} field" unless target.respond_to?(option.field)
|
|
204
|
+
|
|
205
|
+
target.send(option.field)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def table_name_for_class(value)
|
|
209
|
+
klass = value.class
|
|
210
|
+
return unless klass.respond_to?(:table_name)
|
|
211
|
+
|
|
212
|
+
klass.table_name
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def mismatched_scope_columns(target)
|
|
216
|
+
option.scope.select do |column|
|
|
217
|
+
!target.respond_to?(column) ||
|
|
218
|
+
!record.respond_to?(column) ||
|
|
219
|
+
target.public_send(column) != record.public_send(column)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
151
222
|
end
|
|
152
223
|
end
|
data/lib/narabikae/version.rb
CHANGED
data/lib/narabikae.rb
CHANGED
|
@@ -29,20 +29,50 @@ module Narabikae
|
|
|
29
29
|
extend ActiveSupport::Concern
|
|
30
30
|
|
|
31
31
|
class_methods do
|
|
32
|
-
def narabikae(field = :position, size:, scope: [])
|
|
32
|
+
def narabikae(field = :position, size:, scope: [], default_position: :last)
|
|
33
33
|
option = narabikae_option_store.register!(
|
|
34
34
|
field.to_sym,
|
|
35
|
-
Narabikae::Option.new(field: field, key_max_size: size, scope: scope)
|
|
35
|
+
Narabikae::Option.new(field: field, key_max_size: size, scope: scope, default_position: default_position)
|
|
36
36
|
)
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
before_save -> {
|
|
39
39
|
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
|
40
|
-
extension.set_position
|
|
40
|
+
extension.set_position(option.default_position) if extension.auto_set_position?
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
define_method :"set_#{field}_after" do |target = nil, **args|
|
|
44
|
+
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
|
45
|
+
extension.set_after(target, **args)
|
|
46
|
+
end
|
|
47
|
+
alias_method :"#{field}_after=", :"set_#{field}_after"
|
|
48
|
+
|
|
49
|
+
define_method :"set_#{field}_before" do |target = nil, **args|
|
|
50
|
+
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
|
51
|
+
extension.set_before(target, **args)
|
|
52
|
+
end
|
|
53
|
+
alias_method :"#{field}_before=", :"set_#{field}_before"
|
|
54
|
+
|
|
55
|
+
define_method :"set_#{field}_between" do |prev_target = nil, next_target = nil, **args|
|
|
56
|
+
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
|
57
|
+
extension.set_between(prev_target, next_target, **args)
|
|
41
58
|
end
|
|
59
|
+
define_method :"#{field}_between=" do |value|
|
|
60
|
+
prev_target = nil
|
|
61
|
+
next_target = nil
|
|
62
|
+
|
|
63
|
+
case value
|
|
64
|
+
when Array
|
|
65
|
+
prev_target, next_target = value
|
|
66
|
+
when Hash
|
|
67
|
+
payload = value.with_indifferent_access
|
|
68
|
+
prev_target = payload[:prev_target] || payload[:prev]
|
|
69
|
+
next_target = payload[:next_target] || payload[:next]
|
|
70
|
+
else
|
|
71
|
+
prev_target = value
|
|
72
|
+
end
|
|
42
73
|
|
|
43
|
-
before_update do
|
|
44
74
|
extension = Narabikae::ActiveRecordExtension.new(self, option)
|
|
45
|
-
extension.
|
|
75
|
+
extension.set_between(prev_target, next_target)
|
|
46
76
|
end
|
|
47
77
|
|
|
48
78
|
define_method :"move_to_#{field}_after" do |target = nil, **args|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: narabikae
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- matazou
|
|
@@ -10,49 +10,49 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
13
|
+
name: activerecord
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: '7.1'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
25
|
+
version: '7.1'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: railties
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '7.1'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
39
|
+
version: '7.1'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
|
-
name:
|
|
41
|
+
name: fractional_indexer
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version:
|
|
46
|
+
version: 0.4.0
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version:
|
|
53
|
+
version: 0.4.0
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
|
-
name:
|
|
55
|
+
name: appraisal
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
58
|
- - ">="
|
|
@@ -66,47 +66,103 @@ dependencies:
|
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '0'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
|
-
name:
|
|
69
|
+
name: debug
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 1.
|
|
74
|
+
version: '1.9'
|
|
75
75
|
type: :development
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 1.
|
|
81
|
+
version: '1.9'
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
|
-
name:
|
|
83
|
+
name: minitest
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
85
85
|
requirements:
|
|
86
86
|
- - "~>"
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version:
|
|
88
|
+
version: '5.0'
|
|
89
89
|
type: :development
|
|
90
90
|
prerelease: false
|
|
91
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
92
|
requirements:
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
|
-
version:
|
|
95
|
+
version: '5.0'
|
|
96
96
|
- !ruby/object:Gem::Dependency
|
|
97
|
-
name:
|
|
97
|
+
name: mocha
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: mysql2
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: pg
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
type: :development
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: sqlite3
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0'
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - ">="
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '0'
|
|
152
|
+
- !ruby/object:Gem::Dependency
|
|
153
|
+
name: rubocop-rails-omakase
|
|
98
154
|
requirement: !ruby/object:Gem::Requirement
|
|
99
155
|
requirements:
|
|
100
|
-
- -
|
|
156
|
+
- - ">="
|
|
101
157
|
- !ruby/object:Gem::Version
|
|
102
|
-
version:
|
|
158
|
+
version: '0'
|
|
103
159
|
type: :development
|
|
104
160
|
prerelease: false
|
|
105
161
|
version_requirements: !ruby/object:Gem::Requirement
|
|
106
162
|
requirements:
|
|
107
|
-
- -
|
|
163
|
+
- - ">="
|
|
108
164
|
- !ruby/object:Gem::Version
|
|
109
|
-
version:
|
|
165
|
+
version: '0'
|
|
110
166
|
description: 'provides functionality similar to acts_as_list. However, by managing
|
|
111
167
|
position using a fractional indexing system, it allows database record updates during
|
|
112
168
|
reordering to be completed with only a single update (N = 1)!
|
|
@@ -122,7 +178,6 @@ files:
|
|
|
122
178
|
- README.md
|
|
123
179
|
- lib/narabikae.rb
|
|
124
180
|
- lib/narabikae/active_record_extension.rb
|
|
125
|
-
- lib/narabikae/active_record_handler.rb
|
|
126
181
|
- lib/narabikae/configuration.rb
|
|
127
182
|
- lib/narabikae/option.rb
|
|
128
183
|
- lib/narabikae/option_store.rb
|
|
@@ -145,7 +200,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
145
200
|
requirements:
|
|
146
201
|
- - ">="
|
|
147
202
|
- !ruby/object:Gem::Version
|
|
148
|
-
version: '3.
|
|
203
|
+
version: '3.2'
|
|
149
204
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
150
205
|
requirements:
|
|
151
206
|
- - ">="
|
|
@@ -1,31 +0,0 @@
|
|
|
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
|