acts_as_positioned 0.0.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 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