-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathresort.rb
More file actions
245 lines (225 loc) · 7.46 KB
/
resort.rb
File metadata and controls
245 lines (225 loc) · 7.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# frozen_string_literal: true
require 'generators/active_record/resort_generator' if defined?(Rails)
require 'active_record' unless defined?(ActiveRecord)
# # Resort
#
# A tool that allows any ActiveRecord model to be sorted.
#
# Unlike most Rails sorting plugins (acts_as_list, etc), Resort is based
# on linked lists rather than absolute position fields.
#
# @example Using Resort in an ActiveRecord model
# # In the migration
# create_table :products do |t|
# t.text :name
# t.references :next
# t.boolean :first
# end
#
# # Model
# class Product < ActiveRecord::Base
# resort!
#
# # A sortable model must implement #siblings method, which should
# # return and ActiveRecord::Relation with all the models to be
# # considered as `peers` in the list representing the sorted
# # products, i.e. its siblings.
# def siblings
# self.class.all
# end
# end
#
# product = Product.create(:name => 'Bread')
# product.first? # => true
#
# another_product = Product.create(:name => 'Milk')
# yet_another_product = Product.create(:name => 'Salami')
#
# yet_another_product.append_to(product)
#
# Product.ordered.map(&:name)
# # => ['Bread', 'Salami', 'Milk']
module Resort
# The module encapsulating all the Resort functionality.
#
# @todo Refactor into a more OO solution, maybe implementing a LinkedList
# object.
module Sortable
class << self
# When included, extends the includer with {ClassMethods}, and includes
# {InstanceMethods} in it.
#
# It also establishes the required relationships. It is necessary that
# the includer table has the following database columns:
#
# t.references :next
# t.boolean :first
#
# @param [ActiveRecord::Base] base the includer `ActiveRecord` model.
def included(base)
base.extend ClassMethods
base.send :include, InstanceMethods
base.has_one :previous, class_name: base.name, foreign_key: 'next_id', inverse_of: :next
base.belongs_to :next, class_name: base.name, inverse_of: :previous, optional: true
base.after_create :include_in_list!
base.after_destroy :delete_from_list
end
end
# Class methods to be used from the model class.
module ClassMethods
# Returns the first element of the list.
#
# @return [ActiveRecord::Base] the first element of the list.
def first_in_order
all.where(first: true).first
end
# Returns the last element of the list.
#
# @return [ActiveRecord::Base] the last element of the list.
def last_in_order
all.where(next_id: nil).first
end
# Returns eager-loaded Components in order.
#
# OPTIMIZE: Use IdentityMap when available
# @return [Array<ActiveRecord::Base>] the ordered elements
def ordered
ordered_elements = []
elements = {}
all.each do |element|
if ordered_elements.empty? && element.first?
ordered_elements << element
else
elements[element.id] = element
end
end
raise 'Multiple or no first items in the list where found. Consider defining a siblings method' if ordered_elements.length != 1 && elements.length > 0
elements.length.times do
ordered_elements << elements[ordered_elements.last.next_id]
end
ordered_elements.compact
end
end
# Instance methods to use.
module InstanceMethods
# Default definition of siblings, i.e. every instance of the model.
#
# Can be overriden to specify a different scope for the siblings.
# For example, if we wanted to limit a products tree inside a ProductLine
# scope, we would do the following:
#
# class Product < ActiveRecord::Base
# belongs_to :product_line
#
# resort!
#
# def siblings
# self.product_line.products
# end
#
# This way, every product line is an independent tree of sortable
# products.
#
# @return [ActiveRecord::Relation] the element's siblings relation.
def siblings
self.class.all
end
# Includes the object in the linked list.
#
# If there are no other objects, it prepends the object so that it is
# in the first position. Otherwise, it appends it to the end of the
# empty list.
def include_in_list!
self.class.transaction do
save
lock!
_siblings.count > 0 ? last!\
: prepend
end
end
# Puts the object in the first position of the list.
def prepend
self.class.transaction do
save
lock!
return if first?
if _siblings.count > 0
delete_from_list
old_first = _siblings.first_in_order
raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't set next_id from previous first element." unless update_attribute(:next_id, old_first.id)
raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't reset previous first element" unless old_first.update_attribute(:first, false)
end
raise(ActiveRecord::RecordNotSaved) unless update_attribute(:first, true)
end
end
# Puts the object in the last position of the list.
def push
self.class.transaction do
save
lock!
append_to(_siblings.last_in_order) unless last?
end
end
# Puts the object right after another object in the list.
def append_to(another)
self.class.transaction do
save
lock!
return if another.next_id == id
another.save
another.lock!
delete_from_list
if next_id || (another && another.next_id)
raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't append element" unless update_attribute(:next_id, another.next_id)
end
if another
raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't set this element to another's next" unless another.update_attribute(:next_id, id)
end
end
end
def last?
!first && !next_id
end
def last!
self.class.transaction do
save
lock!
raise(ActiveRecord::RecordNotSaved) unless _siblings.last_in_order.update_attribute(:next_id, id)
end
end
private
def delete_from_list
if first? && self.next
self.next.save
self.next.lock!
raise(ActiveRecord::RecordNotSaved) unless self.next.update_attribute(:first, true)
elsif previous
previous.save
previous.lock!
p = previous
self.previous = nil unless frozen?
raise(ActiveRecord::RecordNotSaved) unless p.update_column(:next_id, next_id)
end
unless frozen?
self.first = false
self.next = nil
save!
end
end
def _siblings
table = self.class.arel_table
siblings.where(table[:id].not_eq(id))
end
end
end
# Helper class methods to be injected into ActiveRecord::Base class.
# They will be available to every model.
module ClassMethods
# Helper class method to include Resort::Sortable in an ActiveRecord
# model.
def resort!
include Sortable
end
end
end
ActiveRecord::Base.extend Resort::ClassMethods