├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .standard.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── positioning.rb └── positioning │ ├── healer.rb │ ├── mechanisms.rb │ └── version.rb ├── positioning.gemspec ├── sig └── positioning.rbs └── test ├── models ├── author.rb ├── author │ ├── student.rb │ └── teacher.rb ├── blog.rb ├── categorised_item.rb ├── category.rb ├── composite_primary_key_item.rb ├── default_scope_item.rb ├── entity.rb ├── item.rb ├── list.rb ├── new_item.rb ├── post.rb └── product.rb ├── support ├── active_record.rb ├── ci_database.yml └── database.yml ├── test_healing.rb ├── test_helper.rb └── test_positioning.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [brendon] 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }}, DB ${{ matrix.db }}, Rails ${{ matrix.rails }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby: 18 | - '3.0' 19 | - '3.1' 20 | - '3.2' 21 | - '3.3' 22 | rails: 23 | - '6.1' 24 | - '7.0' 25 | - '7.1' 26 | - '7.2' 27 | - '8.0' 28 | db: 29 | - mysql 30 | - postgresql 31 | - sqlite 32 | exclude: 33 | - rails: '7.0' 34 | ruby: '3.1' 35 | - rails: '7.0' 36 | ruby: '3.2' 37 | - rails: '7.0' 38 | ruby: '3.3' 39 | - rails: '7.2' 40 | ruby: '3.0' 41 | - rails: '8.0' 42 | ruby: '3.0' 43 | - rails: '8.0' 44 | ruby: '3.1' 45 | env: 46 | DB: ${{ matrix.db }} 47 | RAILS_VERSION: ${{ matrix.rails }} 48 | steps: 49 | - uses: actions/checkout@v4 50 | - name: Set up Ruby 51 | uses: ruby/setup-ruby@v1 52 | with: 53 | ruby-version: ${{ matrix.ruby }} 54 | bundler-cache: true 55 | - name: Enable MySQL 56 | if: ${{ matrix.db == 'mysql' }} 57 | run: sudo systemctl start mysql.service 58 | - name: Create MySQL Database 59 | if: ${{ matrix.db == 'mysql' }} 60 | run: mysql -u root -proot -e 'CREATE DATABASE runner;' 61 | - name: Enable PostgreSQL 62 | if: ${{ matrix.db == 'postgresql' }} 63 | run: sudo systemctl start postgresql.service 64 | - name: Create PostgreSQL User 65 | if: ${{ matrix.db == 'postgresql' }} 66 | run: sudo -u postgres -i createuser runner -s 67 | - name: Create PostgreSQL Database 68 | if: ${{ matrix.db == 'postgresql' }} 69 | run: createdb runner 70 | - name: Run the default task 71 | run: bundle exec rake 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /db/ 10 | Gemfile.lock 11 | .ruby-version 12 | .ruby-gemset 13 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | ruby_version: 3.0 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.4.6] - 2025-05-01 4 | 5 | - Fix healing for positioning with nullable scope (parent in trees, for example). Thanks @pyromaniac! 6 | 7 | ## [0.4.5] - 2024-12-04 8 | 9 | - Fix healing a list with a default scope `:order` and/or `:select`. Thanks @LukasSkywalker! 10 | 11 | ## [0.4.4] - 2024-11-20 12 | 13 | - Add `funding_uri` to gemspec. 14 | 15 | ## [0.4.3] - 2024-11-18 16 | 17 | - Add support for polymorphic `belongs_to` where we add both the `id` and the `type` to the scope. 18 | 19 | ## [0.4.2] - 2024-11-08 20 | 21 | NOTE: Versions 0.4.0 and 0.4.1 contain fatal flaws with the locking logic. Upgrade as soon as you can. 22 | 23 | - Fix cases where locking wasn't executed where there were no associated scopes. 24 | - Fix locking causing the in-memory record to be reloaded from the database. We only need the lock, not the reloaded record. 25 | 26 | ## [0.4.1] - 2024-11-07 27 | 28 | - Fix locking where a `belongs_to` association is `optional: true`. 29 | 30 | ## [0.4.0] - 2024-11-07 31 | 32 | - BREAKING CHANGE: Advisory Lock has been removed. If you explicitly define `advisory_lock: false` in your `positioned` call, you'll need to remove this. 33 | - CAUTION: The Advisory Lock replacement is row locking. Where `belongs_to` associations exist, we lock the associated record(s), and that limits the locking scope down to the record's current scope, and potentially the scope it belonged to before a change in scope. If there are no `belongs_to` associations then the records that belong to the current (and potentially new) scope are locked, or all the records in the table are locked if there is no scope. Please report any deadlock issues. 34 | 35 | ## [0.3.0] - 2024-10-12 36 | 37 | - POSSIBLY BREAKING: Clear all position columns on a duplicate created with `dup`. 38 | 39 | ## [0.2.6] - 2024-08-21 40 | 41 | - Implement list healing so that existing lists can be fixed up when implementing `positioned` or if the list somehow gets corrupted. 42 | - Tidy up Advisory Lock code. 43 | 44 | ## [0.2.5] - 2024-08-10 45 | 46 | - Implemented composite primary key support. Thanks @jackozi for the original PR and the nudge to get this done! 47 | 48 | ## [0.2.4] - 2024-07-31 49 | 50 | - Avoid unnecessary SQL queries when the position hasn't changed. 51 | 52 | ## [0.2.3] - 2024-07-06 53 | 54 | - Advisory Lock can now be optionally turned off via `advisory_lock: false` on your `positioned` call. See the README for more details. Advisory Lock remains on by default. Thanks @joaomarcos96! 55 | 56 | ## [0.2.2] - 2024-05-17 57 | 58 | - When destroying a positioned item, first move it out of the way (position = 0) then contract the scope. Do this before destruction. Moving the item out of the way memoizes its original position to cope with the case where multiple items are destroyed with `destroy_all` as they'll have their position column cached. Thanks @james-reading for the report. 59 | 60 | ## [0.2.1] - 2024-04-08 61 | 62 | - Fetch the adapter_name from #connection_db_config (@tijn) 63 | - Use `quote_table_name_for_assignment` in `update_all` calls to guard against reserved word column names. 64 | 65 | ## [0.2.0] - 2024-03-12 66 | 67 | - Add an Advisory Lock to ensure isolation for the entirety of the create, update, and destroy cycles. 68 | - Add SQLite Advisory Lock support using a file lock. 69 | 70 | ## [0.1.7] - 2024-03-06 71 | 72 | - Separated the Concern that is included into ActiveRecord::Base into its own submodule so that Mechanisms isn't also included. 73 | - Added the RelativePosition Struct and documentation to make it easier to supply relative positions via form helpers. 74 | 75 | ## [0.1.6] - 2024-03-05 76 | 77 | - Allow the position to be passed as a JSON object so that we can pass in complex positions from the browser more easily. 78 | 79 | ## [0.1.5] - 2024-03-04 80 | 81 | - Allow empty strings to represent nil for the purposes of solidifying a position 82 | 83 | ## [0.1.4] - 2024-03-04 84 | 85 | - Fix bug relating to relative position hash coming from Rails being a Hash With Indifferent Access 86 | 87 | ## [0.1.3] - 2024-03-04 88 | 89 | - Internal refactoring of Mechanisms for clarity 90 | - Additional unit testing of Mechanisms 91 | - Added additional Ruby and Rails versions to the Github Actions matrix 92 | 93 | ## [0.1.2] - 2024-02-29 94 | 95 | - Fix a bug related to the scope changing with an explicitly set position value that is the same as the original position. 96 | 97 | ## [0.1.1] - 2024-02-25 98 | 99 | - Fix issues with STI based models 100 | 101 | ## [0.1.0] - 2024-02-24 102 | 103 | - Initial release 104 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in positioning.gemspec 4 | gemspec 5 | 6 | gem "rake", "~> 13.0" 7 | 8 | gem "minitest", "~> 5.0" 9 | gem "minitest-hooks", "~> 1.5.1" 10 | gem "mocha", "~> 2.1.0" 11 | 12 | gem "standard", "~> 1.3" 13 | 14 | if ENV["RAILS_VERSION"] 15 | gem "activerecord", ENV["RAILS_VERSION"] 16 | gem "activesupport", ENV["RAILS_VERSION"] 17 | end 18 | 19 | case ENV["DB"] 20 | when "sqlite" 21 | if ENV["RAILS_VERSION"] && 22 | Gem::Version.new(ENV["RAILS_VERSION"]) >= Gem::Version.new("7.2") 23 | gem "sqlite3", "~> 2.2.0" 24 | else 25 | gem "sqlite3", "~> 1.7.2" 26 | end 27 | when "postgresql" 28 | gem "pg", "~> 1.5.5" 29 | else 30 | gem "mysql2", "~> 0.5.6" 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Brendon Muir 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Positioning 2 | 3 | The aim of this gem is to allow you to easily position Active Record model instances within a scope of your choosing. In an ideal world this gem will give your model instances sequential integer positions beginning with `1`. Attempts are made to make all changes within a transaction so that position integers remain consistent. To this end, directly assigning a position is discouraged, instead you can move items by declaring an item's prior or subsequent item in the list and your item will be moved to be relative to that item. 4 | 5 | Positioning supports multiple lists per model with global, simple, and complex scopes. 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'positioning' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle install 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install positioning 22 | 23 | ## Usage 24 | 25 | In the simplest case our database column should be named `position` and not allow `NULL` as a value: 26 | 27 | `add_column :items, :position, :integer, null: false` 28 | 29 | You should also add an index to ensure that the `position` column value is unique within its scope: 30 | 31 | `add_index :items, [:list_id, :position], unique: true` 32 | 33 | The above assumes that your items are scoped to a parent table called `lists`. 34 | 35 | If you have a polymorphic `belongs_to` then you'll want to add the type column to the index also: 36 | 37 | `add_index :items, [:listable_id, :listable_type, :position], unique: true` 38 | 39 | The Positioning gem uses `0` and negative integers to rearrange the lists it manages so don't add database validations to restrict the usage of these. You are also restricted from using `0` and negative integers as position values. If you try, the position value will become `1`. If you try to set an explicit position value that is greater than the next available list position, it will be rounded down to that value. 40 | 41 | ### Declaring Positioning 42 | 43 | To declare that your model should keep track of the position of its records you can use the `positioned` method. Here are some examples: 44 | 45 | ```ruby 46 | # The scope is global (all records will belong to the same list) and the database column 47 | # is 'position' 48 | positioned 49 | 50 | # The scope is on the belongs_to relationship 'list' and the database column is 'position' 51 | # We check if the scope is a belongs_to relationship and use its declared foreign_key as 52 | # the scope value. In this case it would be 'list_id' since we haven't overridden the 53 | # default foreign key. 54 | belongs_to :list 55 | positioned on: :list 56 | 57 | # If you want to change the database column used to record positions you can do so via the 58 | # ':column' parameter. This is most useful when you are keeping track of more than one 59 | # list on a model. 60 | belongs_to :list 61 | belongs_to :category 62 | positioned on: :list 63 | positioned on: :category, column: :category_position 64 | 65 | # A scope need not be a belongs_to relationship; it can be any column in the database table. 66 | positioned on: :type 67 | 68 | # Finally, you can have more complex scopes defined as an array of relationships and/or 69 | # columns. 70 | belongs_to :list 71 | belongs_to :category 72 | positioned on: [:list, :category, :enabled] 73 | 74 | # If your belongs_to is polymorphic positioning will automatically add the type to the scope 75 | belongs_to :listable, polymorphic: true 76 | positioned on: :listable 77 | ``` 78 | 79 | ### Initialising a List 80 | 81 | If you are adding `positioning` to a model with existing database records, or you're migrating from another gem like `acts_as_list` or `ranked-model` and have an existing position column, you will need to do some work to ensure you have well formed position values for your records. `positioning` has a helper method per `positioned` declaration that allows you to 'heal' the position column, ensuring that positions are positive integers starting at 1 with no gaps. 82 | 83 | For example, in the usual case: 84 | 85 | ``` 86 | belongs_to :list 87 | positioned on: :list 88 | ``` 89 | 90 | you'll have a method called `heal_position_column!`. You can call this method and it will cycle through every existing scope combination in your database (every list with items in this case) and reset those items' position based on their current position order by default. You can pass in a custom order if you don't trust (or don't have) an existing order column. The custom order is passed through to the Active Record `reorder` method, so you can provide anything that that method accepts: 91 | 92 | ``` 93 | Item.heal_position_column! name: :desc 94 | ``` 95 | 96 | You may need to introduce your database constraints after healing your position column: 97 | 98 | * We recommend a `null: false` constraint on the position column but if your existing column has `NULL` values, you'll need to fix those first. The heal method will heal `NULL` positions but depending on your database engine `NULL` positioned items might be placed at the start of the returned records or at the end (if positioning on the position column). Some databases allow this behaviour to be customised. 99 | * We also recommend a unique index on the scope columns and the position column. If you have repeated position integers per scope you'll need to use the heal method to fix these first before applying the unique index in a separate migration step. 100 | 101 | The heal method name is named after the column used to store position values. By default this is `position` but if you override it then the method name will change: 102 | 103 | ``` 104 | positioned on: :category, column: :category_position 105 | ``` 106 | 107 | will have a class method named `heal_category_position_column!`. 108 | 109 | ### Manipulating Positioning 110 | 111 | The tools for manipulating the position of records in your list have been kept intentionally terse. Priority has also been given to minimal pollution of the model namespace. Only two class methods are defined on all models (`positioning_columns` and `positioned`), and two instance methods are defined on models that call `positioned`: 112 | 113 | #### Accessing Relative List Items 114 | 115 | The two instance methods that we add are for finding the prior and subsequent items relative to the current item in the list. These methods are named after the database column used to track positioning. By default the methods are named `prior_position` and `subsequent_position`. In the example above where we used the column `category_position` then the methods would be named `prior_category_position` and `subsequent_category_position`. 116 | 117 | #### Assigning Positions 118 | 119 | If you don't provide a position when creating a record, your record will be added to the end of the list. 120 | 121 | To assign a specific position when creating or updating a record you can simply declare a specific value for the database column tracking the position of records (by default this is `position`). The valid options for this column are: 122 | 123 | * A specific integer value as an `Integer` or a `String`. Values are automatically clamped to between `1` and the next available position at the end of the list (inclusive). You should use explicit position values as a last resort, instead you can use: 124 | * `:first` or `"first"` places the record at the start of the list. 125 | * `:last` or `"last"` places the record at the end of the list. 126 | * `nil` and `""` also places the record at the end of the list. 127 | * `before:` and `after:` allow you to define the position relative to other records in the list. You can define the relative record by its primary key (usually `id`) or by providing the record itself. You can also provide `nil` or `""` in which case the item will be placed at the start or end of the list (see below). 128 | 129 | **You can provide the position value as a JSON string and it will be decoded first. This could be useful if you have no other way to provide `before:` or `after:` as a hash (e.g. `"{\"after\":33}"`). See below for a technique to provide `before:` and `after:` using form helpers.** 130 | 131 | Position parameters can be strings or symbols, so you can provide them from the browser. 132 | 133 | Here are some examples: 134 | 135 | ##### Creating 136 | 137 | ```ruby 138 | # Added to the third position, other records are moved out of the way 139 | list.items.create name: 'Item', position: 3 140 | 141 | # Added to the end of the list 142 | list.items.create name: 'Item' 143 | list.items.create name: 'Item', position: :last 144 | list.items.create name: 'Item', position: nil 145 | list.items.create name: 'Item', position: {before: nil} 146 | 147 | # Added to the start of the list 148 | list.items.create name: 'Item', position: :first 149 | list.items.create name: 'Item', position: {after: nil} 150 | 151 | # Added before other_item 152 | list.items.create name: 'Item', position: {before: other_item} 153 | # or 154 | other_item.id # => 22 155 | list.items.create name: 'Item', position: {before: 22} 156 | 157 | # Added after other_item 158 | list.items.create name: 'Item', position: {after: other_item} 159 | # or 160 | other_item.id # => 11 161 | list.items.create name: 'Item', position: {after: 11} 162 | ``` 163 | 164 | ##### Updating 165 | 166 | ```ruby 167 | # Moved to the third position, other records are moved out of the way 168 | item.update position: 3 169 | 170 | # Moved to the end of the list 171 | item.update position: :last 172 | item.update position: nil 173 | item.update position: {before: nil} 174 | 175 | # Moved to the start of the list 176 | item.update position: :first 177 | item.update position: {after: nil} 178 | 179 | # Moved to before other_item 180 | item.update position: {before: other_item} 181 | # or 182 | other_item.id # => 22 183 | item.update position: {before: 22} 184 | 185 | # Moved to after other_item 186 | item.update position: {after: other_item} 187 | # or 188 | other_item.id # => 11 189 | item.update position: {after: 11} 190 | ``` 191 | 192 | ##### Duplicating (`dup`) 193 | 194 | When you call `dup` on an instance in the list, all position columns on the duplicate will be set to `nil` so that when this duplicate is saved it will be added either to the end of the current scopes (if unchanged) or to the end of any new scopes. Of course you can then override the position of the duplicate before you save it if necessary. 195 | 196 | ##### Relative Positioning in Forms 197 | 198 | It can be tricky to provide the hash forms of relative positioning using Rails form helpers, but it is possible. We've declared a special `Struct` for you to use for this purpose. 199 | 200 | Firstly you need to allow both scalar and nested Strong Parameters for the `position` column like so: 201 | 202 | ```ruby 203 | def item_params 204 | params.require(:item).permit :name, :position, { position: :before } 205 | end 206 | ``` 207 | 208 | In the example above we're always declaring what item (by its `id`) we want to position our item **before**. You could change this to `:after` if you'd rather. 209 | 210 | Next, in your `new` method you may wish to initialise the `position` column with a value supplied by incoming parameters: 211 | 212 | ```ruby 213 | def new 214 | item.position = { before: params[:before] } 215 | end 216 | ``` 217 | 218 | You can now just pass the `before` parameter (the `id` of the item you want to add this record before) via the URL to the `new` action. For example: `items/new?before=22`. 219 | 220 | In the form itself, so that your intended position survives a failed `create` attempt and form redisplay you can declare the `position` value like so: 221 | 222 | ``` 223 | <% if item.new_record? %> 224 | <%= form.fields :position, model: Positioning::RelativePosition.new(item.position_before_type_cast) do |fields| %> 225 | <%= fields.hidden_field :before %> 226 | <% end %> 227 | <% end %> 228 | ``` 229 | 230 | The key part here is `Positioning::RelativePosition.new(item.position_before_type_cast)`. `Positioning::RelativePosition` is a `Struct` that can take `before` and `after` as parameters. You should only provide one or the other. Because `position` is an `Integer` column, the hash structure is obliterated when it is assigned but we can still access it with `position_before_type_cast`. Remember to adjust the method if your position column has a different name (e.g. `category_position_before_type_cast`). The `Struct` provides the correct methods for `fields` to display the nested value. 231 | 232 | #### Destroying 233 | 234 | When a record is destroyed, the positions of relative items in the scope will be shuffled to close the gap left by the destroyed record. If we detect that records are being destroyed via a scope dependency (e.g. `has_many :items, dependent: :destroy`) then we skip closing the gaps because all records in the scope will eventually be destroyed anyway. 235 | 236 | #### Scopes 237 | Positioning handles things for you when you change the scope of a record. If you move a record from one scope to another, the gap in the position column will be healed in the scope the record is leaving, and by default (unless you specify an explicit position) the record will be added to the end of the list in the new scope. 238 | 239 | Here are some examples of scope management: 240 | 241 | ```ruby 242 | # Moved to being the third item in other_list 243 | item.update list: other_list, position: 3 244 | 245 | # Moved to the end of other_list 246 | item.update list: other_list 247 | item.update list: other_list, position: :last 248 | item.update list: other_list, position: nil 249 | item.update list: other_list, position: {before: nil} 250 | 251 | # Moved to the start of other_list 252 | item.update list: other_list, position: :first 253 | item.update list: other_list, position: {after: nil} 254 | 255 | # Moved to before other_item in other_list 256 | item.update list: other_list, position: {before: other_item} 257 | # or 258 | other_item.id # => 22 259 | item.update list: other_list, position: {before: 22} 260 | 261 | # Moved to after other_item in other_list 262 | item.update list: other_list, position: {after: other_item} 263 | # or 264 | other_item.id # => 11 265 | item.update list: other_list, position: {after: 11} 266 | ``` 267 | 268 | It's important to note that in the examples above, `other_item` must already belong to the `other_list` scope. 269 | 270 | ## Concurrency 271 | 272 | The queries that this gem runs (especially those that seek the next position integer available) are vulnerable to race conditions. To this end, we lock the scope records to ensure that our model callbacks that determine and assign positions run sequentially. Previously we used an Advisory Lock for this purpose but this was difficult to test and a bit overkill in most situations. Where a scope doesn't exist, we lock all the records in the table. 273 | 274 | **Please Note SQLite Users:** Row locking isn't supported by SQLite. Since writes are non-concurrent by default, the worst you'll probably see are errors about the database being locked under high load. 275 | 276 | If you have any concerns or improvements please file a GitHub issue. 277 | 278 | ## Development 279 | 280 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 281 | 282 | This gem is tested against SQLite, PostgreSQL and MySQL. The default database for testing is MySQL. You can target other databases by prepending the environment variable `DB=sqlite` or `DB=postgresql` before `rake test`. For example: `DB=sqlite rake test`. 283 | 284 | The PostgreSQL and MySQL environments are configured under `test/support/database.yml`. You can edit this file, or preferably adjust your environment to support password-less socket based connections to these two database engines. You'll also need to manually create a database named `positioning_test` in each. 285 | 286 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 287 | 288 | ## Contributing 289 | 290 | Bug reports and pull requests are welcome on GitHub at https://github.com/brendon/positioning. 291 | 292 | ## License 293 | 294 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 295 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/test_*.rb"] 8 | end 9 | 10 | require "standard/rake" 11 | 12 | task default: %i[test standard] 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "positioning" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | -------------------------------------------------------------------------------- /lib/positioning.rb: -------------------------------------------------------------------------------- 1 | require_relative "positioning/version" 2 | require_relative "positioning/mechanisms" 3 | require_relative "positioning/healer" 4 | 5 | require "active_support/concern" 6 | require "active_support/lazy_load_hooks" 7 | 8 | module Positioning 9 | class Error < StandardError; end 10 | 11 | RelativePosition = Struct.new(:before, :after, keyword_init: true) 12 | 13 | module Behaviour 14 | extend ActiveSupport::Concern 15 | 16 | class_methods do 17 | def positioning_columns 18 | @positioning_columns ||= {} 19 | end 20 | 21 | def positioned(on: [], column: :position) 22 | unless base_class? 23 | raise Error.new "can't be called on an abstract class or STI subclass." 24 | end 25 | 26 | column = column.to_sym 27 | 28 | if positioning_columns.key? column 29 | raise Error.new "The column `#{column}` has already been used by the scope `#{positioning_columns[column]}`." 30 | else 31 | positioning_columns[column] = {scope_columns: [], scope_associations: []} 32 | 33 | Array.wrap(on).each do |scope_component| 34 | scope_component = scope_component.to_s 35 | reflection = reflections[scope_component] 36 | 37 | if reflection&.belongs_to? 38 | positioning_columns[column][:scope_columns] << reflection.foreign_key 39 | positioning_columns[column][:scope_columns] << reflection.foreign_type if reflection.polymorphic? 40 | positioning_columns[column][:scope_associations] << reflection.name 41 | else 42 | positioning_columns[column][:scope_columns] << scope_component 43 | end 44 | end 45 | 46 | define_method(:"prior_#{column}") { Mechanisms.new(self, column).prior } 47 | define_method(:"subsequent_#{column}") { Mechanisms.new(self, column).subsequent } 48 | 49 | redefine_method(:"#{column}=") do |position| 50 | send :"#{column}_will_change!" 51 | super(position) 52 | end 53 | 54 | before_create { Mechanisms.new(self, column).create_position } 55 | before_update { Mechanisms.new(self, column).update_position } 56 | before_destroy { Mechanisms.new(self, column).destroy_position } 57 | 58 | define_singleton_method(:"heal_#{column}_column!") do |order = column| 59 | Healer.new(self, column, order).heal 60 | end 61 | end 62 | end 63 | end 64 | 65 | def initialize_dup(other) 66 | super 67 | 68 | self.class.positioning_columns.keys.each do |positioning_column| 69 | send :"#{positioning_column}=", nil 70 | end 71 | end 72 | end 73 | end 74 | 75 | ActiveSupport.on_load :active_record do 76 | ActiveRecord::Base.send :include, Positioning::Behaviour 77 | end 78 | -------------------------------------------------------------------------------- /lib/positioning/healer.rb: -------------------------------------------------------------------------------- 1 | module Positioning 2 | class Healer 3 | def initialize(model, column, order) 4 | @model = model 5 | @column = column.to_sym 6 | @order = order 7 | end 8 | 9 | def heal 10 | if scope_columns.present? 11 | @model.unscope(:order).reselect(*scope_columns).distinct.each do |scope_record| 12 | @model.transaction do 13 | if scope_associations.present? 14 | scope_associations.each do |scope_association| 15 | scope_record.send(scope_association)&.lock! 16 | end 17 | else 18 | @model.where(scope_record.slice(*scope_columns)).lock! 19 | end 20 | 21 | sequence @model.where(scope_record.slice(*scope_columns)) 22 | end 23 | end 24 | else 25 | @model.transaction do 26 | @model.all.lock! 27 | sequence @model 28 | end 29 | end 30 | end 31 | 32 | private 33 | 34 | def scope_columns 35 | @model.positioning_columns[@column][:scope_columns] 36 | end 37 | 38 | def scope_associations 39 | @model.positioning_columns[@column][:scope_associations] 40 | end 41 | 42 | def sequence(scope) 43 | scope.unscope(:select).reorder(@order).each.with_index(1) do |record, index| 44 | record.update_columns @column => index 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/positioning/mechanisms.rb: -------------------------------------------------------------------------------- 1 | module Positioning 2 | class Mechanisms 3 | def initialize(positioned, column) 4 | @positioned = positioned 5 | @column = column.to_sym 6 | end 7 | 8 | def prior 9 | positioning_scope.where(@column => position - 1).first 10 | end 11 | 12 | def subsequent 13 | positioning_scope.where(@column => position + 1).first 14 | end 15 | 16 | def create_position 17 | lock_positioning_scope! 18 | 19 | solidify_position 20 | 21 | expand(positioning_scope, position..) 22 | end 23 | 24 | def update_position 25 | return unless positioning_scope_changed? || position_changed? 26 | 27 | lock_positioning_scope! 28 | 29 | clear_position if positioning_scope_changed? && !position_changed? 30 | 31 | solidify_position 32 | move_out_of_the_way 33 | 34 | if positioning_scope_changed? 35 | contract(positioning_scope_was, position_was..) 36 | expand(positioning_scope, position..) 37 | elsif position_was > position 38 | expand(positioning_scope, position..position_was) 39 | else 40 | contract(positioning_scope, position_was..position) 41 | end 42 | end 43 | 44 | def destroy_position 45 | unless destroyed_via_positioning_scope? 46 | lock_positioning_scope! 47 | 48 | move_out_of_the_way 49 | contract(positioning_scope, (position_was + 1)..) 50 | end 51 | end 52 | 53 | private 54 | 55 | def base_class 56 | @positioned.class.base_class 57 | end 58 | 59 | def with_connection 60 | if base_class.respond_to? :with_connection 61 | base_class.with_connection do |connection| 62 | yield connection 63 | end 64 | else 65 | yield base_class.connection 66 | end 67 | end 68 | 69 | def primary_key 70 | base_class.primary_key 71 | end 72 | 73 | def quoted_column 74 | with_connection do |connection| 75 | connection.quote_table_name_for_assignment base_class.table_name, @column 76 | end 77 | end 78 | 79 | def record_scope 80 | base_class.where primary_key => [@positioned.id] 81 | end 82 | 83 | def position 84 | @positioned.send @column 85 | end 86 | 87 | def position=(position) 88 | @positioned.send :"#{@column}=", position 89 | end 90 | 91 | def clear_position 92 | self.position = nil 93 | end 94 | 95 | def position_changed? 96 | @positioned.send :"#{@column}_changed?" 97 | end 98 | 99 | def position_was 100 | @position_was ||= record_scope.pick(@column) 101 | end 102 | 103 | def move_out_of_the_way 104 | position_was # Memoize the original position before changing it 105 | record_scope.update_all @column => 0 106 | end 107 | 108 | def expand(scope, range) 109 | scope.where(@column => range).update_all "#{quoted_column} = #{quoted_column} * -1" 110 | scope.where(@column => ..-1).update_all "#{quoted_column} = #{quoted_column} * -1 + 1" 111 | end 112 | 113 | def contract(scope, range) 114 | scope.where(@column => range).update_all "#{quoted_column} = #{quoted_column} * -1" 115 | scope.where(@column => ..-1).update_all "#{quoted_column} = #{quoted_column} * -1 - 1" 116 | end 117 | 118 | def solidify_position 119 | position_before_type_cast = @positioned.read_attribute_before_type_cast(@column) 120 | 121 | if position_before_type_cast.is_a? String 122 | begin 123 | position_before_type_cast = JSON.parse(position_before_type_cast, symbolize_names: true) 124 | rescue JSON::ParserError 125 | end 126 | 127 | if position_before_type_cast.is_a?(String) && position_before_type_cast.present? 128 | position_before_type_cast = position_before_type_cast.to_sym 129 | end 130 | elsif position_before_type_cast.is_a? Hash 131 | position_before_type_cast = position_before_type_cast.symbolize_keys 132 | end 133 | 134 | case position_before_type_cast 135 | when Integer 136 | self.position = position_before_type_cast.clamp(1..last_position) 137 | when :first, {after: nil}, {after: ""} 138 | self.position = 1 139 | when nil, "", :last, {before: nil}, {before: ""} 140 | self.position = last_position 141 | when Hash 142 | relative_position, relative_record_or_id = *position_before_type_cast.first 143 | 144 | unless [:before, :after].include? relative_position 145 | raise Error.new, "relative `#{@column}` must be either :before, :after" 146 | end 147 | 148 | relative_id = if relative_record_or_id.is_a? base_class 149 | relative_record_or_id.id 150 | else 151 | relative_record_or_id 152 | end 153 | 154 | relative_record_scope = positioning_scope.where(primary_key => [relative_id]) 155 | 156 | unless relative_record_scope.exists? 157 | raise Error.new, "relative `#{@column}` record must be in the same scope" 158 | end 159 | 160 | solidified_position = relative_record_scope.pick(@column) 161 | solidified_position += 1 if relative_position == :after 162 | solidified_position -= 1 if in_positioning_scope? && position_was < solidified_position 163 | 164 | self.position = solidified_position 165 | end 166 | 167 | unless position.is_a? Integer 168 | raise Error.new, 169 | %(`#{@column}` must be an Integer, :first, :last, ) + 170 | %{before: (#{base_class.name}, #{primary_key}, nil, or ""), } + 171 | %{after: (#{base_class.name}, #{primary_key}, nil or ""), nil or ""} 172 | end 173 | end 174 | 175 | def last_position 176 | (positioning_scope.maximum(@column) || 0) + (in_positioning_scope? ? 0 : 1) 177 | end 178 | 179 | def scope_columns 180 | base_class.positioning_columns[@column][:scope_columns] 181 | end 182 | 183 | def scope_associations 184 | base_class.positioning_columns[@column][:scope_associations] 185 | end 186 | 187 | def positioning_scope 188 | base_class.where @positioned.slice(*scope_columns) 189 | end 190 | 191 | def lock_positioning_scope! 192 | if scope_associations.present? 193 | scope_associations.each do |scope_association| 194 | if @positioned.persisted? && positioning_scope_changed? 195 | associated_record = record_scope.first.send(scope_association) 196 | associated_record.class.base_class.lock.find(associated_record.id) if associated_record 197 | end 198 | 199 | associated_record = @positioned.send(scope_association) 200 | associated_record.class.base_class.lock.find(associated_record.id) if associated_record 201 | end 202 | else 203 | if @positioned.persisted? && positioning_scope_changed? 204 | positioning_scope_was.lock.all.load 205 | end 206 | 207 | positioning_scope.lock.all.load 208 | end 209 | end 210 | 211 | def positioning_scope_was 212 | base_class.where record_scope.first.slice(*scope_columns) 213 | end 214 | 215 | def in_positioning_scope? 216 | @positioned.persisted? && positioning_scope.where(primary_key => [@positioned.id]).exists? 217 | end 218 | 219 | def positioning_scope_changed? 220 | scope_columns.any? do |scope_column| 221 | @positioned.attribute_changed?(scope_column) 222 | end 223 | end 224 | 225 | def destroyed_via_positioning_scope? 226 | @positioned.destroyed_by_association && scope_columns.any? do |scope_column| 227 | @positioned.destroyed_by_association.foreign_key == scope_column 228 | end 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /lib/positioning/version.rb: -------------------------------------------------------------------------------- 1 | module Positioning 2 | VERSION = "0.4.6" 3 | end 4 | -------------------------------------------------------------------------------- /positioning.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/positioning/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "positioning" 5 | spec.version = Positioning::VERSION 6 | spec.authors = ["Brendon Muir"] 7 | spec.email = ["brendon@spike.net.nz"] 8 | 9 | spec.summary = "Simple positioning for Active Record models." 10 | spec.homepage = "https://github.com/brendon/positioning" 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 3.0.0" 13 | 14 | spec.metadata["homepage_uri"] = spec.homepage 15 | spec.metadata["source_code_uri"] = "https://github.com/brendon/positioning" 16 | spec.metadata["changelog_uri"] = "https://github.com/brendon/positioning/blob/main/CHANGELOG.md" 17 | spec.metadata["funding_uri"] = "https://github.com/sponsors/brendon" 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 22 | `git ls-files -z`.split("\x0").reject do |f| 23 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 24 | end 25 | end 26 | spec.bindir = "exe" 27 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 28 | spec.require_paths = ["lib"] 29 | 30 | # Uncomment to register a new dependency of your gem 31 | spec.add_dependency "activesupport", ">= 6.1" 32 | spec.add_dependency "activerecord", ">= 6.1" 33 | 34 | # For more information and examples about making a new gem, check out our 35 | # guide at: https://bundler.io/guides/creating_gem.html 36 | end 37 | -------------------------------------------------------------------------------- /sig/positioning.rbs: -------------------------------------------------------------------------------- 1 | module Positioning 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /test/models/author.rb: -------------------------------------------------------------------------------- 1 | class Author < ActiveRecord::Base 2 | belongs_to :list 3 | 4 | positioned on: [:list, :enabled] 5 | end 6 | -------------------------------------------------------------------------------- /test/models/author/student.rb: -------------------------------------------------------------------------------- 1 | class Author::Student < Author 2 | end 3 | -------------------------------------------------------------------------------- /test/models/author/teacher.rb: -------------------------------------------------------------------------------- 1 | class Author::Teacher < Author 2 | end 3 | -------------------------------------------------------------------------------- /test/models/blog.rb: -------------------------------------------------------------------------------- 1 | class Blog < ActiveRecord::Base 2 | has_many :posts, -> { order(:position) }, dependent: :destroy 3 | 4 | positioned on: :enabled 5 | 6 | default_scope { order(:position) } 7 | end 8 | -------------------------------------------------------------------------------- /test/models/categorised_item.rb: -------------------------------------------------------------------------------- 1 | class CategorisedItem < ActiveRecord::Base 2 | belongs_to :list 3 | belongs_to :category 4 | 5 | positioned on: :list 6 | positioned on: [:list, :category], column: :category_position 7 | end 8 | -------------------------------------------------------------------------------- /test/models/category.rb: -------------------------------------------------------------------------------- 1 | class Category < ActiveRecord::Base 2 | belongs_to :parent, class_name: "Category", optional: true 3 | 4 | positioned on: :parent 5 | end 6 | -------------------------------------------------------------------------------- /test/models/composite_primary_key_item.rb: -------------------------------------------------------------------------------- 1 | class CompositePrimaryKeyItem < ActiveRecord::Base 2 | self.primary_key = [:item_id, :account_id] 3 | 4 | belongs_to :list 5 | 6 | positioned on: :list 7 | end 8 | -------------------------------------------------------------------------------- /test/models/default_scope_item.rb: -------------------------------------------------------------------------------- 1 | class DefaultScopeItem < ActiveRecord::Base 2 | belongs_to :list 3 | 4 | positioned on: :list 5 | 6 | default_scope -> { select(:name, :position).order(:position) } 7 | end 8 | -------------------------------------------------------------------------------- /test/models/entity.rb: -------------------------------------------------------------------------------- 1 | class Entity < ActiveRecord::Base 2 | belongs_to :includable, polymorphic: true 3 | 4 | positioned on: :includable 5 | end 6 | -------------------------------------------------------------------------------- /test/models/item.rb: -------------------------------------------------------------------------------- 1 | class Item < ActiveRecord::Base 2 | belongs_to :list 3 | 4 | positioned on: :list 5 | end 6 | -------------------------------------------------------------------------------- /test/models/list.rb: -------------------------------------------------------------------------------- 1 | class List < ActiveRecord::Base 2 | has_many :items, -> { order(:position) }, dependent: :destroy 3 | has_many :new_items, -> { order(:position) }, dependent: :destroy 4 | has_many :default_scope_items, -> { order(:position) }, dependent: :destroy 5 | has_many :composite_primary_key_items, -> { order(:position) }, dependent: :destroy 6 | has_many :authors, -> { order(:position) }, dependent: :destroy 7 | end 8 | -------------------------------------------------------------------------------- /test/models/new_item.rb: -------------------------------------------------------------------------------- 1 | class NewItem < ActiveRecord::Base 2 | belongs_to :list 3 | 4 | positioned on: :list 5 | end 6 | -------------------------------------------------------------------------------- /test/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ActiveRecord::Base 2 | belongs_to :blog, optional: true 3 | 4 | positioned on: :blog 5 | positioned column: :order 6 | 7 | default_scope { order(:order) } 8 | end 9 | -------------------------------------------------------------------------------- /test/models/product.rb: -------------------------------------------------------------------------------- 1 | class Product < ActiveRecord::Base 2 | positioned 3 | 4 | default_scope { order(:position) } 5 | end 6 | -------------------------------------------------------------------------------- /test/support/active_record.rb: -------------------------------------------------------------------------------- 1 | require "logger" 2 | require "active_record" 3 | 4 | ENV["DB"] = "mysql" unless ENV["DB"] 5 | 6 | database_configuration = ENV["CI"] ? "test/support/ci_database.yml" : "test/support/database.yml" 7 | 8 | ActiveRecord::Base.configurations = YAML.safe_load(IO.read(database_configuration)) 9 | ActiveRecord::Base.establish_connection(ENV["DB"].to_sym) 10 | 11 | ActiveRecord::Migration.suppress_messages do 12 | ActiveRecord::Schema.define version: 0 do 13 | create_table :lists, force: true do |t| 14 | t.string :name 15 | end 16 | 17 | create_table :entities, force: true do |t| 18 | t.string :name 19 | t.integer :position, null: false 20 | t.references :includable, polymorphic: true 21 | end 22 | 23 | add_index :entities, [:includable_id, :includable_type, :position], unique: true, name: "index_entities_on_includable_and_position" 24 | 25 | create_table :items, force: true do |t| 26 | t.string :name 27 | t.integer :position, null: false 28 | t.references :list, null: false 29 | end 30 | 31 | add_index :items, [:list_id, :position], unique: true 32 | 33 | create_table :new_items, force: true do |t| 34 | t.string :name 35 | t.integer :position 36 | t.integer :other_position 37 | t.references :list, null: false 38 | end 39 | 40 | create_table :default_scope_items, force: true do |t| 41 | t.string :name 42 | t.integer :position, null: false 43 | t.references :list, null: false 44 | end 45 | 46 | add_index :default_scope_items, [:list_id, :position], unique: true 47 | 48 | create_table :composite_primary_key_items, primary_key: [:item_id, :account_id], force: true do |t| 49 | t.integer :item_id, null: false 50 | t.integer :account_id, null: false 51 | t.string :name 52 | t.integer :position, null: false 53 | t.references :list, null: false 54 | end 55 | 56 | add_index :composite_primary_key_items, [:list_id, :position], unique: true 57 | 58 | create_table :categories, force: true do |t| 59 | t.string :name 60 | t.integer :position, null: false 61 | t.references :parent 62 | end 63 | 64 | add_index :categories, [:parent_id, :position], unique: true 65 | 66 | create_table :products, force: true do |t| 67 | t.string :name 68 | t.integer :position, null: false 69 | end 70 | 71 | add_index :products, :position, unique: true 72 | 73 | create_table :categorised_items, force: true do |t| 74 | t.string :name 75 | t.integer :position, null: false 76 | t.integer :category_position, null: false 77 | t.references :list, null: false 78 | t.references :category, null: false 79 | end 80 | 81 | add_index :categorised_items, [:list_id, :position], unique: true, name: "index_on_list_id_and_position" 82 | add_index :categorised_items, [:list_id, :category_id, :category_position], unique: true, name: "index_on_list_id_category_id_and_category_position" 83 | 84 | create_table :authors, force: true do |t| 85 | t.string :name 86 | t.string :type 87 | t.boolean :enabled, default: true 88 | t.integer :position, null: false 89 | t.references :list, null: false 90 | end 91 | 92 | add_index :authors, [:list_id, :enabled, :position], unique: true 93 | 94 | create_table :blogs, force: true do |t| 95 | t.string :name 96 | t.boolean :enabled, default: true 97 | t.integer :position, null: false 98 | end 99 | 100 | add_index :blogs, [:position, :enabled], unique: true 101 | 102 | create_table :posts, force: true do |t| 103 | t.string :name 104 | t.integer :order, null: false 105 | t.integer :position, null: false 106 | t.references :blog 107 | end 108 | 109 | add_index :posts, [:blog_id, :position], unique: true 110 | add_index :posts, :order, unique: true 111 | end 112 | end 113 | 114 | # Uncomment the following line to enable SQL logging 115 | # ActiveRecord::Base.logger = ActiveSupport::Logger.new($stdout) 116 | -------------------------------------------------------------------------------- /test/support/ci_database.yml: -------------------------------------------------------------------------------- 1 | sqlite: 2 | adapter: sqlite3 3 | database: db/test.sqlite3 4 | pool: 20 5 | timeout: 5000 6 | 7 | mysql: 8 | adapter: mysql2 9 | database: runner 10 | pool: 5 11 | timeout: 5000 12 | username: root 13 | password: root 14 | 15 | postgresql: 16 | adapter: postgresql 17 | database: runner 18 | pool: 5 19 | timeout: 5000 20 | username: runner 21 | password: 22 | min_messages: ERROR 23 | -------------------------------------------------------------------------------- /test/support/database.yml: -------------------------------------------------------------------------------- 1 | sqlite: 2 | adapter: sqlite3 3 | database: db/test.sqlite3 4 | pool: 20 5 | timeout: 5000 6 | 7 | mysql: 8 | adapter: mysql2 9 | database: positioning_test 10 | pool: 5 11 | timeout: 5000 12 | username: root 13 | password: 14 | 15 | postgresql: 16 | adapter: postgresql 17 | database: positioning_test 18 | pool: 5 19 | timeout: 5000 20 | host: localhost 21 | username: postgres 22 | password: 23 | min_messages: ERROR 24 | -------------------------------------------------------------------------------- /test/test_healing.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TestHealing < Minitest::Test 4 | include Minitest::Hooks 5 | 6 | def around 7 | ActiveRecord::Base.transaction do 8 | super 9 | raise ActiveRecord::Rollback 10 | end 11 | end 12 | 13 | def test_heal_position 14 | first_list = List.create name: "First List" 15 | second_list = List.create name: "Second List" 16 | 17 | first_item = first_list.new_items.create name: "First Item" 18 | second_item = first_list.new_items.create name: "Second Item" 19 | third_item = first_list.new_items.create name: "Third Item" 20 | 21 | fourth_item = second_list.new_items.create name: "Fourth Item" 22 | fifth_item = second_list.new_items.create name: "Fifth Item" 23 | sixth_item = second_list.new_items.create name: "Sixth Item" 24 | 25 | first_item.update_columns position: 9 26 | second_item.update_columns position: nil 27 | third_item.update_columns position: -42 28 | 29 | fourth_item.update_columns position: 0 30 | fifth_item.update_columns position: 998 31 | sixth_item.update_columns position: 800 32 | 33 | NewItem.heal_position_column! 34 | 35 | if ENV["DB"] == "postgresql" 36 | assert_equal [1, 2, 3], [third_item.reload, first_item.reload, second_item.reload].map(&:position) 37 | else 38 | assert_equal [1, 2, 3], [second_item.reload, third_item.reload, first_item.reload].map(&:position) 39 | end 40 | 41 | assert_equal [1, 2, 3], [fourth_item.reload, sixth_item.reload, fifth_item.reload].map(&:position) 42 | 43 | NewItem.heal_position_column! name: :desc 44 | 45 | assert_equal [1, 2, 3], [third_item.reload, second_item.reload, first_item.reload].map(&:position) 46 | assert_equal [1, 2, 3], [sixth_item.reload, fourth_item.reload, fifth_item.reload].map(&:position) 47 | end 48 | 49 | def test_heal_position_on_a_tree 50 | first_category = Category.create name: "First Category" 51 | second_category = Category.create name: "Second Category" 52 | third_category = Category.create name: "Third Category", parent: first_category 53 | fourth_category = Category.create name: "Fourth Category", parent: second_category 54 | fifth_category = Category.create name: "Fifth Category", parent: second_category 55 | sixth_category = Category.create name: "Sixth Category", parent: second_category 56 | 57 | first_category.update_columns position: 9 58 | second_category.update_columns position: 0 59 | third_category.update_columns position: -42 60 | fourth_category.update_columns position: 998 61 | fifth_category.update_columns position: 800 62 | sixth_category.update_columns position: 1000 63 | 64 | Category.heal_position_column! 65 | 66 | assert_equal [1, 2, 1], [second_category.reload, first_category.reload, third_category.reload].map(&:position) 67 | assert_equal [1, 2, 3], [fifth_category.reload, fourth_category.reload, sixth_category.reload].map(&:position) 68 | end 69 | 70 | def test_heal_position_with_no_scope 71 | first_product = Product.create name: "First Product" 72 | second_product = Product.create name: "Second Product" 73 | third_product = Product.create name: "Third Product" 74 | 75 | first_product.update_columns position: 9 76 | second_product.update_columns position: 0 77 | third_product.update_columns position: -42 78 | 79 | Product.heal_position_column! 80 | 81 | assert_equal [1, 2, 3], [third_product.reload, second_product.reload, first_product.reload].map(&:position) 82 | end 83 | 84 | def test_heal_position_with_default_scope 85 | first_list = List.create name: "First List" 86 | 87 | first_item = first_list.default_scope_items.create name: "First Item" 88 | second_item = first_list.default_scope_items.create name: "Second Item" 89 | third_item = first_list.default_scope_items.create name: "Third Item" 90 | 91 | first_item.update_columns position: 10 92 | second_item.update_columns position: 15 93 | third_item.update_columns position: 5 94 | 95 | DefaultScopeItem.heal_position_column! 96 | 97 | assert_equal [1, 2, 3], [third_item.reload, first_item.reload, second_item.reload].map(&:position) 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "positioning" 3 | require "support/active_record" 4 | 5 | require "minitest/hooks/test" 6 | require "minitest/autorun" 7 | require "mocha/minitest" 8 | 9 | require_relative "models/list" 10 | require_relative "models/item" 11 | require_relative "models/new_item" 12 | require_relative "models/default_scope_item" 13 | require_relative "models/composite_primary_key_item" 14 | require_relative "models/product" 15 | require_relative "models/category" 16 | require_relative "models/categorised_item" 17 | require_relative "models/author" 18 | require_relative "models/author/student" 19 | require_relative "models/author/teacher" 20 | require_relative "models/blog" 21 | require_relative "models/post" 22 | require_relative "models/entity" 23 | -------------------------------------------------------------------------------- /test/test_positioning.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TestRelativePositionStruct < Minitest::Test 4 | def test_struct_takes_keyword_arguments 5 | relative_position = Positioning::RelativePosition.new(before: 1) 6 | assert_equal 1, relative_position.before 7 | assert_nil relative_position.after 8 | 9 | relative_position = Positioning::RelativePosition.new(after: 1) 10 | assert_equal 1, relative_position.after 11 | assert_nil relative_position.before 12 | 13 | relative_position = Positioning::RelativePosition.new(before: 2, after: 1) 14 | assert_equal 2, relative_position.before 15 | assert_equal 1, relative_position.after 16 | end 17 | end 18 | 19 | class TestTransactionSafety < Minitest::Test 20 | def test_no_duplicate_row_values_when_creating 21 | ActiveRecord::Base.connection_handler.clear_all_connections! 22 | 23 | list = List.create name: "List" 24 | students = [] 25 | 26 | 4.times do 27 | threads = 5.times.map do 28 | Thread.new do 29 | ActiveRecord::Base.connection_pool.with_connection do 30 | students << list.authors.create(name: "Student", type: "Author::Student") 31 | end 32 | end 33 | end 34 | threads.each(&:join) 35 | end 36 | 37 | assert_equal (1..students.length).to_a, list.authors.map(&:position) 38 | 39 | list.destroy 40 | end 41 | 42 | def test_no_duplicate_row_values_when_updating 43 | ActiveRecord::Base.connection_handler.clear_all_connections! 44 | 45 | list = List.create name: "List" 46 | first_student = list.authors.create name: "First Student", type: "Author::Student" 47 | second_student = list.authors.create name: "Second Student", type: "Author::Student" 48 | third_student = list.authors.create name: "Third Student", type: "Author::Student" 49 | 50 | students = [] 51 | 52 | first_thread = Thread.new do 53 | ActiveRecord::Base.connection_pool.with_connection do 54 | third_student.update(position: 1) 55 | students << third_student 56 | end 57 | end 58 | 59 | second_thread = Thread.new do 60 | ActiveRecord::Base.connection_pool.with_connection do 61 | second_student.update(position: 1) 62 | students << second_student 63 | end 64 | end 65 | 66 | third_thread = Thread.new do 67 | ActiveRecord::Base.connection_pool.with_connection do 68 | first_student.update(position: 1) 69 | students << first_student 70 | end 71 | end 72 | 73 | [first_thread, second_thread, third_thread].each(&:join) 74 | 75 | students.each(&:reload) 76 | 77 | assert_equal [1, 2, 3], students.reverse.map(&:position) 78 | 79 | list.destroy 80 | end 81 | 82 | def test_no_duplicate_row_values_when_destroying 83 | ActiveRecord::Base.connection_handler.clear_all_connections! 84 | 85 | list = List.create name: "List" 86 | students = [] 87 | 88 | ["A", "B", "C", "D", "E", "F", "G", "H"].each do |name| 89 | students << list.authors.create(name: name, type: "Author::Student") 90 | end 91 | 92 | 2.times do 93 | threads = 3.times.map do 94 | Thread.new do 95 | ActiveRecord::Base.connection_pool.with_connection do 96 | student = students.shift 97 | student.destroy 98 | end 99 | end 100 | end 101 | threads.each(&:join) 102 | end 103 | 104 | students.each(&:reload) 105 | 106 | assert_equal [[1, "G"], [2, "H"]], students.sort_by(&:position).pluck(:position, :name) 107 | assert_equal [[1, "G"], [2, "H"]], list.authors.order(:position).pluck(:position, :name) 108 | 109 | list.destroy 110 | end 111 | end 112 | 113 | class TestPositioningMechanisms < Minitest::Test 114 | include Minitest::Hooks 115 | 116 | def around 117 | ActiveRecord::Base.transaction do 118 | super 119 | raise ActiveRecord::Rollback 120 | end 121 | end 122 | 123 | def test_active_record_is_not_polluted 124 | refute Item.const_defined?(:Mechanisms) 125 | end 126 | 127 | def test_base_class 128 | list = List.create name: "List" 129 | student = list.authors.create name: "Student", type: "Author::Student" 130 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 131 | 132 | mechanisms = Positioning::Mechanisms.new(student, :position) 133 | assert_equal Author, mechanisms.send(:base_class) 134 | 135 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 136 | assert_equal Author, mechanisms.send(:base_class) 137 | end 138 | 139 | def test_primary_key 140 | list = List.create name: "List" 141 | student = list.authors.create name: "Student", type: "Author::Student" 142 | 143 | mechanisms = Positioning::Mechanisms.new(student, :position) 144 | assert_equal "id", mechanisms.send(:primary_key) 145 | end 146 | 147 | def test_with_connection 148 | list = List.create name: "List" 149 | student = list.authors.create name: "Student", type: "Author::Student" 150 | 151 | mechanisms = Positioning::Mechanisms.new(student, :position) 152 | mechanisms.send(:with_connection) do |connection| 153 | assert_kind_of ActiveRecord::ConnectionAdapters::AbstractAdapter, connection 154 | end 155 | end 156 | 157 | def test_record_scope 158 | list = List.create name: "List" 159 | student = list.authors.create name: "Student", type: "Author::Student" 160 | 161 | mechanisms = Positioning::Mechanisms.new(student, :position) 162 | assert_equal Author.where(id: student.id).to_sql, mechanisms.send(:record_scope).to_sql 163 | end 164 | 165 | def test_position 166 | list = List.create name: "List" 167 | student = list.authors.create name: "Student", type: "Author::Student" 168 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 169 | 170 | mechanisms = Positioning::Mechanisms.new(student, :position) 171 | assert_equal 1, mechanisms.send(:position) 172 | 173 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 174 | assert_equal 2, mechanisms.send(:position) 175 | end 176 | 177 | def test_position= 178 | list = List.create name: "List" 179 | student = list.authors.create name: "Student", type: "Author::Student" 180 | 181 | mechanisms = Positioning::Mechanisms.new(student, :position) 182 | mechanisms.send(:position=, 2) 183 | assert_equal 2, student.position 184 | end 185 | 186 | def test_clear_position 187 | list = List.create name: "List" 188 | student = list.authors.create name: "Student", type: "Author::Student" 189 | 190 | mechanisms = Positioning::Mechanisms.new(student, :position) 191 | mechanisms.send(:clear_position) 192 | assert_nil student.position 193 | end 194 | 195 | def test_position_changed? 196 | list = List.create name: "List" 197 | student = list.authors.create name: "Student", type: "Author::Student" 198 | 199 | mechanisms = Positioning::Mechanisms.new(student, :position) 200 | student.position = 2 201 | assert mechanisms.send(:position_changed?) 202 | end 203 | 204 | def test_position_was 205 | list = List.create name: "List" 206 | student = list.authors.create name: "Student", type: "Author::Student" 207 | 208 | mechanisms = Positioning::Mechanisms.new(student, :position) 209 | student.position = 2 210 | 211 | assert_equal 1, mechanisms.send(:position_was) 212 | assert mechanisms.instance_variable_defined? :@position_was 213 | end 214 | 215 | def test_move_out_of_the_way 216 | list = List.create name: "List" 217 | student = list.authors.create name: "Student", type: "Author::Student" 218 | 219 | mechanisms = Positioning::Mechanisms.new(student, :position) 220 | mechanisms.send(:move_out_of_the_way) 221 | 222 | assert_equal 1, mechanisms.send(:position_was) 223 | assert mechanisms.instance_variable_defined? :@position_was 224 | assert_equal 0, Author.where(id: student.id).pick(:position) 225 | end 226 | 227 | def test_expand 228 | list = List.create name: "List" 229 | list.authors.create name: "Student", type: "Author::Student" 230 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 231 | list.authors.create name: "Student", type: "Author::Student" 232 | 233 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 234 | mechanisms.send(:expand, list.authors, 2..) 235 | assert_equal [1, 3, 4], list.authors.pluck(:position) 236 | end 237 | 238 | def test_contract 239 | list = List.create name: "List" 240 | list.authors.create name: "Student", type: "Author::Student" 241 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 242 | list.authors.create name: "Student", type: "Author::Student" 243 | 244 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 245 | Author.where(id: teacher.id).update_all position: 0 246 | mechanisms.send(:contract, list.authors, 2..) 247 | assert_equal [0, 1, 2], list.authors.pluck(:position) 248 | end 249 | 250 | def test_solidify_position_integer 251 | list = List.create name: "List" 252 | student = list.authors.create name: "Student", type: "Author::Student" 253 | list.authors.create name: "Teacher", type: "Author::Teacher" 254 | 255 | mechanisms = Positioning::Mechanisms.new(student, :position) 256 | 257 | [0, "0", 0.to_json].each do |position| 258 | student.reload 259 | student.position = position 260 | mechanisms.send(:solidify_position) 261 | assert_equal 1, student.position 262 | end 263 | 264 | student.reload 265 | student.position = 2 266 | mechanisms.send(:solidify_position) 267 | assert_equal 2, student.position 268 | 269 | [3, "3", 3.to_json].each do |position| 270 | student.reload 271 | student.position = position 272 | mechanisms.send(:solidify_position) 273 | assert_equal 2, student.position 274 | end 275 | end 276 | 277 | def test_solidify_position_first_and_after_nil 278 | list = List.create name: "List" 279 | list.authors.create name: "Student", type: "Author::Student" 280 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 281 | 282 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 283 | 284 | [:first, "first", "first".to_json, 285 | {after: nil}, {after: nil}.to_json, 286 | {after: ""}, {after: ""}.to_json].each do |position| 287 | teacher.reload 288 | teacher.position = position 289 | mechanisms.send(:solidify_position) 290 | assert_equal 1, teacher.position 291 | end 292 | end 293 | 294 | def test_solidify_position_nil_last_and_before_nil 295 | list = List.create name: "List" 296 | student = list.authors.create name: "Student", type: "Author::Student" 297 | list.authors.create name: "Teacher", type: "Author::Teacher" 298 | 299 | mechanisms = Positioning::Mechanisms.new(student, :position) 300 | 301 | [nil, nil.to_json, "", "".to_json, 302 | :last, "last", "last".to_json, 303 | {before: nil}, {before: nil}.to_json, 304 | {before: ""}, {before: ""}.to_json].each do |position| 305 | student.reload 306 | student.position = position 307 | mechanisms.send(:solidify_position) 308 | assert_equal 2, student.position 309 | end 310 | end 311 | 312 | def test_solidify_position_before 313 | list = List.create name: "List" 314 | student = list.authors.create name: "Student", type: "Author::Student" 315 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 316 | list.authors.create name: "Teacher", type: "Author::Teacher" 317 | last_teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 318 | 319 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 320 | 321 | [{before: student}, {before: student.id}, {before: student.id}.to_json].each do |position| 322 | teacher.reload 323 | teacher.position = position 324 | mechanisms.send(:solidify_position) 325 | assert_equal 1, teacher.position 326 | end 327 | 328 | [{before: teacher}, {before: teacher.id}, {before: teacher.id}.to_json].each do |position| 329 | teacher.reload 330 | teacher.position = position 331 | mechanisms.send(:solidify_position) 332 | assert_equal 2, teacher.position 333 | end 334 | 335 | [{before: last_teacher}, {before: last_teacher.id}, {before: last_teacher.id}.to_json].each do |position| 336 | teacher.reload 337 | teacher.position = position 338 | mechanisms.send(:solidify_position) 339 | assert_equal 3, teacher.position 340 | end 341 | end 342 | 343 | def test_solidify_position_after 344 | list = List.create name: "List" 345 | student = list.authors.create name: "Student", type: "Author::Student" 346 | list.authors.create name: "Teacher", type: "Author::Teacher" 347 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 348 | last_teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 349 | 350 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 351 | 352 | [{after: student}, {after: student.id}, {after: student.id}.to_json].each do |position| 353 | teacher.reload 354 | teacher.position = position 355 | mechanisms.send(:solidify_position) 356 | assert_equal 2, teacher.position 357 | end 358 | 359 | [{after: teacher}, {after: teacher.id}, {after: teacher.id}.to_json].each do |position| 360 | teacher.reload 361 | teacher.position = position 362 | mechanisms.send(:solidify_position) 363 | assert_equal 3, teacher.position 364 | end 365 | 366 | [{after: last_teacher}, {after: last_teacher.id}, {after: last_teacher.id}.to_json].each do |position| 367 | teacher.reload 368 | teacher.position = position 369 | mechanisms.send(:solidify_position) 370 | assert_equal 4, teacher.position 371 | end 372 | end 373 | 374 | def test_solidify_position_before_new_scope 375 | list = List.create name: "List" 376 | second_list = List.create name: "List" 377 | list.authors.create name: "Student", type: "Author::Student" 378 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 379 | list.authors.create name: "Teacher", type: "Author::Teacher" 380 | other_teacher = second_list.authors.create name: "Teacher", type: "Author::Teacher" 381 | 382 | mechanisms = Positioning::Mechanisms.new(other_teacher, :position) 383 | 384 | [{before: teacher}, {before: teacher.id}, {before: teacher.id}.to_json].each do |position| 385 | other_teacher.reload 386 | other_teacher.position = position 387 | other_teacher.list = list 388 | mechanisms.send(:solidify_position) 389 | assert_equal 2, other_teacher.position 390 | end 391 | end 392 | 393 | def test_solidify_position_after_new_scope 394 | list = List.create name: "List" 395 | second_list = List.create name: "List" 396 | list.authors.create name: "Student", type: "Author::Student" 397 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 398 | list.authors.create name: "Teacher", type: "Author::Teacher" 399 | other_teacher = second_list.authors.create name: "Teacher", type: "Author::Teacher" 400 | 401 | mechanisms = Positioning::Mechanisms.new(other_teacher, :position) 402 | 403 | [{after: teacher}, {after: teacher.id}, {after: teacher.id}.to_json].each do |position| 404 | other_teacher.reload 405 | other_teacher.position = position 406 | other_teacher.list = list 407 | mechanisms.send(:solidify_position) 408 | assert_equal 3, other_teacher.position 409 | end 410 | end 411 | 412 | def test_solidify_position_with_has_with_indifferent_access 413 | list = List.create name: "List" 414 | student = list.authors.create name: "Student", type: "Author::Student" 415 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 416 | 417 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 418 | 419 | teacher.position = {before: student}.with_indifferent_access 420 | mechanisms.send(:solidify_position) 421 | assert_equal 1, teacher.position 422 | end 423 | 424 | def test_last_position 425 | list = List.create name: "List" 426 | student = list.authors.create name: "Student", type: "Author::Student" 427 | list.authors.create name: "Student", type: "Author::Student" 428 | list.authors.create name: "Student", type: "Author::Student" 429 | 430 | mechanisms = Positioning::Mechanisms.new(student, :position) 431 | assert_equal 3, mechanisms.send(:last_position) 432 | end 433 | 434 | def test_scope_columns 435 | list = List.create name: "List" 436 | student = list.authors.create name: "Student", type: "Author::Student" 437 | 438 | mechanisms = Positioning::Mechanisms.new(student, :position) 439 | assert_equal ["list_id", "enabled"], mechanisms.send(:scope_columns) 440 | end 441 | 442 | def test_scope_associations 443 | list = List.create name: "List" 444 | student = list.authors.create name: "Student", type: "Author::Student" 445 | 446 | mechanisms = Positioning::Mechanisms.new(student, :position) 447 | assert_equal [:list], mechanisms.send(:scope_associations) 448 | end 449 | 450 | def test_lock_positioning_scope_with_new_record_and_scope_association 451 | list = List.create name: "List" 452 | student = list.authors.create name: "Student", type: "Author::Student" 453 | 454 | mechanisms = Positioning::Mechanisms.new(student, :position) 455 | 456 | List.expects(:lock).once.returns(List) 457 | mechanisms.send(:lock_positioning_scope!) 458 | end 459 | 460 | def test_lock_positioning_scope_with_persisted_record_and_scope_association_change 461 | first_list = List.create name: "First List" 462 | second_list = List.create name: "Second List" 463 | student = first_list.authors.create name: "Student", type: "Author::Student" 464 | student.list = second_list 465 | 466 | mechanisms = Positioning::Mechanisms.new(student, :position) 467 | 468 | List.expects(:lock).twice.returns(List) 469 | mechanisms.send(:lock_positioning_scope!) 470 | end 471 | 472 | def test_lock_positioning_scope_with_only_scope_columns 473 | blog = Blog.create name: "Blog" 474 | mechanisms = Positioning::Mechanisms.new(blog, :position) 475 | 476 | ActiveRecord::Relation.any_instance.expects(:lock).once.returns(Blog) 477 | mechanisms.send(:lock_positioning_scope!) 478 | end 479 | 480 | def test_lock_positioning_scope_with_only_scope_columns_on_persisted_record_and_scope_change 481 | blog = Blog.create name: "Blog" 482 | blog.enabled = false 483 | mechanisms = Positioning::Mechanisms.new(blog, :position) 484 | 485 | ActiveRecord::Relation.any_instance.expects(:lock).twice.returns(Blog) 486 | mechanisms.send(:lock_positioning_scope!) 487 | end 488 | 489 | def test_lock_positioning_scope_without_scope_association 490 | product = Product.create name: "Product" 491 | mechanisms = Positioning::Mechanisms.new(product, :position) 492 | 493 | ActiveRecord::Relation.any_instance.expects(:lock).once.returns(Product) 494 | mechanisms.send(:lock_positioning_scope!) 495 | end 496 | 497 | def test_lock_positioning_scope_with_optional_scope_association 498 | blog = Blog.create name: "Blog" 499 | blog.posts.create name: "First Post" 500 | second_post = Post.create name: "Second Post" 501 | second_post.blog = blog 502 | 503 | mechanisms = Positioning::Mechanisms.new(second_post, :position) 504 | 505 | Blog.expects(:lock).once.returns(Blog) 506 | mechanisms.send(:lock_positioning_scope!) 507 | end 508 | 509 | def test_positioning_scope 510 | list = List.create name: "List" 511 | student = list.authors.create name: "Student", type: "Author::Student" 512 | 513 | mechanisms = Positioning::Mechanisms.new(student, :position) 514 | assert_equal Author.where(list: list, enabled: true).to_sql, mechanisms.send(:positioning_scope).to_sql 515 | end 516 | 517 | def test_positioning_scope_was 518 | first_list = List.create name: "List" 519 | second_list = List.create name: "List" 520 | student = first_list.authors.create name: "Student", type: "Author::Student" 521 | 522 | mechanisms = Positioning::Mechanisms.new(student, :position) 523 | student.list = second_list 524 | 525 | assert_equal Author.where(list: second_list, enabled: true).to_sql, mechanisms.send(:positioning_scope).to_sql 526 | 527 | assert_equal Author.where(list: first_list, enabled: true).to_sql, mechanisms.send(:positioning_scope_was).to_sql 528 | end 529 | 530 | def test_in_positioning_scope? 531 | first_list = List.create name: "List" 532 | second_list = List.create name: "List" 533 | student = first_list.authors.create name: "Student", type: "Author::Student" 534 | 535 | mechanisms = Positioning::Mechanisms.new(student, :position) 536 | assert mechanisms.send(:in_positioning_scope?) 537 | 538 | student.list = second_list 539 | refute mechanisms.send(:in_positioning_scope?) 540 | end 541 | 542 | def test_positioning_scope_changed? 543 | first_list = List.create name: "List" 544 | second_list = List.create name: "List" 545 | student = first_list.authors.create name: "Student", type: "Author::Student" 546 | 547 | mechanisms = Positioning::Mechanisms.new(student, :position) 548 | refute mechanisms.send(:positioning_scope_changed?) 549 | 550 | student.list = second_list 551 | assert mechanisms.send(:positioning_scope_changed?) 552 | end 553 | 554 | def test_destroyed_via_positioning_scope? 555 | list = List.create name: "List" 556 | student = list.authors.create name: "Student", type: "Author::Student" 557 | teacher = list.authors.create name: "Teacher", type: "Author::Teacher" 558 | 559 | mechanisms = Positioning::Mechanisms.new(student, :position) 560 | refute mechanisms.send(:destroyed_via_positioning_scope?) 561 | 562 | mechanisms = Positioning::Mechanisms.new(teacher, :position) 563 | teacher.destroy 564 | refute mechanisms.send(:destroyed_via_positioning_scope?) 565 | 566 | list.destroy 567 | assert mechanisms.send(:destroyed_via_positioning_scope?) 568 | end 569 | end 570 | 571 | class TestPositioningScopes < Minitest::Test 572 | include Minitest::Hooks 573 | 574 | def around 575 | ActiveRecord::Base.transaction do 576 | super 577 | raise ActiveRecord::Rollback 578 | end 579 | end 580 | 581 | def test_that_it_has_a_version_number 582 | refute_nil ::Positioning::VERSION 583 | end 584 | 585 | def test_that_position_columns_starts_empty 586 | assert_equal({}, List.positioning_columns) 587 | end 588 | 589 | def test_that_position_columns_has_default_column 590 | assert_equal({position: {scope_columns: ["list_id"], scope_associations: [:list]}}, Item.positioning_columns) 591 | end 592 | 593 | def test_that_position_columns_does_not_need_a_scope 594 | assert_equal({position: {scope_columns: [], scope_associations: []}}, Product.positioning_columns) 595 | end 596 | 597 | def test_that_position_columns_can_have_multiple_entries 598 | assert_equal({position: {scope_columns: ["list_id"], scope_associations: [:list]}, category_position: {scope_columns: ["list_id", "category_id"], scope_associations: [:list, :category]}}, CategorisedItem.positioning_columns) 599 | end 600 | 601 | def test_that_position_columns_will_cope_with_standard_columns 602 | assert_equal({position: {scope_columns: ["list_id", "enabled"], scope_associations: [:list]}}, Author.positioning_columns) 603 | end 604 | 605 | def test_that_position_columns_will_cope_with_polymorphic_belong_to 606 | assert_equal({position: {scope_columns: ["includable_id", "includable_type"], scope_associations: [:includable]}}, Entity.positioning_columns) 607 | end 608 | 609 | def test_that_position_columns_must_have_unique_keys 610 | assert_raises(Positioning::Error) do 611 | Item.send :positioned, on: :list 612 | end 613 | end 614 | 615 | def test_that_an_error_is_raised_when_initialising_on_non_base_class 616 | assert_raises(Positioning::Error) do 617 | Author::Student.send :positioned 618 | end 619 | end 620 | 621 | def test_that_the_default_list_scope_works 622 | list = List.create name: "First List" 623 | first_item = list.items.create name: "First Item" 624 | second_item = list.items.create name: "Second Item" 625 | third_item = list.items.create name: "Third Item" 626 | 627 | assert_equal [first_item, second_item, third_item], 628 | Positioning::Mechanisms.new(second_item, :position).send(:positioning_scope).order(:position) 629 | end 630 | 631 | def test_that_destroyed_via_positioning_scope_does_not_call_contract 632 | list = List.create name: "First List" 633 | list.items.create name: "First Item" 634 | list.items.create name: "Second Item" 635 | list.items.create name: "Third Item" 636 | 637 | Positioning::Mechanisms.any_instance.expects(:contract).never 638 | 639 | list.destroy 640 | end 641 | 642 | def test_that_not_destroyed_via_positioning_scope_calls_contract 643 | list = List.create name: "First List" 644 | list.items.create name: "First Item" 645 | second_item = list.items.create name: "Second Item" 646 | list.items.create name: "Third Item" 647 | 648 | Positioning::Mechanisms.any_instance.expects(:contract).once 649 | 650 | second_item.destroy 651 | end 652 | 653 | def test_that_not_destroyed_via_positioning_scope_closes_gap 654 | list = List.create name: "First List" 655 | first_item = list.items.create name: "First Item" 656 | second_item = list.items.create name: "Second Item" 657 | third_item = list.items.create name: "Third Item" 658 | 659 | second_item.destroy 660 | 661 | assert_equal [1, 2], [first_item.reload, third_item.reload].map(&:position) 662 | end 663 | end 664 | 665 | class TestPositioningColumns < Minitest::Test 666 | include Minitest::Hooks 667 | 668 | def around 669 | ActiveRecord::Base.transaction do 670 | super 671 | raise ActiveRecord::Rollback 672 | end 673 | end 674 | 675 | def test_that_a_column_named_order_works 676 | first_post = Post.create name: "First Post" 677 | second_post = Post.create name: "Second Post" 678 | third_post = Post.create name: "Third Post" 679 | 680 | assert_equal [1, 2, 3], [first_post.reload, second_post.reload, third_post.reload].map(&:order) 681 | 682 | second_post.update order: {before: first_post} 683 | 684 | assert_equal [1, 2, 3], [second_post.reload, first_post.reload, third_post.reload].map(&:order) 685 | 686 | first_post.update order: {after: third_post} 687 | 688 | assert_equal [1, 2, 3], [second_post.reload, third_post.reload, first_post.reload].map(&:order) 689 | 690 | third_post.destroy 691 | 692 | assert_equal [1, 2], [second_post.reload, first_post.reload].map(&:order) 693 | end 694 | end 695 | 696 | class TestDuplication < Minitest::Test 697 | include Minitest::Hooks 698 | 699 | def around 700 | ActiveRecord::Base.transaction do 701 | super 702 | raise ActiveRecord::Rollback 703 | end 704 | end 705 | 706 | def test_that_dup_clears_position 707 | first_list = List.create name: "First List" 708 | first_item = first_list.items.create name: "First Item" 709 | second_item = first_list.items.create name: "Second Item" 710 | third_item = first_list.items.create name: "Third Item" 711 | 712 | assert_equal [1, 2, 3], [first_item.reload, second_item.reload, third_item.reload].map(&:position) 713 | 714 | last_item = first_item.dup 715 | last_item.save 716 | 717 | assert_equal [1, 2, 3, 4], [first_item.reload, second_item.reload, third_item.reload, last_item.reload].map(&:position) 718 | 719 | fifth_item = first_item.dup 720 | assert_nil fifth_item.position 721 | 722 | fourth_item = second_item.dup 723 | sixth_item = third_item.dup 724 | 725 | second_list = first_list.dup 726 | second_list.save 727 | second_list.items = fourth_item, fifth_item, sixth_item 728 | 729 | assert_equal [1, 2, 3], [fourth_item.reload, fifth_item.reload, sixth_item.reload].map(&:position) 730 | end 731 | end 732 | 733 | class TestPositioning < Minitest::Test 734 | include Minitest::Hooks 735 | 736 | def around 737 | ActiveRecord::Base.transaction do 738 | super 739 | raise ActiveRecord::Rollback 740 | end 741 | end 742 | 743 | def configure 744 | @association = :items 745 | 746 | @id = Enumerator.new do |yielder| 747 | loop do 748 | yielder.yield(nil) 749 | end 750 | end 751 | end 752 | 753 | def setup 754 | configure 755 | 756 | @first_list = List.create name: "First List" 757 | @second_list = List.create name: "Second List" 758 | @first_item = @first_list.send(@association).create id: @id.next, name: "First Item" 759 | @second_item = @first_list.send(@association).create id: @id.next, name: "Second Item" 760 | @third_item = @first_list.send(@association).create id: @id.next, name: "Third Item" 761 | @fourth_item = @second_list.send(@association).create id: @id.next, name: "Fourth Item" 762 | @fifth_item = @second_list.send(@association).create id: @id.next, name: "Fifth Item" 763 | @sixth_item = @second_list.send(@association).create id: @id.next, name: "Sixth Item" 764 | 765 | @models = [ 766 | @first_list, @second_list, @first_item, @second_item, 767 | @third_item, @fourth_item, @fifth_item, @sixth_item 768 | ] 769 | 770 | reload_models 771 | end 772 | 773 | def reload_models 774 | @models.map(&:reload) 775 | end 776 | 777 | def test_that_updating_an_item_does_not_change_its_position 778 | @second_item.update name: "Focus Item" 779 | reload_models 780 | 781 | assert_equal [1, 2, 3], [@first_item, @second_item, @third_item].map(&:position) 782 | end 783 | 784 | def test_that_prior_item_is_found 785 | assert_nil @first_item.prior_position 786 | assert_equal @first_item, @second_item.prior_position 787 | assert_equal @second_item, @third_item.prior_position 788 | end 789 | 790 | def test_that_subsequent_item_is_found 791 | assert_equal @second_item, @first_item.subsequent_position 792 | assert_equal @third_item, @second_item.subsequent_position 793 | assert_nil @third_item.subsequent_position 794 | end 795 | 796 | def test_that_positions_are_automatically_assigned 797 | assert_equal [1, 2, 3], [@first_item, @second_item, @third_item].map(&:position) 798 | assert_equal [1, 2, 3], [@fourth_item, @fifth_item, @sixth_item].map(&:position) 799 | end 800 | 801 | def test_that_an_item_is_added_to_the_end_of_a_new_scope_by_default 802 | @second_item.update list: @second_list 803 | reload_models 804 | 805 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 806 | assert_equal [1, 2, 3, 4], [@fourth_item, @fifth_item, @sixth_item, @second_item].map(&:position) 807 | end 808 | 809 | def test_that_an_item_is_added_to_position_of_a_new_scope_when_explicitly_set 810 | @second_item.update list: @second_list, position: 2 # NOTE: The same position it already had 811 | @third_item.update list: @second_list, position: 1 812 | @first_item.update list: @second_list, position: nil 813 | reload_models 814 | 815 | assert @first_list.send(@association).empty? 816 | assert_equal @second_list.send(@association), [@third_item, @fourth_item, @second_item, @fifth_item, @sixth_item, @first_item] 817 | assert_equal [1, 2, 3, 4, 5, 6], [@third_item, @fourth_item, @second_item, @fifth_item, @sixth_item, @first_item].map(&:position) 818 | end 819 | 820 | def test_that_position_is_assignable_on_create 821 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: 2 822 | reload_models 823 | 824 | assert_equal [1, 2, 3, 4], [@first_item, seventh_item, @second_item, @third_item].map(&:position) 825 | end 826 | 827 | def test_that_position_is_assignable_on_update 828 | @first_item.update position: 2 829 | reload_models 830 | 831 | assert_equal [1, 2, 3], [@second_item, @first_item, @third_item].map(&:position) 832 | end 833 | 834 | def test_that_position_is_assignable_on_update_in_new_scope 835 | @first_item.update list: @second_list, position: 2 836 | reload_models 837 | 838 | assert_equal [1, 2], [@second_item, @third_item].map(&:position) 839 | assert_equal [1, 2, 3, 4], [@fourth_item, @first_item, @fifth_item, @sixth_item].map(&:position) 840 | end 841 | 842 | def test_that_item_position_is_clamped_up_to_1_on_create 843 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: 0 844 | reload_models 845 | 846 | assert_equal [1, 2, 3, 4], [seventh_item, @first_item, @second_item, @third_item].map(&:position) 847 | end 848 | 849 | def test_that_item_position_is_clamped_down_to_max_plus_1_on_create 850 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: 100 851 | reload_models 852 | 853 | assert_equal [1, 2, 3, 4], [@first_item, @second_item, @third_item, seventh_item].map(&:position) 854 | end 855 | 856 | def test_that_item_position_is_clamped_up_to_1_on_update 857 | @second_item.update position: 0 858 | reload_models 859 | 860 | assert_equal [1, 2, 3], [@second_item, @first_item, @third_item].map(&:position) 861 | end 862 | 863 | def test_that_item_position_is_clamped_down_to_max_plus_1_on_update 864 | @second_item.update position: 100 865 | reload_models 866 | 867 | assert_equal [1, 2, 3], [@first_item, @third_item, @second_item].map(&:position) 868 | end 869 | 870 | def test_that_item_position_is_clamped_up_to_1_on_update_scope 871 | @second_item.update list: @second_list, position: 0 872 | reload_models 873 | 874 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 875 | assert_equal [1, 2, 3, 4], [@second_item, @fourth_item, @fifth_item, @sixth_item].map(&:position) 876 | end 877 | 878 | def test_that_item_position_is_clamped_down_to_max_plus_1_on_update_scope 879 | @second_item.update list: @second_list, position: 100 880 | reload_models 881 | 882 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 883 | assert_equal [1, 2, 3, 4], [@fourth_item, @fifth_item, @sixth_item, @second_item].map(&:position) 884 | end 885 | 886 | def test_that_item_is_at_start_of_list_on_create_with_first 887 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: :first 888 | reload_models 889 | 890 | assert_equal [1, 2, 3, 4], [seventh_item, @first_item, @second_item, @third_item].map(&:position) 891 | end 892 | 893 | def test_that_item_is_at_start_of_list_on_update_with_first 894 | @second_item.update position: :first 895 | reload_models 896 | 897 | assert_equal [1, 2, 3], [@second_item, @first_item, @third_item].map(&:position) 898 | end 899 | 900 | def test_that_item_is_at_end_of_list_on_create_with_last 901 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: :last 902 | reload_models 903 | 904 | assert_equal [1, 2, 3, 4], [@first_item, @second_item, @third_item, seventh_item].map(&:position) 905 | end 906 | 907 | def test_that_item_is_at_end_of_list_on_update_with_last 908 | @second_item.update position: :last 909 | reload_models 910 | 911 | assert_equal [1, 2, 3], [@first_item, @third_item, @second_item].map(&:position) 912 | end 913 | 914 | def test_that_item_is_at_end_of_list_on_create_with_nil 915 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: nil 916 | reload_models 917 | 918 | assert_equal [1, 2, 3, 4], [@first_item, @second_item, @third_item, seventh_item].map(&:position) 919 | end 920 | 921 | def test_that_item_is_at_end_of_list_on_update_with_nil 922 | @second_item.update position: nil 923 | reload_models 924 | 925 | assert_equal [1, 2, 3], [@first_item, @third_item, @second_item].map(&:position) 926 | end 927 | 928 | def test_that_items_are_moved_out_of_the_way_on_create_with_before 929 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: {before: @second_item} 930 | reload_models 931 | 932 | assert_equal [1, 2, 3, 4], [@first_item, seventh_item, @second_item, @third_item].map(&:position) 933 | end 934 | 935 | def test_that_items_are_moved_out_of_the_way_on_create_with_before_id 936 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: {before: @second_item.id} 937 | reload_models 938 | 939 | assert_equal [1, 2, 3, 4], [@first_item, seventh_item, @second_item, @third_item].map(&:position) 940 | end 941 | 942 | def test_that_items_are_moved_out_of_the_way_on_create_with_before_nil 943 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: {before: nil} 944 | reload_models 945 | 946 | assert_equal [1, 2, 3, 4], [@first_item, @second_item, @third_item, seventh_item].map(&:position) 947 | end 948 | 949 | def test_that_items_are_moved_out_of_the_way_on_create_with_after 950 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: {after: @second_item} 951 | reload_models 952 | 953 | assert_equal [1, 2, 3, 4], [@first_item, @second_item, seventh_item, @third_item].map(&:position) 954 | end 955 | 956 | def test_that_items_are_moved_out_of_the_way_on_create_with_after_id 957 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: {after: @second_item.id} 958 | reload_models 959 | 960 | assert_equal [1, 2, 3, 4], [@first_item, @second_item, seventh_item, @third_item].map(&:position) 961 | end 962 | 963 | def test_that_items_are_moved_out_of_the_way_on_create_with_after_nil 964 | seventh_item = @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: {after: nil} 965 | reload_models 966 | 967 | assert_equal [1, 2, 3, 4], [seventh_item, @first_item, @second_item, @third_item].map(&:position) 968 | end 969 | 970 | def test_that_items_are_moved_out_of_the_way_on_update_with_before 971 | @first_item.update position: {before: @third_item} 972 | reload_models 973 | 974 | assert_equal [1, 2, 3], [@second_item, @first_item, @third_item].map(&:position) 975 | end 976 | 977 | def test_that_items_are_moved_out_of_the_way_on_update_with_before_id 978 | @first_item.update position: {before: @third_item.id} 979 | reload_models 980 | 981 | assert_equal [1, 2, 3], [@second_item, @first_item, @third_item].map(&:position) 982 | end 983 | 984 | def test_that_items_are_moved_out_of_the_way_on_update_with_before_nil 985 | @first_item.update position: {before: nil} 986 | reload_models 987 | 988 | assert_equal [1, 2, 3], [@second_item, @third_item, @first_item].map(&:position) 989 | end 990 | 991 | def test_that_items_are_moved_out_of_the_way_on_update_with_after 992 | @third_item.update position: {after: @first_item} 993 | reload_models 994 | 995 | assert_equal [1, 2, 3], [@first_item, @third_item, @second_item].map(&:position) 996 | end 997 | 998 | def test_that_items_are_moved_out_of_the_way_on_update_with_after_id 999 | @third_item.update position: {after: @first_item.id} 1000 | reload_models 1001 | 1002 | assert_equal [1, 2, 3], [@first_item, @third_item, @second_item].map(&:position) 1003 | end 1004 | 1005 | def test_that_items_are_moved_out_of_the_way_on_update_with_after_nil 1006 | @third_item.update position: {after: nil} 1007 | reload_models 1008 | 1009 | assert_equal [1, 2, 3], [@third_item, @first_item, @second_item].map(&:position) 1010 | end 1011 | 1012 | def test_that_items_are_moved_out_of_the_way_on_update_scope_with_before 1013 | @second_item.update list: @second_list, position: {before: @sixth_item} 1014 | reload_models 1015 | 1016 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 1017 | assert_equal [1, 2, 3, 4], [@fourth_item, @fifth_item, @second_item, @sixth_item].map(&:position) 1018 | end 1019 | 1020 | def test_that_items_are_moved_out_of_the_way_on_update_scope_with_before_id 1021 | @second_item.update list: @second_list, position: {before: @sixth_item.id} 1022 | reload_models 1023 | 1024 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 1025 | assert_equal [1, 2, 3, 4], [@fourth_item, @fifth_item, @second_item, @sixth_item].map(&:position) 1026 | end 1027 | 1028 | def test_that_items_are_moved_out_of_the_way_on_update_scope_with_before_nil 1029 | @second_item.update list: @second_list, position: {before: nil} 1030 | reload_models 1031 | 1032 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 1033 | assert_equal [1, 2, 3, 4], [@fourth_item, @fifth_item, @sixth_item, @second_item].map(&:position) 1034 | end 1035 | 1036 | def test_that_items_are_moved_out_of_the_way_on_update_scope_with_after 1037 | @second_item.update list: @second_list, position: {after: @fourth_item} 1038 | reload_models 1039 | 1040 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 1041 | assert_equal [1, 2, 3, 4], [@fourth_item, @second_item, @fifth_item, @sixth_item].map(&:position) 1042 | end 1043 | 1044 | def test_that_items_are_moved_out_of_the_way_on_update_scope_with_after_id 1045 | @second_item.update list: @second_list, position: {after: @fourth_item.id} 1046 | reload_models 1047 | 1048 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 1049 | assert_equal [1, 2, 3, 4], [@fourth_item, @second_item, @fifth_item, @sixth_item].map(&:position) 1050 | end 1051 | 1052 | def test_that_items_are_moved_out_of_the_way_on_update_scope_with_after_nil 1053 | @second_item.update list: @second_list, position: {after: nil} 1054 | reload_models 1055 | 1056 | assert_equal [1, 2], [@first_item, @third_item].map(&:position) 1057 | assert_equal [1, 2, 3, 4], [@second_item, @fourth_item, @fifth_item, @sixth_item].map(&:position) 1058 | end 1059 | 1060 | def test_that_an_item_must_belong_to_the_scope_of_before_on_create 1061 | assert_raises(Positioning::Error) do 1062 | @second_list.send(@association).create id: @id.next, name: "Seventh Item", position: {before: @second_item} 1063 | end 1064 | end 1065 | 1066 | def test_that_an_item_id_must_belong_to_the_scope_of_before_on_create 1067 | assert_raises(Positioning::Error) do 1068 | @second_list.send(@association).create id: @id.next, name: "Seventh Item", position: {before: @second_item.id} 1069 | end 1070 | end 1071 | 1072 | def test_that_an_item_must_belong_to_the_scope_of_after_on_create 1073 | assert_raises(Positioning::Error) do 1074 | @second_list.send(@association).create id: @id.next, name: "Seventh Item", position: {after: @first_item} 1075 | end 1076 | end 1077 | 1078 | def test_that_an_item_id_must_belong_to_the_scope_of_after_on_create 1079 | assert_raises(Positioning::Error) do 1080 | @second_list.send(@association).create id: @id.next, name: "Seventh Item", position: {after: @first_item.id} 1081 | end 1082 | end 1083 | 1084 | def test_that_an_item_must_belong_to_the_scope_of_before_on_update 1085 | assert_raises(Positioning::Error) do 1086 | @fifth_item.update position: {before: @second_item} 1087 | end 1088 | end 1089 | 1090 | def test_that_an_item_id_must_belong_to_the_scope_of_before_on_update 1091 | assert_raises(Positioning::Error) do 1092 | @fifth_item.update position: {before: @second_item.id} 1093 | end 1094 | end 1095 | 1096 | def test_that_an_item_must_belong_to_the_scope_of_after_on_update 1097 | assert_raises(Positioning::Error) do 1098 | @fifth_item.update position: {after: @first_item} 1099 | end 1100 | end 1101 | 1102 | def test_that_an_item_id_must_belong_to_the_scope_of_after_on_update 1103 | assert_raises(Positioning::Error) do 1104 | @fifth_item.update position: {after: @first_item.id} 1105 | end 1106 | end 1107 | 1108 | def test_that_an_error_is_raised_with_invalid_relative_key 1109 | assert_raises(Positioning::Error) do 1110 | @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: {wrong: @second_item.id} 1111 | end 1112 | 1113 | assert_raises(Positioning::Error) do 1114 | @first_item.update position: {wrong: @second_item.id} 1115 | end 1116 | end 1117 | 1118 | def test_that_an_error_is_raised_with_invalid_position 1119 | assert_raises(Positioning::Error) do 1120 | @first_list.send(@association).create id: @id.next, name: "Seventh Item", position: :other 1121 | end 1122 | 1123 | assert_raises(Positioning::Error) do 1124 | @first_item.update position: :other 1125 | end 1126 | end 1127 | 1128 | def test_destroying_multiple_items 1129 | @first_list.send(@association).limit(2).destroy_all 1130 | @third_item.reload 1131 | 1132 | assert_equal 1, @third_item.position 1133 | end 1134 | end 1135 | 1136 | class TestCompositePrimaryKeyPositioning < TestPositioning 1137 | def configure 1138 | skip if ActiveRecord.version < Gem::Version.new("7.1.0") 1139 | 1140 | @association = :composite_primary_key_items 1141 | @id = Enumerator.new do |yielder| 1142 | number = 1 1143 | 1144 | loop do 1145 | yielder.yield([number, number]) 1146 | number += 1 1147 | end 1148 | end 1149 | end 1150 | end 1151 | 1152 | class TestNoScopePositioning < Minitest::Test 1153 | include Minitest::Hooks 1154 | 1155 | def around 1156 | ActiveRecord::Base.transaction do 1157 | super 1158 | raise ActiveRecord::Rollback 1159 | end 1160 | end 1161 | 1162 | def setup 1163 | @first_product = Product.create name: "First Product" 1164 | @second_product = Product.create name: "Second Product" 1165 | @third_product = Product.create name: "Third Product" 1166 | 1167 | @models = [ 1168 | @first_product, @second_product, @third_product 1169 | ] 1170 | 1171 | reload_models 1172 | end 1173 | 1174 | def reload_models 1175 | @models.map(&:reload) 1176 | end 1177 | 1178 | def test_initial_positioning 1179 | assert_equal [1, 2, 3], [@first_product, @second_product, @third_product].map(&:position) 1180 | end 1181 | 1182 | def test_absolute_positioning_create 1183 | positions = [1, 2, 3] 1184 | 1185 | 4.times do |position| 1186 | model = Product.create name: "New Product", position: position 1187 | @models.insert position.clamp(1..3) - 1, model 1188 | positions.push positions.length + 1 1189 | 1190 | reload_models 1191 | assert_equal Product.all, @models 1192 | assert_equal positions, @models.map(&:position) 1193 | end 1194 | end 1195 | 1196 | def test_relative_positioning_create 1197 | positions = [1, 2, 3] 1198 | 1199 | [:before, :after].each do |relative_position| 1200 | [@first_product, @second_product, @third_product, nil].each do |relative_model| 1201 | model = Product.create name: "New Product", position: {"#{relative_position}": relative_model} 1202 | 1203 | if !relative_model 1204 | if relative_position == :before 1205 | @models.insert @models.length, model 1206 | elsif relative_position == :after 1207 | @models.insert 0, model 1208 | end 1209 | elsif model != relative_model 1210 | if relative_position == :before 1211 | @models.insert @models.index(relative_model), model 1212 | elsif relative_position == :after 1213 | @models.insert @models.index(relative_model) + 1, model 1214 | end 1215 | end 1216 | 1217 | positions.push positions.length + 1 1218 | 1219 | reload_models 1220 | assert_equal Product.all, @models 1221 | assert_equal positions, @models.map(&:position) 1222 | end 1223 | end 1224 | 1225 | [:first, :last, nil].each do |relative_position| 1226 | model = Product.create name: "New Product", position: relative_position 1227 | 1228 | case relative_position 1229 | when :first 1230 | @models.insert 0, model 1231 | when :last, nil 1232 | @models.insert @models.length, model 1233 | end 1234 | 1235 | positions.push positions.length + 1 1236 | 1237 | reload_models 1238 | assert_equal Product.all, @models 1239 | assert_equal positions, @models.map(&:position) 1240 | end 1241 | end 1242 | 1243 | def test_absolute_positioning_update 1244 | 4.times do |position| 1245 | [@first_product, @second_product, @third_product].each do |model| 1246 | model.update position: position 1247 | @models.delete_at @models.index(model) 1248 | @models.insert position.clamp(1..3) - 1, model 1249 | 1250 | reload_models 1251 | assert_equal Product.all, @models 1252 | assert_equal [1, 2, 3], @models.map(&:position) 1253 | end 1254 | end 1255 | end 1256 | 1257 | def test_relative_positioning_update 1258 | [:before, :after].each do |relative_position| 1259 | [@first_product, @second_product, @third_product].each do |model| 1260 | [@first_product, @second_product, @third_product, nil].each do |relative_model| 1261 | model.update position: {"#{relative_position}": relative_model} 1262 | 1263 | if !relative_model 1264 | @models.delete_at @models.index(model) 1265 | 1266 | if relative_position == :before 1267 | @models.insert @models.length, model 1268 | elsif relative_position == :after 1269 | @models.insert 0, model 1270 | end 1271 | elsif model != relative_model 1272 | @models.delete_at @models.index(model) 1273 | 1274 | if relative_position == :before 1275 | @models.insert @models.index(relative_model), model 1276 | elsif relative_position == :after 1277 | @models.insert @models.index(relative_model) + 1, model 1278 | end 1279 | end 1280 | 1281 | reload_models 1282 | assert_equal Product.all, @models 1283 | assert_equal [1, 2, 3], @models.map(&:position) 1284 | end 1285 | end 1286 | end 1287 | 1288 | [:first, :last, nil].each do |relative_position| 1289 | [@first_product, @second_product, @third_product].each do |model| 1290 | model.update position: relative_position 1291 | 1292 | @models.delete_at @models.index(model) 1293 | 1294 | case relative_position 1295 | when :first 1296 | @models.insert 0, model 1297 | when :last, nil 1298 | @models.insert @models.length, model 1299 | end 1300 | 1301 | reload_models 1302 | assert_equal Product.all, @models 1303 | assert_equal [1, 2, 3], @models.map(&:position) 1304 | end 1305 | end 1306 | end 1307 | 1308 | def test_destruction 1309 | positions = [1, 2, 3] 1310 | 1311 | [@second_product, @first_product, @third_product].each do |model| 1312 | index = @models.index(model) 1313 | model.destroy 1314 | 1315 | @models.delete_at index 1316 | positions.pop 1317 | 1318 | reload_models 1319 | assert_equal Product.all, @models 1320 | assert_equal positions, @models.map(&:position) 1321 | end 1322 | end 1323 | end 1324 | 1325 | class TestSTIPositioning < Minitest::Test 1326 | include Minitest::Hooks 1327 | 1328 | def around 1329 | ActiveRecord::Base.transaction do 1330 | super 1331 | raise ActiveRecord::Rollback 1332 | end 1333 | end 1334 | 1335 | def setup 1336 | @first_list = List.create name: "First List" 1337 | @second_list = List.create name: "Second List" 1338 | 1339 | @first_student = @first_list.authors.create name: "First Student", type: "Author::Student" 1340 | @first_teacher = @first_list.authors.create name: "First Teacher", type: "Author::Teacher" 1341 | @second_student = @first_list.authors.create name: "Second Student", type: "Author::Student" 1342 | @second_teacher = @first_list.authors.create name: "Second Teacher", type: "Author::Teacher" 1343 | @third_student = @first_list.authors.create name: "Third Student", type: "Author::Student" 1344 | @third_teacher = @first_list.authors.create name: "Third Teacher", type: "Author::Teacher" 1345 | @fourth_student = @second_list.authors.create name: "Fourth Student", type: "Author::Student" 1346 | @fourth_teacher = @second_list.authors.create name: "Fourth Teacher", type: "Author::Teacher" 1347 | @fifth_student = @second_list.authors.create name: "Fifth Student", type: "Author::Student" 1348 | @fifth_teacher = @second_list.authors.create name: "Fifth Teacher", type: "Author::Teacher" 1349 | @sixth_student = @second_list.authors.create name: "Sixth Student", type: "Author::Student" 1350 | @sixth_teacher = @second_list.authors.create name: "Sixth Teacher", type: "Author::Teacher" 1351 | 1352 | @first_list_models = [ 1353 | @first_student, @first_teacher, @second_student, 1354 | @second_teacher, @third_student, @third_teacher 1355 | ] 1356 | 1357 | @second_list_models = [ 1358 | @fourth_student, @fourth_teacher, @fifth_student, 1359 | @fifth_teacher, @sixth_student, @sixth_teacher 1360 | ] 1361 | 1362 | reload_models 1363 | end 1364 | 1365 | def reload_models 1366 | [@first_list, @second_list].map(&:reload) 1367 | @first_list_models.map(&:reload) 1368 | @second_list_models.map(&:reload) 1369 | end 1370 | 1371 | def test_initial_positioning 1372 | assert_equal [1, 2, 3, 4, 5, 6], [ 1373 | @first_student, @first_teacher, @second_student, 1374 | @second_teacher, @third_student, @third_teacher 1375 | ].map(&:position) 1376 | 1377 | assert_equal [1, 2, 3, 4, 5, 6], [ 1378 | @fourth_student, @fourth_teacher, @fifth_student, 1379 | @fifth_teacher, @sixth_student, @sixth_teacher 1380 | ].map(&:position) 1381 | end 1382 | 1383 | def test_absolute_positioning_create 1384 | types = ["Author::Student", "Author::Teacher"].cycle 1385 | 1386 | [[@first_list, @first_list_models], [@second_list, @second_list_models]].each do |(list, models)| 1387 | positions = [1, 2, 3, 4, 5, 6] 1388 | 1389 | 4.times do |position| 1390 | model = list.authors.create name: "New Author", position: position, type: types.next 1391 | models.insert position.clamp(1..3) - 1, model 1392 | positions.push positions.length + 1 1393 | 1394 | reload_models 1395 | assert_equal list.authors, models 1396 | assert_equal positions, models.map(&:position) 1397 | end 1398 | end 1399 | end 1400 | 1401 | def test_relative_positioning_create 1402 | types = ["Author::Student", "Author::Teacher"].cycle 1403 | 1404 | [[@first_list, @first_list_models], [@second_list, @second_list_models]].each do |(list, models)| 1405 | positions = [1, 2, 3, 4, 5, 6] 1406 | 1407 | [:before, :after].each do |relative_position| 1408 | [*models.dup, nil].each do |relative_model| 1409 | model = list.authors.create name: "New Author", position: {"#{relative_position}": relative_model}, 1410 | type: types.next 1411 | 1412 | if !relative_model 1413 | if relative_position == :before 1414 | models.insert models.length, model 1415 | elsif relative_position == :after 1416 | models.insert 0, model 1417 | end 1418 | elsif model != relative_model 1419 | if relative_position == :before 1420 | models.insert models.index(relative_model), model 1421 | elsif relative_position == :after 1422 | models.insert models.index(relative_model) + 1, model 1423 | end 1424 | end 1425 | 1426 | positions.push positions.length + 1 1427 | 1428 | reload_models 1429 | assert_equal list.authors, models 1430 | assert_equal positions, models.map(&:position) 1431 | end 1432 | end 1433 | 1434 | [:first, :last, nil].each do |relative_position| 1435 | model = list.authors.create name: "New Author", position: relative_position, type: types.next 1436 | 1437 | case relative_position 1438 | when :first 1439 | models.insert 0, model 1440 | when :last, nil 1441 | models.insert models.length, model 1442 | end 1443 | 1444 | positions.push positions.length + 1 1445 | 1446 | reload_models 1447 | assert_equal list.authors, models 1448 | assert_equal positions, models.map(&:position) 1449 | end 1450 | end 1451 | end 1452 | 1453 | def test_absolute_positioning_update 1454 | [[@first_list, @first_list_models, @second_list, @second_list_models], 1455 | [@second_list, @second_list_models, @first_list, @first_list_models]] 1456 | .each do |(list, models, other_list, other_models)| 1457 | models.dup.each do |model| 1458 | 8.times do |position| 1459 | model.update position: position 1460 | models.delete_at models.index(model) 1461 | models.insert position.clamp(1..6) - 1, model 1462 | 1463 | reload_models 1464 | assert_equal list.authors, models 1465 | assert_equal [1, 2, 3, 4, 5, 6], models.map(&:position) 1466 | end 1467 | end 1468 | end 1469 | end 1470 | 1471 | def test_absolute_positioning_update_scope 1472 | positions = [1, 2, 3, 4, 5, 6] 1473 | other_positions = [1, 2, 3, 4, 5, 6] 1474 | 1475 | [[@first_list, @first_list_models, @second_list, @second_list_models], 1476 | [@second_list, @second_list_models, @first_list, @first_list_models]] 1477 | .each do |(list, models, other_list, other_models)| 1478 | models.dup.each do |model| 1479 | 8.times do |position| 1480 | model.update position: position, list: other_list 1481 | 1482 | models.delete_at models.index(model) 1483 | other_models.insert position.clamp(1..6) - 1, model 1484 | positions.pop 1485 | other_positions.push other_positions.length + 1 1486 | 1487 | reload_models 1488 | assert_equal list.authors, models 1489 | assert_equal other_list.authors, other_models 1490 | assert_equal positions, models.map(&:position) 1491 | assert_equal other_positions, other_models.map(&:position) 1492 | 1493 | list, other_list = other_list, list 1494 | models, other_models = other_models, models 1495 | positions, other_positions = other_positions, positions 1496 | end 1497 | end 1498 | end 1499 | end 1500 | 1501 | def test_relative_positioning_update 1502 | [[@first_list, @first_list_models], [@second_list, @second_list_models]] 1503 | .each do |(list, models)| 1504 | models.dup.each do |model| 1505 | [:before, :after].each do |relative_position| 1506 | [*models.dup, nil].each do |relative_model| 1507 | model.update position: {"#{relative_position}": relative_model} 1508 | 1509 | if !relative_model 1510 | models.delete_at models.index(model) 1511 | 1512 | if relative_position == :before 1513 | models.insert models.length, model 1514 | elsif relative_position == :after 1515 | models.insert 0, model 1516 | end 1517 | elsif model != relative_model 1518 | models.delete_at models.index(model) 1519 | 1520 | if relative_position == :before 1521 | models.insert models.index(relative_model), model 1522 | elsif relative_position == :after 1523 | models.insert models.index(relative_model) + 1, model 1524 | end 1525 | end 1526 | 1527 | reload_models 1528 | assert_equal list.authors, models 1529 | assert_equal [1, 2, 3, 4, 5, 6], models.map(&:position) 1530 | end 1531 | end 1532 | 1533 | [:first, :last, nil].each do |relative_position| 1534 | model.update position: relative_position 1535 | models.delete_at models.index(model) 1536 | 1537 | case relative_position 1538 | when :first 1539 | models.insert 0, model 1540 | when :last, nil 1541 | models.insert models.length, model 1542 | end 1543 | 1544 | reload_models 1545 | assert_equal list.authors, models 1546 | assert_equal [1, 2, 3, 4, 5, 6], models.map(&:position) 1547 | end 1548 | end 1549 | end 1550 | end 1551 | 1552 | def test_relative_positioning_update_scope 1553 | positions = [1, 2, 3, 4, 5, 6] 1554 | other_positions = [1, 2, 3, 4, 5, 6] 1555 | 1556 | [[@first_list, @first_list_models, @second_list, @second_list_models], 1557 | [@second_list, @second_list_models, @first_list, @first_list_models]] 1558 | .each do |(list, models, other_list, other_models)| 1559 | models.dup.each do |model| 1560 | [:before, :after].each do |relative_position| 1561 | other_models.dup.zip(models.dup).flatten.each do |relative_model| 1562 | model.update position: {"#{relative_position}": relative_model}, list: relative_model.list 1563 | list_changed = model.list_id_previously_changed? 1564 | 1565 | if model != relative_model 1566 | models.delete_at models.index(model) 1567 | 1568 | if list_changed 1569 | if relative_position == :before 1570 | other_models.insert other_models.index(relative_model), model 1571 | elsif relative_position == :after 1572 | other_models.insert other_models.index(relative_model) + 1, model 1573 | end 1574 | 1575 | positions.pop 1576 | other_positions.push other_positions.length + 1 1577 | elsif relative_position == :before 1578 | models.insert models.index(relative_model), model 1579 | elsif relative_position == :after 1580 | models.insert models.index(relative_model) + 1, model 1581 | end 1582 | end 1583 | 1584 | reload_models 1585 | assert_equal list.authors, models 1586 | assert_equal other_list.authors, other_models 1587 | assert_equal positions, models.map(&:position) 1588 | assert_equal other_positions, other_models.map(&:position) 1589 | 1590 | if list_changed 1591 | list, other_list = other_list, list 1592 | models, other_models = other_models, models 1593 | positions, other_positions = other_positions, positions 1594 | end 1595 | end 1596 | end 1597 | end 1598 | end 1599 | end 1600 | 1601 | def test_relative_positioning_update_scope_relative_nil 1602 | positions = [1, 2, 3, 4, 5, 6] 1603 | other_positions = [1, 2, 3, 4, 5, 6] 1604 | 1605 | [[@first_list, @first_list_models, @second_list, @second_list_models], 1606 | [@second_list, @second_list_models, @first_list, @first_list_models]] 1607 | .each do |(list, models, other_list, other_models)| 1608 | models.dup.each do |model| 1609 | [:before, :after].each do |relative_position| 1610 | [other_list, list].each do |relative_list| 1611 | model.update position: {"#{relative_position}": nil}, list: relative_list 1612 | 1613 | models.delete_at models.index(model) 1614 | 1615 | if relative_position == :before 1616 | other_models.insert other_models.length, model 1617 | elsif relative_position == :after 1618 | other_models.insert 0, model 1619 | end 1620 | 1621 | positions.pop 1622 | other_positions.push other_positions.length + 1 1623 | 1624 | reload_models 1625 | assert_equal list.authors, models 1626 | assert_equal other_list.authors, other_models 1627 | assert_equal positions, models.map(&:position) 1628 | assert_equal other_positions, other_models.map(&:position) 1629 | 1630 | list, other_list = other_list, list 1631 | models, other_models = other_models, models 1632 | positions, other_positions = other_positions, positions 1633 | end 1634 | end 1635 | 1636 | [:first, :last, nil].each do |relative_position| 1637 | [other_list, list].each do |relative_list| 1638 | model.update position: relative_position, list: relative_list 1639 | 1640 | models.delete_at models.index(model) 1641 | 1642 | case relative_position 1643 | when :first 1644 | other_models.insert 0, model 1645 | when :last, nil 1646 | other_models.insert other_models.length, model 1647 | end 1648 | 1649 | positions.pop 1650 | other_positions.push other_positions.length + 1 1651 | 1652 | reload_models 1653 | assert_equal list.authors, models 1654 | assert_equal other_list.authors, other_models 1655 | assert_equal positions, models.map(&:position) 1656 | assert_equal other_positions, other_models.map(&:position) 1657 | 1658 | list, other_list = other_list, list 1659 | models, other_models = other_models, models 1660 | positions, other_positions = other_positions, positions 1661 | end 1662 | end 1663 | end 1664 | end 1665 | end 1666 | 1667 | def test_destruction 1668 | second_list_models_for_iteration = @second_list_models.slice(0, 3) 1669 | .zip(@second_list_models.slice(3, 3)).flatten 1670 | 1671 | [[@first_list, @first_list_models, @first_list_models.dup], 1672 | [@second_list, @second_list_models, second_list_models_for_iteration]] 1673 | .each do |(list, models, models_for_iteration)| 1674 | positions = [1, 2, 3, 4, 5, 6] 1675 | 1676 | models_for_iteration.each do |model| 1677 | index = models.index(model) 1678 | model.destroy 1679 | 1680 | models.delete_at index 1681 | positions.pop 1682 | 1683 | reload_models 1684 | assert_equal list.authors, models 1685 | assert_equal positions, models.map(&:position) 1686 | end 1687 | end 1688 | end 1689 | end 1690 | --------------------------------------------------------------------------------