diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml
index 9168072..24c752a 100644
--- a/.github/workflows/ruby.yml
+++ b/.github/workflows/ruby.yml
@@ -13,7 +13,7 @@ jobs:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
- ruby: [ 2.6, 2.7, '3.0', '3.1', head, truffleruby, jruby ]
+ ruby: [ '3.1', '3.2', '3.3', head, truffleruby, jruby ]
db_adapter: [ sqlite, mysql, postgresql ]
runs-on: ${{ matrix.os }}
steps:
@@ -21,7 +21,7 @@ jobs:
- run: mv test/database.yml.example test/database.yml
- run: mv docker-compose.yml.example docker-compose.yml
if: ${{ matrix.db_adapter == 'mysql' || matrix.db_adapter == 'postgresql' }}
- - run: docker-compose up -d ${{ matrix.db_adapter }}
+ - run: docker compose up -d ${{ matrix.db_adapter }}
if: ${{ matrix.db_adapter == 'mysql' || matrix.db_adapter == 'postgresql' }}
- uses: ruby/setup-ruby@v1
with:
diff --git a/.gitignore b/.gitignore
index 74d0d4d..1580afe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,4 +12,5 @@
Gemfile.lock
docker-compose.yml
/test/database.yml
-/data/
+/log/*
+!/log/.keep
diff --git a/.rubocop.yml b/.rubocop.yml
index cc9677f..b6f920d 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,7 +1,7 @@
inherit_gem:
ruboconf: ruboconf.yml
AllCops:
- TargetRubyVersion: 2.6
+ TargetRubyVersion: 3.1
inherit_mode:
merge:
- Exclude
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..ff4e3ca
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,60 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project tries to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Major Changes
+
+This version introduces **advisory locks**. Advisory locking is **automatically enabled** if your model class responds to `#with_advisory_lock` (ex. `User.with_advisory_lock`).
+
+From now on the lexorank gem requires ruby version 3.1 or higher. This decision is based on ruby's end of life dates (3.0 went eol in April 2024).
+
+All internal API methods that lexorank was using until 0.1.3 were moved to another location. If you rely on those (and you should not), have a look at the `Lexorank::Ranking` class. An instance of this class can be accessed via the `lexorank_ranking` attribute on your model class.
+
+### Added
+
+- Add advisory locks if the model class responds to `with_advisory_lock`
+- Add `#move_to_end` and `#move_to_end!` to move a record to the end of a collection
+- The CI now runs against multiple database adapters (sqlite, mysql, postgresql)
+
+### Changed
+
+- Blocks passed to all `move_to` methods will now be executed after the rank was assigned. When using advisory locks, the block will be executed while the lock is still active.
+- When calling `#move_to` with a position that is larger than the number of records in the collection it will now be moved to the end of the list
+- Require ruby version 3.1 or higher
+- Moved Changelog from [README.md](https://github.com/richardboehme/lexorank/blob/main/README.md) to [CHANGELOG.md](https://github.com/richardboehme/lexorank/blob/main/CHANGELOG.md)
+
+## [0.1.3] - 2021-07-16
+
+### Added
+
+- Add support to move elements into another group ([#5](https://github.com/richardboehme/lexorank/pull/5), by [@bookis](https://github.com/bookis))
+- Add the `no_rank?` method ([#5](https://github.com/richardboehme/lexorank/pull/5), by [@bookis](https://github.com/bookis))
+
+### Fixed
+
+- Removed in-memory operations while trying to find records around the model that should be moved
+
+## [0.1.2] - 2021-03-08
+
+### Fixed
+
+- Fixed gemspec to be valid
+
+### Changed
+
+- Updated Changelog format
+
+## [0.1.1] - 2021-03-08
+
+### Changed
+
+- Updated license year
+
+## [0.1.0] - 2021-03-08
+
+*Initial Release*
diff --git a/Gemfile b/Gemfile
index 0998b19..5d41d10 100644
--- a/Gemfile
+++ b/Gemfile
@@ -14,7 +14,7 @@ if defined?(JRUBY_VERSION)
else
gem 'mysql2'
gem 'pg'
- gem 'sqlite3', '~> 1.4'
+ gem 'sqlite3'
end
gem 'm'
gem 'minitest'
diff --git a/LICENSE b/LICENSE
index 6ec6ac7..0818785 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2021 Richard Böhme
+Copyright (c) 2024 Richard Böhme
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 2ecf5fa..2532abc 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Add this line to your application's Gemfile:
```ruby
gem 'lexorank'
+gem 'with_advisory_lock' # recommended to get locking out of the box
```
And then execute:
@@ -129,15 +130,15 @@ end
## Class methods
-
-rank!(field: :rank, group_by: nil)
+rank!(field: :rank, group_by: nil, advisory_lock: {})
This is the entry point to use lexorank in your model.
Options:
* `field`: Allows you to pass a custom field which is being used to store the models rank. (defaults to `:rank`)
* `group_by`: Makes it possible to split model ordering into groups by a specific column. [Learn more](#associations-and-grouping)
+* `advisory_lock`: The advisory lock configuration. [Learn more](#locking)
@@ -156,10 +157,14 @@ Those will only be available if your model calls `rank!` before.
-move_to(position)
+move_to(position, &block)
This method will set your object's rank column according to the new position. Position counts start at zero.
This will not persist the rank to the database.
+
+The passed block will be executed after the new rank was assigned.
+
+When 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.
move_to_top
@@ -242,6 +247,46 @@ Retrieving data in a grouped manner is as simple as utilizing built-in ActiveRec
Page.first.paragraphs.ranked
```
+## Locking
+
+Since 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.
+
+Advisory 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).
+
+It 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).
+
+With 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.
+
+### Opting out of locking
+
+If you manage locking yourself or you do not need locking, you can disable advisory locks:
+
+```ruby
+class Page < ActiveRecord::Base
+ rank!(advisory_lock: { enabled: false })
+end
+```
+
+Note that locking will be disabled by default if the model class does not respond to the `with_advisory_lock` method.
+
+### Configuring locking
+
+The 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.
+
+```ruby
+class Page < ActiveRecord::Base
+ rank!(advisory_lock: { lock_name: ->(page) { "custom_lock_for_page_#{page.id}" } })
+end
+```
+
+Also 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.
+
+```ruby
+class Page < ActiveRecord::Base
+ rank!(advisory_lock: { timeout_seconds: 3 })
+end
+```
+
## Internals - How does lexorank work?
The gem works quite simple. When calling `move_to` the gem will identify the item which is on the wanted position and the one before.
@@ -346,39 +391,6 @@ Setting up the different database adapter environments *should* be as simple as
5. Build gem and push to rubygems.org
-## Changelog
-
-
-0.1.3
-
-* add support to move element into another group ([#5](https://github.com/richardboehme/lexorank/pull/5), by [@bookis](https://github.com/bookis))
-* add the `no_rank?` method ([#5](https://github.com/richardboehme/lexorank/pull/5), by [@bookis](https://github.com/bookis))
-* remove in memory operations with the collection when calling `move_to`
-
-
-
-0.1.2
-
-* fix gem specification
-* update changelog format
-
-
-
-0.1.1
-
-* update license year
-* let rubygems be happy to have an updated version
-
-
-
-
-0.1.0
-
-*Initial Release*
-
-
## License
-Copyright (c) 2021-2022 Richard Böhme (richard.boehme1999@gmail.com)
-
Lexorank is released under the [MIT License](https://opensource.org/licenses/MIT).
diff --git a/docker-compose.yml.example b/docker-compose.yml.example
index 8ca0033..cb5470b 100644
--- a/docker-compose.yml.example
+++ b/docker-compose.yml.example
@@ -1,5 +1,3 @@
-version: "3.8"
-
services:
postgresql:
image: postgres:latest
@@ -10,7 +8,7 @@ services:
ports:
- "5432:5432"
volumes:
- - ./data/postgres:/var/lib/postgresql/data
+ - postgres_data:/var/lib/postgresql/data
mysql:
image: mariadb
@@ -21,4 +19,8 @@ services:
ports:
- "3306:3306"
volumes:
- - ./data/mysql:/var/lib/mysql
+ - mysql_data:/var/lib/mysql
+
+volumes:
+ postgres_data:
+ mysql_data:
diff --git a/lexorank.gemspec b/lexorank.gemspec
index 357809d..9a3e36e 100644
--- a/lexorank.gemspec
+++ b/lexorank.gemspec
@@ -8,12 +8,16 @@ Gem::Specification.new do |spec|
spec.version = Lexorank::VERSION
spec.authors = ['Richard Böhme']
spec.email = ['richard.boehme1999@gmail.com']
- spec.metadata['rubygems_mfa_required'] = 'true'
spec.summary = 'Store order of your models by using lexicographic sorting.'
spec.homepage = 'https://github.com/richardboehme/lexorank'
spec.license = 'MIT'
- spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
+ spec.required_ruby_version = Gem::Requirement.new('>= 3.1.0')
+
+ spec.metadata['rubygems_mfa_required'] = 'true'
+ spec.metadata['homepage_uri'] = spec.homepage
+ spec.metadata['source_code_uri'] = spec.homepage
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
spec.files = Dir['LICENSE', 'lib/**/*']
diff --git a/lib/lexorank.rb b/lib/lexorank.rb
index 070bac1..dd8e47e 100644
--- a/lib/lexorank.rb
+++ b/lib/lexorank.rb
@@ -26,6 +26,7 @@
# SOFTWARE.
module Lexorank
class InvalidRankError < StandardError; end
+ class InvalidConfigError < StandardError; end
MIN_CHAR = '0'
MAX_CHAR = 'z'
diff --git a/lib/lexorank/rankable.rb b/lib/lexorank/rankable.rb
index 4ba6c86..5c1e49b 100644
--- a/lib/lexorank/rankable.rb
+++ b/lib/lexorank/rankable.rb
@@ -1,91 +1,59 @@
# frozen_string_literal: true
require 'lexorank'
+require 'lexorank/ranking'
require 'active_support/concern'
module Lexorank::Rankable
extend ActiveSupport::Concern
module ClassMethods
- attr_reader :ranking_column, :ranking_group_by
+ attr_reader :lexorank_ranking
- def rank!(field: :rank, group_by: nil)
- @ranking_column = check_column(field)
- if group_by
- @ranking_group_by = check_column(group_by)
- unless @ranking_group_by
- warn "The supplied grouping by \"#{group_by}\" is neither a column nor an association of the model!"
- end
- end
+ def rank!(field: :rank, group_by: nil, advisory_lock: {})
+ @lexorank_ranking = Lexorank::Ranking.new(record_class: self, field: field, group_by: group_by, advisory_lock: advisory_lock)
+ lexorank_ranking.validate!
- if @ranking_column
- scope :ranked, ->(direction: :asc) { where.not("#{field}": nil).order("#{field}": direction) }
- include Lexorank
+ if lexorank_ranking.field
+ scope :ranked, ->(direction: :asc) { where.not("#{lexorank_ranking.field}": nil).order("#{lexorank_ranking.field}": direction) }
include InstanceMethods
- else
- warn "The supplied ranking column \"#{field}\" is not a column of the model!"
- end
- end
-
- private
-
- def check_column(column_name)
- return unless column_name
-
- # This requires an active connection... do we want this?
- if columns.map(&:name).include?(column_name.to_s)
- column_name
- # This requires rank! to be after the specific association
- elsif (association = reflect_on_association(column_name))
- association.foreign_key.to_sym
end
end
end
module InstanceMethods
- def move_to_top
- move_to(0)
+ def move_to_top(&)
+ move_to(0, &)
end
- def move_to(position)
- collection = self.class.ranked
- if self.class.ranking_group_by.present?
- collection = collection.where("#{self.class.ranking_group_by}": send(self.class.ranking_group_by))
- end
-
- # exceptions:
- # move to the beginning (aka move to position 0)
- # move to end (aka position = collection.size - 1)
- # when moving to the end of the collection the offset and limit statement automatically handles
- # that 'after' is nil which is the same like [collection.last, nil]
- before, after =
- if position.zero?
- [nil, collection.first]
- else
- collection.where.not(id: id).offset(position - 1).limit(2)
- end
-
- rank =
- if self == after && send(self.class.ranking_column).present?
- send(self.class.ranking_column)
- else
- value_between(before&.send(self.class.ranking_column), after&.send(self.class.ranking_column))
- end
+ def move_to_end(&)
+ self.class.lexorank_ranking.move_to(self, :last, &)
+ end
- send(:"#{self.class.ranking_column}=", rank)
+ def move_to(position, &)
+ self.class.lexorank_ranking.move_to(self, position, &)
end
def move_to!(position)
- move_to(position)
- save
+ move_to(position) do
+ save
+ end
end
def move_to_top!
- move_to!(0)
+ move_to_top do
+ save
+ end
+ end
+
+ def move_to_end!
+ move_to_end do
+ save
+ end
end
def no_rank?
- !send(self.class.ranking_column)
+ !send(self.class.lexorank_ranking.field)
end
end
end
diff --git a/lib/lexorank/ranking.rb b/lib/lexorank/ranking.rb
new file mode 100644
index 0000000..a4c60b6
--- /dev/null
+++ b/lib/lexorank/ranking.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+class Lexorank::Ranking
+ include Lexorank
+
+ attr_reader :record_class, :original_field, :field, :original_group_by, :group_by, :advisory_lock_config
+
+ def initialize(record_class:, field:, group_by:, advisory_lock:)
+ @record_class = record_class
+ @original_field = field
+ @field = process_column_name(field)
+ @original_group_by = group_by
+ @group_by = process_group_by_column_name(group_by)
+ @advisory_lock_config = { enabled: record_class.respond_to?(:with_advisory_lock) }.merge(advisory_lock)
+ end
+
+ def validate!
+ if advisory_lock_config[:enabled] && !record_class.respond_to?(:with_advisory_lock)
+ raise(
+ Lexorank::InvalidConfigError,
+ "Cannot enable advisory lock if #{record_class.name} does not respond to #with_advisory_lock. " \
+ 'Consider installing the with_advisory_lock gem (https://rubygems.org/gems/with_advisory_lock).'
+ )
+ end
+
+ unless @field
+ # TODO: Make this raise an error. Supplying an invalid column should raise.
+ warn "The supplied ranking column \"#{@original_field}\" is not a column of the model!"
+ end
+
+ if original_group_by && !group_by
+ warn "The supplied grouping by \"#{original_group_by}\" is neither a column nor an association of the model!"
+ end
+ end
+
+ def move_to(instance, position)
+ if block_given? && advisory_locks_enabled?
+ return with_lock_if_enabled(instance) do
+ move_to(instance, position)
+ yield
+ end
+ end
+
+ collection = record_class.ranked
+ if group_by.present?
+ collection = collection.where("#{group_by}": instance.send(group_by))
+ end
+
+ # exceptions:
+ # move to the beginning (aka move to position 0)
+ # move to end (aka position = collection.size - 1)
+ # when moving to the end of the collection the offset and limit statement automatically handles
+ # that 'after' is nil which is the same like [collection.last, nil]
+ before, after =
+ if position == :last
+ [collection.last, nil]
+ elsif position.zero?
+ [nil, collection.first]
+ else
+ collection.where.not(id: instance.id).offset(position - 1).limit(2)
+ end
+
+ # If position >= collection.size both `before` and `after` will be nil. In this case
+ # we set before to the last element of the collection
+ if before.nil? && after.nil?
+ before = collection.last
+ end
+
+ rank =
+ if (self == after && send(field).present?) || (before == self && after.nil?)
+ send(field)
+ else
+ value_between(before&.send(field), after&.send(field))
+ end
+
+ instance.send(:"#{field}=", rank)
+
+ if block_given?
+ yield
+ else
+ rank
+ end
+ end
+
+ def with_lock_if_enabled(instance, &)
+ if advisory_locks_enabled?
+ advisory_lock_options = advisory_lock_config.except(:enabled, :lock_name)
+
+ record_class.with_advisory_lock(advisory_lock_name(instance), **advisory_lock_options, &)
+ else
+ yield
+ end
+ end
+
+ def advisory_lock_name(instance)
+ if advisory_lock_config[:lock_name].present?
+ advisory_lock_config[:lock_name].(instance)
+ else
+ "#{record_class.table_name}_update_#{field}".tap do |name|
+ if group_by.present?
+ name << "_group_#{instance.send(group_by)}"
+ end
+ end
+ end
+ end
+
+ def advisory_locks_enabled?
+ record_class.respond_to?(:with_advisory_lock) && advisory_lock_config[:enabled]
+ end
+
+ private
+
+ def process_column_name(name)
+ return unless name
+
+ # This requires an active connection... do we want this?
+ if record_class.columns.map(&:name).include?(name.to_s)
+ name
+ end
+ end
+
+ def process_group_by_column_name(name)
+ processed_name = process_column_name(name)
+
+ # This requires rank! to be after the specific association
+ if name && !processed_name && (association = record_class.reflect_on_association(name))
+ association.foreign_key.to_sym
+ else
+ processed_name
+ end
+ end
+end
diff --git a/log/.keep b/log/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/advisory_lock_test.rb b/test/advisory_lock_test.rb
new file mode 100644
index 0000000..f7bf800
--- /dev/null
+++ b/test/advisory_lock_test.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class AdvisoryLockTest < ActiveSupport::TestCase
+ should 'raise if model does not respond to #with_advisory_lock and explicitly enabled' do
+ error =
+ assert_raises Lexorank::InvalidConfigError do
+ class Page1 < ActiveRecord::Base
+ self.table_name = 'pages'
+
+ rank!(advisory_lock: { enabled: true })
+ end
+ end
+
+ assert_equal(
+ 'Cannot enable advisory lock if AdvisoryLockTest::Page1 does not respond to #with_advisory_lock. ' \
+ 'Consider installing the with_advisory_lock gem (https://rubygems.org/gems/with_advisory_lock).',
+ error.message
+ )
+ end
+
+ should 'disable advisory locks if the model does not respond to #with_advisory_lock' do
+ class Page2 < ActiveRecord::Base
+ self.table_name = 'pages'
+
+ rank!
+ end
+ assert_not Page2.lexorank_ranking.advisory_lock_config[:enabled]
+
+ # This should not raise a NoMethodError
+ Page2.new.move_to_top!
+ end
+
+ should 'enable advisory locks if model responds to #with_advisory_lock' do
+ assert Page.lexorank_ranking.advisory_lock_config[:enabled]
+
+ assert_advisory_locked Page do
+ Page.new.move_to_top!
+ end
+ end
+
+ should 'allow arbitrary options passed to #with_advisory_lock' do
+ class Page3 < Base
+ self.table_name = 'pages'
+
+ rank!(advisory_lock: { foo: 'bar', bar: 1 })
+ end
+
+ assert_equal({ enabled: true, foo: 'bar', bar: 1 }, Page3.lexorank_ranking.advisory_lock_config)
+
+ instance = Page3.new
+ assert_nil Page3.advisory_locked_with
+ instance.move_to_top!
+ _name, options = Page3.advisory_locked_with
+ assert_equal({ foo: 'bar', bar: 1 }, options)
+ end
+
+ should 'be able to overwrite advisory lock name' do
+ class Page4 < Base
+ self.table_name = 'pages'
+
+ rank!(advisory_lock: { lock_name: ->(instance) { "my_custom_lock_name_#{instance.id}" } })
+ end
+
+ instance = Page4.new
+ assert_advisory_locked_with Page4, ["my_custom_lock_name_#{instance.id}"] do
+ instance.move_to_top!
+ end
+ end
+end
diff --git a/test/group_by_test.rb b/test/group_by_test.rb
index 9bd4d3f..e2b8d3c 100644
--- a/test/group_by_test.rb
+++ b/test/group_by_test.rb
@@ -21,25 +21,25 @@ class GroupByTest < ActiveSupport::TestCase
end
should 'resolve attribute names' do
- assert_equal :page_id, GroupedParagraph.ranking_group_by
+ assert_equal :page_id, GroupedParagraph.lexorank_ranking.group_by
- class Paragraph2 < ActiveRecord::Base
+ class Paragraph2 < Base
self.table_name = 'paragraphs'
belongs_to :page
rank!(group_by: :page)
end
- assert_equal :page_id, Paragraph2.ranking_group_by
+ assert_equal :page_id, Paragraph2.lexorank_ranking.group_by
end
should 'warn on invalid ranking field' do
_, err = capture_io do
- class Paragraph3 < ActiveRecord::Base
+ class Paragraph3 < Base
self.table_name = 'paragraphs'
rank!(group_by: :foo)
end
end
assert_equal "The supplied grouping by \"foo\" is neither a column nor an association of the model!\n", err
- assert_nil Paragraph3.ranking_group_by
+ assert_nil Paragraph3.lexorank_ranking.group_by
end
describe 'moving to a different group' do
diff --git a/test/models/base.rb b/test/models/base.rb
new file mode 100644
index 0000000..c203230
--- /dev/null
+++ b/test/models/base.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Base < ActiveRecord::Base
+ self.abstract_class = true
+
+ class << self
+ attr_accessor :advisory_locked_with
+
+ def with_advisory_lock(*args)
+ @advisory_locked_with = args
+ yield
+ end
+ end
+end
diff --git a/test/models/page.rb b/test/models/page.rb
index 99f3d49..ca62521 100644
--- a/test/models/page.rb
+++ b/test/models/page.rb
@@ -2,7 +2,7 @@
require 'lexorank/rankable'
-class Page < ActiveRecord::Base
+class Page < Base
rank!
has_many :paragraphs
diff --git a/test/models/paragraph.rb b/test/models/paragraph.rb
index 271840d..34608af 100644
--- a/test/models/paragraph.rb
+++ b/test/models/paragraph.rb
@@ -2,7 +2,7 @@
require 'lexorank/rankable'
-class Paragraph < ActiveRecord::Base
+class Paragraph < Base
belongs_to :page
rank!(group_by: :page)
diff --git a/test/ranking_test.rb b/test/ranking_test.rb
index d28d74a..38847a5 100644
--- a/test/ranking_test.rb
+++ b/test/ranking_test.rb
@@ -33,8 +33,31 @@ class RankingTest < Minitest::Test
assert_equal [page_3, page_1, page_2], Page.ranked
end
+ should 'move to end' do
+ page_1, page_2, page_3 = create_sample_pages
+
+ page_1.move_to_end!
+ assert_equal [page_2, page_3, page_1], Page.ranked
+
+ page_2.move_to_end do
+ page_2.save
+ end
+ assert_equal [page_3, page_1, page_2], Page.ranked
+ end
+
+ should 'move to end even if position is larger than collection' do
+ page_1, page_2, page_3 = create_sample_pages
+
+ page_1.move_to!(4)
+ assert_equal [page_2, page_3, page_1], Page.ranked
+
+ # stay the same if current page is already last page
+ page_1.move_to!(5)
+ assert_equal [page_2, page_3, page_1], Page.ranked
+ end
+
should 'be able to use custom ranking column' do
- class Page1 < ActiveRecord::Base
+ class Page1 < Base
self.table_name = 'pages'
rank!(field: :other_ranking_field)
end
@@ -51,15 +74,14 @@ class Page1 < ActiveRecord::Base
should 'report warning on invalid field' do
_, err = capture_io do
- class Page2 < ActiveRecord::Base
+ class Page2 < Base
self.table_name = 'pages'
rank!(field: :foo)
end
end
assert_equal "The supplied ranking column \"foo\" is not a column of the model!\n", err
- assert_not Page2.method_defined?(:ranked)
- assert_nil Page2.ranking_column
- assert_nil Page2.ranking_group_by
+ assert_not Page2.respond_to?(:ranked)
+ assert_not Page2.method_defined?(:move_to)
end
should 'error out if invalid ranks' do
diff --git a/test/scope_test.rb b/test/scope_test.rb
index 7c79999..0530607 100644
--- a/test/scope_test.rb
+++ b/test/scope_test.rb
@@ -16,7 +16,7 @@ class ScopeTest < ActiveSupport::TestCase
end
should 'consider custom ranking column' do
- class Page1 < ActiveRecord::Base
+ class Page1 < Base
self.table_name = 'pages'
rank!(field: :other_ranking_field)
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 357c01d..84fa6d6 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -14,8 +14,10 @@
db_config = YAML.load_file(File.expand_path('database.yml', __dir__)).fetch(ENV['DB'] || 'sqlite')
ActiveRecord::Base.establish_connection(db_config)
ActiveRecord::Schema.verbose = false
+ActiveRecord::Base.logger = Logger.new('log/test.log')
load 'schema.rb'
+require 'models/base'
require 'models/page'
require 'models/paragraph'
require 'models/grouped_paragraph'
@@ -51,6 +53,26 @@ def assert_not(condition)
assert !condition
end
+ def assert_advisory_locked_with(clazz, args = nil)
+ clazz.advisory_locked_with = nil
+
+ yield
+
+ unless args.nil?
+ assert_equal args, clazz.advisory_locked_with
+ end
+ end
+
+ def assert_advisory_locked(clazz, &)
+ assert_advisory_locked_with(clazz, &)
+ end
+
+ def assert_no_advisory_lock(clazz)
+ clazz.advisory_locked_with = nil
+ yield
+ assert_nil clazz.advisory_locked_with
+ end
+
def create_sample_docs(count:, clazz:, create_with: {})
docs = []
count.times do