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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 889a0fb05988806d1ff5ab57e02932cf4bf8a57ad788e3a4f094456e15e57902
4
- data.tar.gz: 1dfb405bc128aacd6bae61b89e133af0aa40cf67b16638ba475bb1933584c855
3
+ metadata.gz: bc30700880dd0d9d8f7e463026575efd31fe4accee18068e7481332259a035e7
4
+ data.tar.gz: d973c99b9a1349a2e162bb4c5574bafac62c48f5726e73426a701896d28729d9
5
5
  SHA512:
6
- metadata.gz: 0c2e99432062adc3745d8c6641d99bbd7a2b91a021103c552f3c4f9a7d6678370c344af81adf7d10a0ba3103730070bd8f7370fcb9c99a99dd06c8d01d89e882
7
- data.tar.gz: 0aed46f7bf0ed8ae057324e17b99799d132e77244382ba0d945476221d3edb0d7c2f9444f2281b0d28a9e85e17fce9da1cb0eba13c27c613e4e4fe5da629bdaa
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.0s default collation (utf8mb4_0900_ai_ci), which does not distinguish between uppercase and lowercase, the sort results may not behave as expected.
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
- #### Form-friendly setters
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.1, 3.2, 3.3, 3.4, 4.0
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
@@ -24,9 +24,12 @@ module Narabikae
24
24
  end
25
25
 
26
26
  # Finds the position after the specified target.
27
- # If generated key is invalid(ex: it already exists),
28
- # a new key is generated until the challenge count reaches the limit.
29
- # challenge count is 10 by default.
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
- key = FractionalIndexer.generate_key(prev_key: target_key)
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
- # @example
58
- # position = Position.new
59
- # position.find_position_before(target, challenge: 5)
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
- key = FractionalIndexer.generate_key(next_key: target_key)
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
@@ -1,3 +1,3 @@
1
1
  module Narabikae
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
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.0
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.1'
203
+ version: '3.2'
204
204
  required_rubygems_version: !ruby/object:Gem::Requirement
205
205
  requirements:
206
206
  - - ">="