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