narabikae 0.3.0 → 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 +73 -4
- data/lib/narabikae/position.rb +43 -12
- data/lib/narabikae/version.rb +1 -1
- metadata +2 -2
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
|
|
|
@@ -189,7 +258,7 @@ target.position_after = tasks.last
|
|
|
189
258
|
target.position_between = [tasks.first, tasks.last]
|
|
190
259
|
```
|
|
191
260
|
|
|
192
|
-
|
|
261
|
+
### Form-friendly setters
|
|
193
262
|
|
|
194
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.
|
|
195
264
|
|
|
@@ -273,7 +342,7 @@ Feel free to message me on Github (kazu-2020)
|
|
|
273
342
|
|
|
274
343
|
### Supported versions (tested in CI)
|
|
275
344
|
|
|
276
|
-
- Ruby 3.
|
|
345
|
+
- Ruby 3.2, 3.3, 3.4, 4.0
|
|
277
346
|
- Rails 7.1, 7.2, 8.0, 8.1, and Rails main (via `railties` from `rails/rails`)
|
|
278
347
|
|
|
279
348
|
### Test suite
|
data/lib/narabikae/position.rb
CHANGED
|
@@ -24,9 +24,12 @@ module Narabikae
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Finds the position after the specified target.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
27
|
+
#
|
|
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.
|
|
30
33
|
#
|
|
31
34
|
# @param target [ActiveRecord::Base, String]
|
|
32
35
|
# @param challenge [Integer] The number of times to attempt finding a valid position.
|
|
@@ -34,7 +37,8 @@ module Narabikae
|
|
|
34
37
|
def find_position_after(target, challenge: 10)
|
|
35
38
|
# when target is nil, try to generate key from the last position
|
|
36
39
|
target_key = extract_target_key(target) || current_last_position
|
|
37
|
-
|
|
40
|
+
next_key = find_next_position_key(target_key)
|
|
41
|
+
key = FractionalIndexer.generate_key(prev_key: target_key, next_key: next_key)
|
|
38
42
|
return key if valid?(key)
|
|
39
43
|
|
|
40
44
|
(challenge || 0).times do |i|
|
|
@@ -48,15 +52,13 @@ module Narabikae
|
|
|
48
52
|
nil
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
#
|
|
52
55
|
# Finds the position before the target position.
|
|
53
|
-
# If generated key is invalid(ex: it already exists),
|
|
54
|
-
# a new key is generated until the challenge count reaches the limit.
|
|
55
|
-
# challenge count is 10 by default.
|
|
56
56
|
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
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.
|
|
60
62
|
#
|
|
61
63
|
# @param target [ActiveRecord::Base, String]
|
|
62
64
|
# @param challenge [Integer] The number of times to attempt finding a valid position.
|
|
@@ -64,7 +66,8 @@ module Narabikae
|
|
|
64
66
|
def find_position_before(target, challenge: 10)
|
|
65
67
|
# when target is nil, try to generate key from the first position
|
|
66
68
|
target_key = extract_target_key(target) || current_first_position
|
|
67
|
-
|
|
69
|
+
prev_key = find_prev_position_key(target_key)
|
|
70
|
+
key = FractionalIndexer.generate_key(prev_key: prev_key, next_key: target_key)
|
|
68
71
|
return key if valid?(key)
|
|
69
72
|
|
|
70
73
|
(challenge || 0).times do |i|
|
|
@@ -124,6 +127,34 @@ module Narabikae
|
|
|
124
127
|
model.merge(model_scope).maximum(option.field)
|
|
125
128
|
end
|
|
126
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
|
+
|
|
127
158
|
def model
|
|
128
159
|
record.class.base_class.unscoped
|
|
129
160
|
end
|
data/lib/narabikae/version.rb
CHANGED
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.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- matazou
|
|
@@ -200,7 +200,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
200
200
|
requirements:
|
|
201
201
|
- - ">="
|
|
202
202
|
- !ruby/object:Gem::Version
|
|
203
|
-
version: '3.
|
|
203
|
+
version: '3.2'
|
|
204
204
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
205
205
|
requirements:
|
|
206
206
|
- - ">="
|