acts_as_positioned 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 83b94938abcb3a029732e108e86d077e46d60674
4
+ data.tar.gz: 445a9a8fd77994dca9df179e796f3208397bd1b1
5
+ SHA512:
6
+ metadata.gz: 949f19c613d45803c816678b6b6f3dcc00ddf1f67f47b533c4d9e0443285970c41f5f90330e9fbb1f61c3cd7a546354d3bda2859e58bb8b43858ea5a45d3eaa7
7
+ data.tar.gz: 847050cd223261db55862b04d71113975ddc7057f70a8bc0b0e94e5ec4a3482ba9e62995267189a430dcc5f8dd370544c62d1e9926cd94c5bbeaa1535499d560
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # ActsAsPositioned (for ActiveRecord 3 or higher)
2
+
3
+ This gem allows you to have ordered models. It is like the old *acts_as_list*
4
+ gem, but very lightweight and with an optimized SQL syntax.
5
+
6
+ Suppose you want to order a Post model by position. You need to add a
7
+ `position` column to the table *posts* first.
8
+
9
+ class CreatePost < ActiveRecord::Migration
10
+ def change
11
+ create_table(:posts) do |t|
12
+ ...
13
+ t.integer(:position, null: false)
14
+ end
15
+ end
16
+ end
17
+
18
+ You can make the `position` column optional: only records with entered
19
+ positions will be ordered. In rare cases, you can also add extra order columns.
20
+
21
+ To add ordering to a model, do the following:
22
+
23
+ class Post < ActiveRecord::Base
24
+ acts_as_positioned
25
+ end
26
+
27
+ You can also order within the scope of other columns, which is useful for
28
+ things like associations:
29
+
30
+ class Detail < ActiveRecord::Base
31
+ belongs_to(:post)
32
+ acts_as_positioned(scope: :post_id)
33
+ end
34
+
35
+ This means the order positions are unique within the scope of `post_id`.
36
+
37
+ # Examples
38
+
39
+ Check out the tests (in `test/lib/acts_as_positioned.rb`) to see more
40
+ examples.
41
+
42
+ Suppose you have these records (for all examples this is the starting point):
43
+
44
+ id | position
45
+ ---+---------
46
+ 1 | 0
47
+ 2 | 1
48
+ 3 | 2
49
+
50
+ ## Insert a new record at position 1
51
+
52
+ The existing records with position greater than or equal to 1 will have their
53
+ position increased by 1 and the new record (with id 4) is inserted:
54
+
55
+ Post.create(position: 2)
56
+
57
+ id | position
58
+ ---+---------
59
+ 1 | 0
60
+ 2 | 2 # moved down
61
+ 3 | 3 # moved down
62
+ 4 | 1 # inserted
63
+
64
+ ## Delete a record at position 1
65
+
66
+ The existing records with position greater than or equal to 1 will have their
67
+ position decreased by 1 and the record (with id 2) is deleted:
68
+
69
+ Post.find(2).destroy
70
+
71
+ id | position
72
+ ---+---------
73
+ 1 | 0
74
+ # deleted
75
+ 3 | 1 # moved up
76
+
77
+ ## Move a record down from position 0 to position 1
78
+
79
+ The existing record with position equal to 1 will have its position decreased
80
+ by 1 and the record (with id 1) is moved down:
81
+
82
+ Post.find(1).update(position: 1)
83
+
84
+ id | position
85
+ ---+---------
86
+ 1 | 1 # moved down
87
+ 2 | 0 # moved up
88
+ 3 | 2
89
+
90
+ ## Move a record up from position 2 to position 0
91
+
92
+ The existing records with position greater than or equal to 0 and less than or
93
+ equal to 1 will have their position increased by 1 and the record (with id 3)
94
+ is moved up:
95
+
96
+ Post.find(3).update(position: 0)
97
+
98
+ id | position
99
+ ---+---------
100
+ 1 | 1 # moved down
101
+ 2 | 2 # moved down
102
+ 3 | 0 # moved up
103
+
104
+ ## Insert a new record at position 4
105
+
106
+ This would create a gap in the positions and is not allowed.
107
+
108
+ Post.create(position: 4)
109
+
110
+ id | position
111
+ ---+---------
112
+ 1 | 0
113
+ 2 | 1
114
+ 3 | 2
115
+ 4 | 4 # invalid (thus not saved)
116
+
117
+ ## Insert a new record with an empty position
118
+
119
+ This will not affect the other records.
120
+
121
+ Post.create(position: nil)
122
+
123
+ id | position
124
+ ---+---------
125
+ 1 | 0
126
+ 2 | 1
127
+ 3 | 2
128
+ 4 | nil # inserted, but without position
129
+
130
+ ## Clear a record's position
131
+
132
+ Clearing a record's position, is like deleting its position.
133
+
134
+ Post.find(1).update(position: nil)
135
+
136
+ id | position
137
+ ---+---------
138
+ 1 | nil
139
+ 2 | 0 # moved up
140
+ 3 | 1 # moved up
141
+
142
+ # Copyright
143
+
144
+ &copy; 2017 Walter Horstman, [IT on Rails](http://itonrails.com)
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ begin
2
+ require('bundler/setup')
3
+ rescue LoadError
4
+ puts('You must `gem install bundler` and `bundle install` to run rake tasks')
5
+ end
6
+
7
+ Bundler::GemHelper.install_tasks
@@ -0,0 +1,89 @@
1
+ # This module allow models to be positioned (order on a specific column). See the README file for more information.
2
+ module ActsAsPositioned
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ # Class methods that will be added to the base class (the class mixing in this module).
8
+ module ClassMethods
9
+ def acts_as_positioned(options = {})
10
+ column = options[:column] || :position
11
+ scope_columns = Array.wrap(options[:scope])
12
+
13
+ after_validation do
14
+ acts_as_positioned_validation(column, scope_columns)
15
+ end
16
+
17
+ before_create do
18
+ acts_as_positioned_create(column, scope_columns)
19
+ end
20
+
21
+ before_destroy do
22
+ acts_as_positioned_destroy(column, scope_columns)
23
+ end
24
+
25
+ before_update do
26
+ acts_as_positioned_update(column, scope_columns)
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def acts_as_positioned_create(column, scope_columns)
34
+ scope = acts_as_positioned_scope(column, scope_columns)
35
+ scope.where(scope.arel_table[column].gteq(send(column))).update_all("#{column} = #{column} + 1")
36
+ end
37
+
38
+ def acts_as_positioned_destroy(column, scope_columns)
39
+ scope = acts_as_positioned_scope(column, scope_columns, true)
40
+ scope.where(scope.arel_table[column].gt(send("#{column}_was"))).update_all("#{column} = #{column} - 1")
41
+ end
42
+
43
+ def acts_as_positioned_scope(column, scope_columns, use_old_values = false)
44
+ scope_columns.reduce(self.class.base_class.where.not(column => nil)) do |scope, scope_column|
45
+ scope.where(scope_column => use_old_values ? send("#{scope_column}_was") : send(scope_column))
46
+ end
47
+ end
48
+
49
+ def acts_as_positioned_update(column, scope_columns)
50
+ if scope_columns.any? { |scope_column| send("#{scope_column}_changed?") }
51
+ acts_as_positioned_create(column, scope_columns)
52
+ acts_as_positioned_destroy(column, scope_columns)
53
+
54
+ elsif send(:"#{column}_changed?")
55
+ old_value, new_value = send("#{column}_change")
56
+
57
+ # If the new position becomes nil (and thus the old position wasn't), it should destroy a position.
58
+ if new_value.nil?
59
+ acts_as_positioned_destroy(column, scope_columns)
60
+
61
+ # If the old position was nil (and thus the new position isn't), it should insert a position.
62
+ elsif old_value.nil?
63
+ acts_as_positioned_create(column, scope_columns)
64
+
65
+ else
66
+ from, to, sign = old_value < new_value ? [old_value + 1, new_value, '-'] : [new_value, old_value - 1, '+']
67
+
68
+ acts_as_positioned_scope(column, scope_columns)
69
+ .where(column => from.eql?(to) ? from : from..to)
70
+ .update_all("#{column} = #{column} #{sign} 1")
71
+ end
72
+ end
73
+ end
74
+
75
+ def acts_as_positioned_validation(column, scope_columns)
76
+ return if errors[column].any?
77
+
78
+ scope = acts_as_positioned_scope(column, scope_columns)
79
+ scope = scope.where(scope.arel_table[scope.primary_key].not_eq(id)) unless new_record?
80
+ options = { attributes: column, allow_nil: true, only_integer: true, greater_than_or_equal_to: 0,
81
+ less_than_or_equal_to: (scope.maximum(column) || -1) + 1 }
82
+
83
+ ActiveModel::Validations::NumericalityValidator.new(options).validate(self)
84
+ end
85
+ end
86
+
87
+ ActiveSupport.on_load(:active_record) do
88
+ include(ActsAsPositioned)
89
+ end
@@ -0,0 +1,179 @@
1
+ require('active_record')
2
+ require('minitest')
3
+ require('minitest/autorun')
4
+ require('acts_as_positioned')
5
+
6
+ # Test by running: ruby -Ilib test/lib/acts_as_positioned_test.rb
7
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: File.dirname(__FILE__) + '/../test.sqlite3')
8
+
9
+ # Create "posts" table.
10
+ ActiveRecord::Schema.define do
11
+ create_table(:posts, force: true) do |t|
12
+ t.string(:text, null: false)
13
+ t.integer(:position)
14
+ t.integer(:author_id)
15
+ end
16
+ end
17
+
18
+ # Create "animals" table.
19
+ ActiveRecord::Schema.define do
20
+ create_table(:animals, force: true) do |t|
21
+ t.string(:type, null: false)
22
+ t.string(:sound, null: false)
23
+ t.integer(:ordering)
24
+ end
25
+ end
26
+
27
+ class Post < ActiveRecord::Base
28
+ acts_as_positioned
29
+ end
30
+
31
+ class PostWithScope < ActiveRecord::Base
32
+ self.table_name = 'posts'
33
+ acts_as_positioned(scope: :author_id)
34
+ end
35
+
36
+ class Animal < ActiveRecord::Base
37
+ acts_as_positioned(column: :ordering)
38
+ end
39
+
40
+ class Cat < Animal
41
+ end
42
+
43
+ class Dog < Animal
44
+ end
45
+
46
+ class ActsAsPositionedTest < Minitest::Test
47
+ def setup
48
+ Post.delete_all
49
+ end
50
+
51
+ def test_insert_record
52
+ post1 = Post.create(text: '1st post', position: 0)
53
+ post2 = Post.create(text: '2nd post', position: 1)
54
+ post3 = Post.create(text: '3rd post', position: 2)
55
+ post4 = Post.create(text: '4th post', position: 1)
56
+ assert_equal(post4.position, 1)
57
+ assert_equal(post1.reload.position, 0)
58
+ assert_equal(post2.reload.position, 2)
59
+ assert_equal(post3.reload.position, 3)
60
+ end
61
+
62
+ def test_delete_record
63
+ post1 = Post.create(text: '1st post', position: 0)
64
+ post2 = Post.create(text: '2nd post', position: 1)
65
+ post3 = Post.create(text: '3rd post', position: 2)
66
+ post2.destroy
67
+ assert_equal(post1.reload.position, 0)
68
+ assert_equal(post3.reload.position, 1)
69
+ end
70
+
71
+ def test_move_record_downwards
72
+ post1 = Post.create(text: '1st post', position: 0)
73
+ post2 = Post.create(text: '2nd post', position: 1)
74
+ post3 = Post.create(text: '3rd post', position: 2)
75
+ post1.update(position: 1)
76
+ assert_equal(post1.reload.position, 1)
77
+ assert_equal(post2.reload.position, 0)
78
+ assert_equal(post3.reload.position, 2)
79
+ end
80
+
81
+ def test_move_record_upwards
82
+ post1 = Post.create(text: '1st post', position: 0)
83
+ post2 = Post.create(text: '2nd post', position: 1)
84
+ post3 = Post.create(text: '3rd post', position: 2)
85
+ post3.update(position: 0)
86
+ assert_equal(post1.reload.position, 1)
87
+ assert_equal(post2.reload.position, 2)
88
+ assert_equal(post3.reload.position, 0)
89
+ end
90
+
91
+ def test_move_record_downwards_and_upwards
92
+ post1 = Post.create(text: '1st post', position: 0)
93
+ post2 = Post.create(text: '2nd post', position: 1)
94
+ post3 = Post.create(text: '3rd post', position: 2)
95
+ post1.update(position: 1)
96
+ post1.update(position: 0)
97
+ assert_equal(post1.reload.position, 0)
98
+ assert_equal(post2.reload.position, 1)
99
+ assert_equal(post3.reload.position, 2)
100
+ end
101
+
102
+ def test_insert_record_without_position_should_do_nothing
103
+ post1 = Post.create(text: '1st post', position: 0)
104
+ post2 = Post.create(text: '2nd post', position: 1)
105
+ post3 = Post.create(text: '3rd post', position: 2)
106
+ post4 = Post.create(text: '4th post')
107
+ assert_nil(post4.position)
108
+ assert_equal(post1.reload.position, 0)
109
+ assert_equal(post2.reload.position, 1)
110
+ assert_equal(post3.reload.position, 2)
111
+ end
112
+
113
+ def test_clear_position
114
+ post1 = Post.create(text: '1st post', position: 0)
115
+ post2 = Post.create(text: '2nd post', position: 1)
116
+ post3 = Post.create(text: '3rd post', position: 2)
117
+ post1.update(position: nil)
118
+ assert_equal(post2.reload.position, 0)
119
+ assert_equal(post3.reload.position, 1)
120
+ end
121
+
122
+ def test_fill_position
123
+ post1 = Post.create(text: '1st post', position: 0)
124
+ post2 = Post.create(text: '2nd post', position: 1)
125
+ post3 = Post.create(text: '3rd post', position: 2)
126
+ post4 = Post.create(text: '4th post')
127
+ assert_nil(post4.position)
128
+ assert_equal(post1.reload.position, 0)
129
+ assert_equal(post2.reload.position, 1)
130
+ assert_equal(post3.reload.position, 2)
131
+
132
+ post4.update(position: 1)
133
+ assert_equal(post4.position, 1)
134
+ assert_equal(post1.reload.position, 0)
135
+ assert_equal(post2.reload.position, 2)
136
+ assert_equal(post3.reload.position, 3)
137
+ end
138
+
139
+ def test_update_record_without_position_should_do_nothing
140
+ post = Post.create(text: 'Initial text', position: 0)
141
+ assert_equal(post.reload.position, 0)
142
+ post.update(text: 'Changed text')
143
+ assert_equal(post.reload.position, 0)
144
+ end
145
+
146
+ # First record that gets created, must have position 0.
147
+ def test_validation
148
+ post = Post.new(text: 'Post', position: 4)
149
+ assert_equal(post.valid?, false)
150
+ end
151
+
152
+ def test_insert_record_with_scope_column
153
+ post1 = PostWithScope.create(text: '1st post', position: 0)
154
+ post2 = PostWithScope.create(text: '1nd post for author 1', position: 0, author_id: 1)
155
+ post3 = PostWithScope.create(text: '2nd post for author 1', position: 1, author_id: 1)
156
+ post4 = PostWithScope.create(text: '3th post for author 1', position: 1, author_id: 1)
157
+ assert_equal(post4.position, 1)
158
+ assert_equal(post1.reload.position, 0)
159
+ assert_equal(post2.reload.position, 0)
160
+ assert_equal(post3.reload.position, 2)
161
+ end
162
+
163
+ def test_reposition_when_author_changes
164
+ post1 = PostWithScope.create(text: '1st post', position: 0)
165
+ post2 = PostWithScope.create(text: '1nd post for author 1', position: 0, author_id: 1)
166
+ post3 = PostWithScope.create(text: '2nd post for author 1', position: 1, author_id: 1)
167
+ post2.update(author_id: nil)
168
+ assert_equal(post2.position, 0)
169
+ assert_equal(post1.reload.position, 1)
170
+ assert_equal(post3.reload.position, 0)
171
+ end
172
+
173
+ def test_single_table_inheritance
174
+ cat = Cat.create(sound: 'Miaow', ordering: 0)
175
+ dog = Dog.create(sound: 'Bark', ordering: 0)
176
+ assert_equal(dog.ordering, 0)
177
+ assert_equal(cat.reload.ordering, 1)
178
+ end
179
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts_as_positioned
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Walter Horstman
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-05-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: This gem allows you to have ordered models. It is like the old acts_as_list,
84
+ but very lightweight and with an optimized SQL syntax.
85
+ email:
86
+ - walter.horstman@itonrails.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - README.md
92
+ - Rakefile
93
+ - lib/acts_as_positioned.rb
94
+ - test/lib/acts_as_positioned_test.rb
95
+ homepage: http://github.com/walterhorstman/acts_as_positioned
96
+ licenses: []
97
+ metadata: {}
98
+ post_install_message:
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubyforge_project:
114
+ rubygems_version: 2.5.2
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: Lightweight ordering of models in ActiveRecord 3 or higher
118
+ test_files:
119
+ - test/lib/acts_as_positioned_test.rb