{"id":15653695,"url":"https://github.com/richardboehme/lexorank","last_synced_at":"2025-03-15T17:04:08.424Z","repository":{"id":43182225,"uuid":"304909266","full_name":"richardboehme/lexorank","owner":"richardboehme","description":"Storing user-defined order of your models by utilizing lexicographical sorting","archived":false,"fork":false,"pushed_at":"2024-09-05T20:02:45.000Z","size":86,"stargazers_count":30,"open_issues_count":1,"forks_count":4,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-02-28T04:46:22.247Z","etag":null,"topics":["activerecord","gem","lexorank","order","rails","ruby"],"latest_commit_sha":null,"homepage":"https://lexorank.richardboeh.me","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/richardboehme.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2020-10-17T15:32:14.000Z","updated_at":"2025-01-10T04:03:33.000Z","dependencies_parsed_at":"2024-10-23T03:36:52.892Z","dependency_job_id":null,"html_url":"https://github.com/richardboehme/lexorank","commit_stats":{"total_commits":38,"total_committers":4,"mean_commits":9.5,"dds":"0.21052631578947367","last_synced_commit":"50be76104c126f1be5d7c2d1763a0d01be194ab6"},"previous_names":[],"tags_count":7,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richardboehme%2Flexorank","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richardboehme%2Flexorank/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richardboehme%2Flexorank/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richardboehme%2Flexorank/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/richardboehme","download_url":"https://codeload.github.com/richardboehme/lexorank/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":243762264,"owners_count":20343979,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activerecord","gem","lexorank","order","rails","ruby"],"created_at":"2024-10-03T12:46:35.187Z","updated_at":"2025-03-15T17:04:08.387Z","avatar_url":"https://github.com/richardboehme.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# lexorank\n\nEasily store user-defined order of your ActiveRecord models utilizing lexicographical sorting. A live demo is available [here](https://lexorank.richardboeh.me).\n\nInspired by [Atlassian's Lexorank](https://confluence.atlassian.com/jirakb/understand-the-lexorank-managment-page-in-jira-server-779159218.html).\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'lexorank'\ngem 'with_advisory_lock' # recommended to get locking out of the box\n```\n\nAnd then execute:\n\n    $ bundle install\n\nYour model will need a database column storing the rank. The default ranking column is called `rank`, however you are free to change that (see [rank!](#rank)).\n\nOne way to add this in a rails application is to generate a simple migration:\n\n    $ rails g migration AddRankTo\u003cinsert model name here\u003e rank:text:uniq\n\n\u003cdetails\u003e\n\u003csummary\u003eThis should generate a migration like that:\u003c/summary\u003e\n\n```ruby\nclass AddRankToPages \u003c ActiveRecord::Migration[7.0]\n  def change\n    add_column :pages, :rank, :text\n    add_index :pages, :rank, unique: true\n  end\nend\n```\n\u003c/details\u003e\n\n**Important:** After the migration was created, take a look at the following paragraphs highlighting differences between the different database adapters:\n* [MySQL](#mysql)\n* [PostgreSQL](#postgresql)\n\nAfter applying the specific options just run the migration using:\n\n    $ rails db:migrate\n\n### MySQL\n\nIt's important to choose a [binary collation](https://dev.mysql.com/doc/refman/8.0/en/charset-binary-collations.html) for the database column.\nThe simplest one to use, which we recommend and test against, is the `ascii_bin` collation.\n\n\u003cdetails\u003e\n\u003csummary\u003eYou can specify it like this:\u003c/summary\u003e\n\n```ruby\nclass AddRankToPages \u003c ActiveRecord::Migration[7.0]\n  def change\n    add_column :pages, :rank, :text, collation: 'ascii_bin'\n    add_index :pages, :rank, unique: true\n  end\nend\n```\n\u003c/details\u003e\n\n### PostgreSQL\n\nIt's important to use the `C` collation which supports ordering in the same way ruby does for strings.\nYou can specify it like this:\n\n\u003cdetails\u003e\n\u003csummary\u003eYou can specify it like this:\u003c/summary\u003e\n\n```ruby\nclass AddRankToPages \u003c ActiveRecord::Migration[7.0]\n  def change\n    add_column :pages, :rank, :text, collation: 'C'\n    add_index :pages, :rank, unique: true\n  end\nend\n```\n\u003c/details\u003e\n\n### SQLite\n\nThere are no additional steps needed if you use SQLite.\n\n## Basic Usage\n\nIn your model require `lexorank/rankable`. Afterwards, you will be able to use the `rank!` method like this:\n\n```ruby\nrequire 'lexorank/rankable'\nclass Post \u003c ActiveRecord::Base\n  rank!\nend\n```\n\nNow you have access to the following methods:\n\n```ruby\n# Return all pages in the supplied order\nPage.ranked\n\npage = Page.first\n\n# Moves this page instance to the second position\npage.move_to(1)\n\n# Alias to page.move_to(0)\npage.move_to_top\n```\n\nKeep in mind that a newly created record will not have a rank by default. Just manually move it to the position you want it to be.\nAlternatively you can setup a `before_create` [callback](https://guides.rubyonrails.org/active_record_callbacks.html) like this:\n\n\u003cdetails\u003e\n\u003csummary\u003eExpand\u003c/summary\u003e\n\n```ruby\nrequire 'lexorank/rankable'\nclass Page \u003c ActiveRecord::Base\n  rank!\n\n  before_create do\n    self.move_to_top\n  end\nend\n```\n\u003c/details\u003e\n\n## Class methods\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ca id=\"rank\"\u003e\u003c/a\u003e\u003ccode\u003erank!(field: :rank, group_by: nil, advisory_lock: {})\u003c/code\u003e\u003c/summary\u003e\n\nThis is the entry point to use lexorank in your model.\n\nOptions:\n* `field`: Allows you to pass a custom field which is being used to store the models rank. (defaults to `:rank`)\n* `group_by`: Makes it possible to split model ordering into groups by a specific column. [Learn more](#associations-and-grouping)\n* `advisory_lock`: The advisory lock configuration. [Learn more](#locking)\n\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003eranked(direction: :asc)\u003c/code\u003e\u003c/summary\u003e\n\nThis is a model [scope](https://guides.rubyonrails.org/active_record_querying.html#scopes) which will return the ordered collection.\nThis will only be available if your model calls `rank!` before. The scope will exclude all models that have no rank set.\n\nOptions:\n* `direction`: Allows you to pass the orders direction. See `ActiveRecord::QueryMethods::VALID_DIRECTIONS` for possible values. (defaults to `:asc`)\n\u003c/details\u003e\n\n## Instance methods\n\nThose will only be available if your model calls `rank!` before.\n\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ca id=\"move_to\"\u003e\u003c/a\u003e\u003ccode\u003emove_to(position, **options, \u0026block)\u003c/code\u003e\u003c/summary\u003e\n\nThis method will set your object's rank column according to the new position. Position counts start at zero.\nThis will not persist the rank to the database.\n\nThe options passed can be used to configure the ranking operation. Currently it is only possible to pass options related to [Locking](#locking). When passing a configuration hash under the `:advisory_lock` key one can pass additional options to `::with_advisory_lock`.\n\nThe passed block will be executed after the new rank was assigned.\n\nWhen using [Locking](#locking) it is **discouraged** to use `move_to` without passing a block. The block will be executed inside of the advisory lock and should persist the change to the rank to ensure that no positioning conflicts will occur.\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003emove_to_top(**options, \u0026block)\u003c/code\u003e\u003c/summary\u003e\n\nAlias to [`move_to(0, ...)`](#move_to)\n\u003c/details\u003e\n\u003csummary\u003e\u003ccode\u003emove_to_end(**options, \u0026block)\u003c/code\u003e\u003c/summary\u003e\n\nLike [`move_to`](#move_to) but moves the element to the end of the collection.\n\u003c/details\u003e\n\n\u003cbr /\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ca id=\"move_to!\"\u003e\u003c/a\u003e\u003ccode\u003emove_to!(position, **options)\u003c/code\u003e\u003c/summary\u003e\n\nLike [`move_to`](#move_to). However, this methods persists the rank to the database directly.\nIf an update is needed, the method will return the result of `save`, otherwise `true`.\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003emove_to_top!(**options)\u003c/code\u003e\u003c/summary\u003e\n\nLike [`move_to!`](#move_to!) but moves the element to the top of the collection.\n\u003c/details\u003e\n\u003csummary\u003e\u003ccode\u003emove_to_end!(**options)\u003c/code\u003e\u003c/summary\u003e\n\nLike [`move_to!`](#move_to!) but moves the element to the end of the collection.\n\u003c/details\u003e\n\n\u003cbr /\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003eno_rank?\u003c/code\u003e\u003c/summary\u003e\n\nReturns true if the ranking field is `nil`. This means that the model has no rank yet.\n\u003c/details\u003e\n\n## Associations and Grouping\n\nOften times you come in a situation where you don't want to order all items in one collection. Lexorank will take care of this by grouping your models by a specified column. This is especially interesting when your model is associated with another one.\n\nConsider the following example:\n\n```ruby\n# page.rb\nclass Page \u003c ActiveRecord::Base\n  has_many :paragraphs\nend\n\n# paragraph.rb\nclass Paragraph \u003c ActiveRecord::Base\n  belongs_to :page\nend\n```\n\nWhen adding lexorank to your paragraph model, ordering would not be page dependent. Instead the whole collection will be considered when moving or retrieving the models via lexorank.\n\nThe solution is the `group_by` option of the [`rank!`](#rank) method, which will allow a column or an association name.\nChange your paragraph model like this:\n\n```ruby\nrequire 'lexorank/rankable'\nclass Paragraph \u003c ActiveRecord::Base\n  belongs_to :page\n  rank!(group_by: :page)\nend\n```\n\nWhen moving a paragraph via the [instance methods](#instance-methods) only paragraphs of the model's page will be considered.\nPlease keep in mind that lexorank can only find the association when you put `rank!` after the association definition.\n\nAlternatively, you can supply the column directly:\n\n```ruby\nrequire 'lexorank/rankable'\nclass Paragraph \u003c ActiveRecord::Base\n  rank!(group_by: :page_id)\n  belongs_to :page\nend\n```\n\nThis means that grouping is completely independent of associations and can also be used without them.\n\nRetrieving data in a grouped manner is as simple as utilizing built-in ActiveRecord behavior of scopes:\n\n```ruby\n# This will return all paragraphs of the first page in the supplied order.\nPage.first.paragraphs.ranked\n```\n\n## Locking\n\nSince version 0.2.0 lexorank ships with advisory locking by default. Advisory locks are a locking mechanism on the database level that ensures that only one record in a collection can change their rank at a time. This is important to prevent two records being assigned the same rank.\n\nAdvisory locking is enabled by default if the model class responds to the `::with_advisory_lock` method. The easiest way to achieve this is by installing the incredible [`with_advisory_lock` gem](https://github.com/ClosureTree/with_advisory_lock).\n\nIt is also possible to implement advisory locking yourself. The `with_adivsory_lock` method must accept one name argument and arbitrary keyword arguments similar to the signature of the [`with_advisory_lock` gem](https://github.com/ClosureTree/with_advisory_lock).\n\nWith advisory locking enabled it is actively **dicouraged** to call `move_to` or `move_to_top` without a block. This is because those methods do not persist to the database and thus cannot acquire a lock. Make sure the bang equivalents or pass a block in which the record is persisted.\n\n### Opting out of locking\n\nIf you manage locking yourself or you do not need locking, you can disable advisory locks:\n\n```ruby\nclass Page \u003c ActiveRecord::Base\n  rank!(advisory_lock: { enabled: false })\nend\n```\n\nNote that locking will be disabled by default if the model class does not respond to the `with_advisory_lock` method.\n\n### Configuring locking\n\nThe lexorank gem will choose an appropriate lock name by taking the class name, the ranking column and grouping into account. It's still possible to supply a `lock_name` callable that returns a custom name.\n\n```ruby\nclass Page \u003c ActiveRecord::Base\n  rank!(advisory_lock: { lock_name: -\u003e(page) { \"custom_lock_for_page_#{page.id}\" } })\nend\n```\n\nAlso it's possible to pass other options (e.g. `timeout_seconds` when using the [`with_advisory_lock` gem](https://github.com/ClosureTree/with_advisory_lock)). All options are passed to the `with_advisory_lock` method as keyword arguments.\n\n```ruby\nclass Page \u003c ActiveRecord::Base\n  rank!(advisory_lock: { timeout_seconds: 3 })\nend\n```\n\nIf an option needs to be applied for a single ranking operation only, you can directly pass additional options to all `move_to` methods using the `:advisory_lock` key. Those will overwrite all options set using `rank!`.\n\n```ruby\nclass Page \u003c ActiveRecord::Base\n  rank!(advisory_lock: { timeout_seconds: 3 })\nend\n\n# We might need a higher timeout here so we overwrite the initial configuration.\nPage.new.move_to_top!(advisory_lock: { timeout_seconds: 30 })\n```\n\n## Internals - How does lexorank work?\n\nThe gem works quite simple. When calling `move_to` the gem will identify the item which is on the wanted position and the one before.\nAfterwards, a simple function searches for a rank which is between the ranks of the two items.\n\nFor example: We want to move a currently unranked item to position 1 which means it should be the second element when calling `ranked` (because positions start at 0).\nThe gem will look for the element on position 0 and on position 1. When we position our item between those two we'll achieve the wanted position.\nLet's say the items have the ranks 'A' and 'C'. The gem will give our item the position 'B' as it is between the other two.\n\n![rank-between](docs/images/example_rank_between.svg)\n\nLet's say we want to find a rank between 'A' and 'B'. There is no character in between, which means the gem chooses the new rank value 'AU'. It chooses 'U' because it's the middle between 'z' (highest possible character) and '0' (lowest possible character).\n\n![rank-increment-length](docs/images/example_increment_rank_length.svg)\n\nThis also means, that given a huge amount of items and frequent moves to similar positions can result to long rank values which will make ordering slower (see [Performance](#performance)).\nThe solution to this problem is rebalancing all rank values, which currently isn't implemented in this gem.\n\n## Performance\n\n**Disclaimer:** *I'm kinda new to benchmarking. Feel free to give tips or advice on the current [implementations](benchmarks).*\n\nAll tests were run with the following setup: ActiveRecord with SQLite on WSL2 running ruby 3.0.0\n\nBecause of possible unbalanced ranks, receiving data from the database can slow down. To demonstrate this there is a [benchmark](benchmarks/scope_benchmark.rb) which will compare receiving data from a balanced set of x items against an unbalanced set of x items.\n\n\u003cdetails\u003e\n\u003csummary\u003eResults with 100,000 items\u003c/summary\u003e\n\n```\nRehearsal ----------------------------------------------------\nUnbalanced:        1.009327   0.190001   1.199328 (  1.199330)\nBalanced:          0.605503   0.039992   0.645495 (  0.645499)\n------------------------------------------- total: 1.845495sec\n\n                       user     system      total        real\nUnbalanced:        0.872151   0.019991   0.892142 (  0.892137)\nBalanced:          0.617773   0.000000   0.617773 (  0.617767)\n```\n\u003c/details\u003e\n\u003cbr /\u003e\n\nAnother [benchmark](benchmarks/move_to_benchmark.rb) checks how the internal algorithm which calculates new ranks performs. This method is still subject to optimization but one can see here that finding a rank between two close ranks takes significantly more time than finding a rank between two more different ranks.\n\n\u003cdetails\u003e\n\u003csummary\u003eResults (rank length of 100,000 letters):\u003c/summary\u003e\n\n```\nRehearsal ----------------------------------------------------------------------------\nvalue between two close ranks:             0.872685   0.100091   0.972776 (  0.992852)\nvalue between two more different ranks:    0.000059   0.000006   0.000065 (  0.000064)\n------------------------------------------------------------------- total: 0.972841sec\n\n                                               user     system      total        real\nvalue between two close ranks:             0.818498   0.100112   0.918610 (  0.928660)\nvalue between two more different ranks:    0.000042   0.000000   0.000042 (  0.000035)\n```\n\u003c/details\u003e\n\n\n## Planned Features\n\n- [ ] task to rebalance ranks\n- [ ] method to output information about current balancing situation (number of ranked items, longest rank value, ...)\n- [ ] ...\n\n## Development\n\n\u003cdetails\u003e\n\u003csummary\u003eContributing\u003c/summary\u003e\n\nBug reports and pull requests are highly welcomed and appreciated. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).\n\n1. Fork the repository\n2. Create your feature branch by branching off of **main** (`git checkout -b my-new-feature`)\n3. Make your changes\n4. Make sure all tests run successfully (`bundle exec rake test`)\n5. Commit your changes (`git commit -am 'Add some feature'`)\n6. Push to the branch (`git push origin my-new-feature`)\n7. Create a new pull request\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003eDuring development\u003c/summary\u003e\n\n* Install dependencies using `bundle install`\n* Run all tests using `bundle exec rake test`\n* Run a specific test using `m path_to_file:line`\n* Run tests using a specific database adapter `DB=[sqlite,mysql,postgresql] bundle exec rake test`\n\nSetting up the different database adapter environments *should* be as simple as copying `docker-compose.yml.example` to `docker-compose.yml` and `test/database.yml.example` to `test/database.yml` and running `docker-compose up -d`.\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003eRelease a new version\u003c/summary\u003e\n\n1. Update gem version in ``Lexorank::Version``\n2. Add changelog entries\n3. Push changes to github\n4. Create a release on github and create a tag for the version (v0.1.0 for example).\n5. Build gem and push to rubygems.org\n\u003c/details\u003e\n\n## License\n\nLexorank is released under the [MIT License](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frichardboehme%2Flexorank","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frichardboehme%2Flexorank","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frichardboehme%2Flexorank/lists"}