positionable 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ coverage/
6
+ spec/*.sqlite3
7
+ doc/
8
+ .DS_Store
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ rvm: 1.9.2
2
+ script: "bundle exec rspec spec"
3
+ branches:
4
+ only:
5
+ - master
6
+ notifications:
7
+ email: false
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in positionable.gemspec
4
+ gemspec
data/LICENCE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Philippe Guégan
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,117 @@
1
+ = Positionable
2
+
3
+ {<img src="https://secure.travis-ci.org/pguegan/positionable.png" />}[http://travis-ci.org/pguegan/positionable]
4
+
5
+ <b>Positionable</b> is a library which provides contiguous positionning capabilities to your ActiveRecord models.
6
+
7
+ For more functionalities, you could also have a look at acts_as_list[https://github.com/swanandp/acts_as_list].
8
+
9
+ == Installation
10
+
11
+ Edit your Gemfile, and simply add the following line:
12
+
13
+ gem 'positionable'
14
+
15
+ == Getting Started
16
+
17
+ Let's say you want to make the model +Item+ positionable.
18
+
19
+ === Create a migration
20
+
21
+ First, create a migration to add the column +position+ to the table +items+:
22
+
23
+ rails generate migration add_position_to_items position:integer
24
+
25
+ Then, run the migration:
26
+
27
+ rake db:migrate
28
+
29
+ === Setup your model
30
+
31
+ Simply add the +is_positionable+ method in your ActiveRecord model:
32
+
33
+ class Item < ActiveRecord::Base
34
+ is_positionable
35
+ end
36
+
37
+ ==== Grouping records
38
+
39
+ Maybe your items are grouped (typically with a +belongs_to+ association). In this case, you'll want to restrict the position in each group by declaring the +:scope+ option:
40
+
41
+ class Item < ActiveRecord::Base
42
+ belongs_to :folder
43
+ is_positionable :scope => :folder
44
+ attr_accessible :folder_id, :position
45
+ end
46
+
47
+ Note that it is the model responsibility to give the white-list of attributes that can be updated <em>via</em> mass-assignement. In this case, <b>you must</b> add the +position+ attribute in the +attr_accessible+ clause.
48
+
49
+ ==== Start position
50
+
51
+ By default, position starts by zero. But you may want to change this at the model level, for instance by starting at one (which seems more natural for some people):
52
+
53
+ class Item < ActiveRecord::Base
54
+ is_positionable :start => 1
55
+ end
56
+
57
+ ==== Ordering
58
+
59
+ When a new record is created, it is inserted by default at the last (highest) position of its group. Thus, when record are listed, the newly created record will appear at the bottom.
60
+
61
+ It is possible to change this behaviour by setting the +order+ option as follows:
62
+
63
+ class Item < ActiveRecord::Base
64
+ is_positionable :order => :desc
65
+ end
66
+
67
+ This way, records are always listed by descending positions order. Record that have the highest position will appears at the top.
68
+
69
+ <b>Caution!</b> The semantic of +next+ or +previous+ methods remains unchanged. More precisely, even if the highest position is the position of the <em>first</em> returned record, it is still considered as the <em>last</em> one (<em>i.e.:</em> +Item.first.last?+ returns +true+). I know, this is odd. The semantic of these methods will certainly change in a further version.
70
+
71
+ ==== Mixing options
72
+
73
+ Obviously, these options are not exclusive. You are free to mix them like, for example:
74
+
75
+ class Item < ActiveRecord::Base
76
+ belongs_to :folder
77
+ is_positionable :scope => :folder, :order => :desc, :start => 1
78
+ end
79
+
80
+ == Usage
81
+
82
+ === Querying
83
+
84
+ To get the previous or next sibling items:
85
+
86
+ previous = item.previous
87
+ all_previous = item.all_previous
88
+ next = item.next
89
+ all_next = item.all_next
90
+
91
+ Both first and last items can be caracterized:
92
+
93
+ item.first? # True if item.previous is nil
94
+ item.last? # True if item.next is nil
95
+
96
+ Given a positionable item, its position is always included in a range which can be determined as follows:
97
+
98
+ item.range
99
+
100
+ If this item is aimed at being moved to another different scope, then you can pass this new scope as a parameter:
101
+
102
+ item.range(folder)
103
+
104
+ === Moving
105
+
106
+ Rather than directly assign position attribute, you can move your items with these provided methods:
107
+
108
+ item = Item.create(...) # The newly created item is put at the last position (by default).
109
+ item.up! # Item's position is swaped with the previous item.
110
+ item.down! # Item's position is swaped with the next item.
111
+ item.move_to new_position # Moves this record to the given position, and updates sibling items positions accordingly.
112
+
113
+ Yet, it is possible to update the item's position <em>via</em> mass-assignement:
114
+
115
+ item.update_attributes { :position => new_position, ... }
116
+
117
+ This will trigger some ActiveRecord callbacks in order to maintain positions contiguity across all other concerned items, even if the item is moved from a scope to another.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,314 @@
1
+ require "positionable/version"
2
+ require 'active_record'
3
+
4
+ # <b>Positionable</b> is a library which provides contiguous positionning capabilities to your
5
+ # ActiveRecord models. This module is designed to be an ActiveRecord extension.
6
+ #
7
+ # Calling the <tt>is_positionable</tt> method in your ActiveRecord model will inject
8
+ # positionning capabilities. In particular, this will guarantee your records' positions
9
+ # to be <em>contiguous</em>, ie.: there is no 'hole' between two adjacent positions.
10
+ #
11
+ # Positionable has a strong management of records that belong to a group (a.k.a. scope). When a
12
+ # record is moved whithin its scope, or from a scope to another, other impacted records are
13
+ # also reordered accordingly.
14
+ #
15
+ # You can use the provided instance methods (<tt>up!</tt>, <tt>down!</tt> or <tt>move_to</tt>)
16
+ # to move or reorder your records, whereas it is possible to update the position <em>via</em>
17
+ # mass-assignement. In particular, <tt>update_attributes({:position => new_position})</tt> will
18
+ # trigger some ActiveRecord callbacks in order to maintain positions' contiguity.
19
+ #
20
+ # Additional methods are available to query your model: check if this the <tt>last?</tt> or
21
+ # <tt>first?</tt> of its own scope, retrieve the <tt>previous</tt> or the <tt>next</tt> records
22
+ # according to their positions, etc.
23
+ module Positionable
24
+
25
+ def self.included(base)
26
+ base.extend(PositionableMethods)
27
+ end
28
+
29
+ module PositionableMethods
30
+
31
+ # Makes this model positionable.
32
+ #
33
+ # class Item < ActiveRecord::Base
34
+ # is_positionable
35
+ # end
36
+ #
37
+ # Maybe your items are grouped (typically with a +belongs_to+ association). In this case,
38
+ # you'll want to restrict the position in each group by declaring the +:scope+ option:
39
+ #
40
+ # class Folder < ActiveRecord::Base
41
+ # has_many :items
42
+ # end
43
+ #
44
+ # class Item < ActiveRecord::Base
45
+ # belongs_to :folder
46
+ # is_positionable :scope => :folder
47
+ # end
48
+ #
49
+ # By default, position starts by zero. But you may want to change this at the model level,
50
+ # for instance by starting at one (which seems more natural for some people):
51
+ #
52
+ # class Item < ActiveRecord::Base
53
+ # is_positionable :start => 1
54
+ # end
55
+ #
56
+ # To make new records to appear at first position, the default ordering can be changed as
57
+ # follows:
58
+ #
59
+ # class Item < ActiveRecord::Base
60
+ # is_positionable :order => :desc
61
+ # end
62
+ def is_positionable(options = {})
63
+ include InstanceMethods
64
+
65
+ scope_id_attr = "#{options[:scope].to_s}_id" if options[:scope]
66
+ start = options[:start] || 0
67
+ order = options[:order] || :asc
68
+
69
+ default_scope order("\"#{self.table_name}\".\"position\" #{order}")
70
+
71
+ before_create :add_to_bottom
72
+ before_update :update_position
73
+ after_destroy :decrement_all_next
74
+
75
+ if scope_id_attr
76
+ class_eval <<-RUBY
77
+
78
+ def scope_id
79
+ send(:"#{scope_id_attr}")
80
+ end
81
+
82
+ # Gives the range of available positions for this record, whithin the provided scope.
83
+ # If no scope is provided, then it takes the record's current scope by default.
84
+ # If this record is new and no scope can be retrieved, then a <tt>RangeWithoutScopeError</tt>
85
+ # is raised.
86
+ def range(scope = nil)
87
+ raise RangeWithoutScopeError if new_record? and scope.nil? and scope_id.nil?
88
+ # Does its best to retrieve the target scope...
89
+ target_scope_id = scope.nil? ? scope_id : scope.id
90
+ # Number of records whithin the target scope
91
+ count = self.class.where("#{scope_id_attr} = ?", target_scope_id).count
92
+ # An additional position is available if this record is new, or if it's moved to another scope
93
+ if new_record? or target_scope_id != scope_id
94
+ (start..(count + 1))
95
+ else
96
+ (start..count)
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def scoped_condition
103
+ "#{scope_id_attr} = " + scope_id.to_s
104
+ end
105
+
106
+ def scoped_position
107
+ scoped_condition + " and position"
108
+ end
109
+
110
+ def scope_changed?
111
+ send(:"#{scope_id_attr}_changed?")
112
+ end
113
+
114
+ def scope_id_was
115
+ send(:"#{scope_id_attr}_was")
116
+ end
117
+
118
+ def scoped_condition_was
119
+ "#{scope_id_attr} = " + scope_id_was.to_s
120
+ end
121
+
122
+ def scoped_position_was
123
+ scoped_condition_was + " and position"
124
+ end
125
+
126
+ RUBY
127
+ else
128
+ class_eval <<-RUBY
129
+
130
+ # Gives the range of available positions for this record.
131
+ def range
132
+ if new_record?
133
+ (start..(bottom + 1))
134
+ else
135
+ (start..bottom)
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def scope_changed?
142
+ false
143
+ end
144
+
145
+ def scoped_condition
146
+ ""
147
+ end
148
+
149
+ def scoped_position
150
+ "position"
151
+ end
152
+
153
+ RUBY
154
+ end
155
+
156
+ class_eval <<-RUBY
157
+ def start
158
+ #{start}
159
+ end
160
+ RUBY
161
+ end
162
+
163
+ module InstanceMethods
164
+
165
+ # Tells whether this record is the first one (of his scope, if any).
166
+ def first?
167
+ position == start
168
+ end
169
+
170
+ # Tells whether this record is the last one (of his scope, if any).
171
+ def last?
172
+ position == bottom
173
+ end
174
+
175
+ # Swaps this record position with his previous sibling, unless this record is the first one.
176
+ def up!
177
+ swap_with(previous) unless first?
178
+ end
179
+
180
+ # Swaps this record position with his next sibling, unless this record is the last one.
181
+ def down!
182
+ swap_with(self.next) unless last?
183
+ end
184
+
185
+ # Moves this record at the given position, and updates positions of the impacted sibling
186
+ # records accordingly. If the new position is out of range, then the record is not moved.
187
+ def move_to(new_position)
188
+ if range.include? new_position
189
+ reorder(position, new_position)
190
+ update_column(:position, new_position)
191
+ end
192
+ end
193
+
194
+ # The next sibling record, whose position is right after this record.
195
+ def next
196
+ at(position + 1)
197
+ end
198
+
199
+ # All the next records, whose positions are greater than this record. Records
200
+ # are ordered by their respective positions, depending on the <tt>order</tt> option
201
+ # provided to <tt>is_positionable</tt>.
202
+ def all_next
203
+ self.class.where("#{scoped_position} > ?", position)
204
+ end
205
+
206
+ # All the next records <em>of the old scope</em>, whose positions are greater
207
+ # than this record before it was moved from its old record.
208
+ def all_next_was
209
+ self.class.where("#{scoped_position_was} > ?", position_was)
210
+ end
211
+
212
+ # Gives the next sibling record, whose position is right before this record.
213
+ def previous
214
+ at(position - 1)
215
+ end
216
+
217
+ # All the next records, whose positions are smaller than this record. Records
218
+ # are ordered by their respective positions, depending on the <tt>order</tt> option
219
+ # provided to <tt>is_positionable</tt> (ascending by default).
220
+ def all_previous
221
+ self.class.where("#{scoped_position} < ?", position)
222
+ end
223
+
224
+ private
225
+
226
+ # The position of the last record.
227
+ def bottom
228
+ scoped_all.size + start - 1
229
+ end
230
+
231
+ # Finds the record at the given position.
232
+ def at(position)
233
+ self.class.where("#{scoped_position} = ?", position).limit(1).first
234
+ end
235
+
236
+ # Swaps this record's position with the other provided record.
237
+ def swap_with(other)
238
+ self.class.transaction do
239
+ old_position = position
240
+ update_attribute(:position, other.position)
241
+ other.update_attribute(:position, old_position)
242
+ end
243
+ end
244
+
245
+ # All the records that belong to same scope (if any) of this record (including itself).
246
+ def scoped_all
247
+ self.class.where(scoped_condition)
248
+ end
249
+
250
+ # Reorders records between provided positions, unless the destination position is out of range.
251
+ def reorder(from, to)
252
+ if to > from
253
+ shift, siblings = -1, ((from + 1)..to).map { |p| at(p) }
254
+ elsif scope_changed?
255
+ # When scope changes, it actually inserts this record in the new scope
256
+ # All next siblings (from new position to bottom) have to be staggered
257
+ shift, siblings = 1, (to..bottom).map { |p| at(p) }
258
+ else
259
+ shift, siblings = 1, (to..(from - 1)).map { |p| at(p) }
260
+ end
261
+ self.class.transaction do
262
+ siblings.map do |sibling|
263
+ sibling.update_column(:position, sibling.position + shift)
264
+ end
265
+ end
266
+ end
267
+
268
+ # Reorders records between old and new position (and old and new scope).
269
+ def update_position
270
+ if scope_changed?
271
+ decrement(all_next_was)
272
+ if range.include?(position)
273
+ reorder(position_was, position)
274
+ else
275
+ add_to_bottom
276
+ end
277
+ else
278
+ if range.include?(position)
279
+ reorder(position_was, position)
280
+ else
281
+ self.position = position_was # Keep original position
282
+ end
283
+ end
284
+ end
285
+
286
+ # Adds this record to the bottom.
287
+ def add_to_bottom
288
+ self.position = bottom + 1
289
+ end
290
+
291
+ # Decrements the position of all the next sibling records of this record.
292
+ def decrement_all_next
293
+ decrement(all_next)
294
+ end
295
+
296
+ # Decrements the position of all the provided records.
297
+ def decrement(records)
298
+ self.class.transaction do
299
+ records.each do |record|
300
+ record.update_column(:position, record.position - 1)
301
+ end
302
+ end
303
+ end
304
+
305
+ end
306
+
307
+ end
308
+
309
+ class RangeWithoutScopeError < StandardError
310
+ end
311
+
312
+ ActiveRecord::Base.send(:include, Positionable)
313
+
314
+ end