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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07a44a8622d1ba78a90fa807d5947713bb47fdff0cb644973d8a5657ce37d453
4
- data.tar.gz: 17dc1892a77e52ea46b6753cd621d5a0fb9f45ffd2c1ab0adbbac138063437ed
3
+ metadata.gz: bc30700880dd0d9d8f7e463026575efd31fe4accee18068e7481332259a035e7
4
+ data.tar.gz: d973c99b9a1349a2e162bb4c5574bafac62c48f5726e73426a701896d28729d9
5
5
  SHA512:
6
- metadata.gz: 3a459cabd5079dd5c308726e40ad42fdcbe082e6b266e94220d27cf336f3b62da6d5aba39df39c443079533509a31c20ff7b5ebffca2bfc25dece8e340f6ef7f
7
- data.tar.gz: 9875163ef2ef2ffb5f48ffde782883b215875c498ef214027be373a88ac6789f8f172db6480ac50eb4e44804da4b9cdc1bfacefcbadfef57d5f41c2b5c1558f5
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
 
@@ -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: %i[course_id]
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
- option.scope.any? { |s| record.will_save_change_to_attribute?(s) } && !record.will_save_change_to_attribute?(option.field)
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
- record.send("#{option.field}=", position_generator.create_last_position)
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 move_to_after(target, **args)
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 move_to_before(target, **args)
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 move_to_between(prev_target, next_target, **args)
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 94.
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|
@@ -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
- def initialize(field:, key_max_size:, scope: [])
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 = scope.is_a?(Array) ? scope.map(&:to_sym) : []
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
@@ -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
- # @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.
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, **args)
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&.send(option.field) || current_last_position
32
- key = FractionalIndexer.generate_key(prev_key: target_key)
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
- (merged_args[:challenge] || 0).times do |i|
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
- # @example
53
- # position = Position.new
54
- # 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.
55
62
  #
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.
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, **args)
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&.send(option.field) || current_first_position
64
- key = FractionalIndexer.generate_key(next_key: target_key)
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
- (merged_args[:challenge] || 0).times do |i|
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 [#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.
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, **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?
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
- merged_args = { challenge: 10 }.merge(args)
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
- (merged_args[:challenge] || 0).times do |i|
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
@@ -1,3 +1,3 @@
1
1
  module Narabikae
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.1"
3
3
  end
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
- before_create do
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.set_position if extension.auto_set_position?
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.2.1
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: fractional_indexer
13
+ name: activerecord
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.4.0
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: 0.4.0
25
+ version: '7.1'
26
26
  - !ruby/object:Gem::Dependency
27
- name: activerecord
27
+ name: railties
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '6.1'
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: '6.1'
39
+ version: '7.1'
40
40
  - !ruby/object:Gem::Dependency
41
- name: activesupport
41
+ name: fractional_indexer
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '6.1'
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: '6.1'
53
+ version: 0.4.0
54
54
  - !ruby/object:Gem::Dependency
55
- name: rspec-rails
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: rubocop-rails-omakase
69
+ name: debug
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: 1.0.0
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.0.0
81
+ version: '1.9'
82
82
  - !ruby/object:Gem::Dependency
83
- name: rubocop-rspec
83
+ name: minitest
84
84
  requirement: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: 3.1.0
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: 3.1.0
95
+ version: '5.0'
96
96
  - !ruby/object:Gem::Dependency
97
- name: appraisal
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: 2.5.0
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: 2.5.0
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.0'
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