diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..bb9f753d --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and update with your database connection strings + +# PostgreSQL primary database +DATABASE_URL_PG=postgresql://closure_tree:closure_tree_pass@127.0.0.1:5434/closure_tree_test + +# MySQL secondary database +DATABASE_URL_MYSQL=mysql2://closure_tree:closure_tree_pass@127.0.0.1:3367/closure_tree_test + +# SQLite database (optional, in-memory by default) +# DATABASE_URL_SQLITE3=sqlite3:closure_tree_test.db \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83926a4c..99d6e0b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,3 @@ ---- name: CI on: @@ -9,23 +8,17 @@ on: branches: - master - jobs: test: runs-on: ubuntu-latest + services: - mysql: - image: mysql/mysql-server - ports: - - "3306:3306" - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: closure_tree_test - MYSQL_ROOT_HOST: '%' postgres: - image: 'postgres' - ports: ['5432:5432'] + image: postgres:17-alpine + ports: + - 5432:5432 env: + POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: closure_tree_test options: >- @@ -33,25 +26,31 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + + mysql: + image: mysql:8 + ports: + - 3306:3306 + env: + MYSQL_DATABASE: closure_tree_test + MYSQL_ROOT_PASSWORD: root + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 strategy: fail-fast: false matrix: ruby: - - '3.3' + - '3.4' rails: - - activerecord_8.0 - - activerecord_7.2 - - activerecord_7.1 - - activerecord_edge - adapter: - - 'sqlite3:///:memory:' - - mysql2://root:root@0/closure_tree_test - - postgres://closure_tree:closure_tree@0/closure_tree_test + - '8.0' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Ruby uses: ruby/setup-ruby@v1 @@ -60,14 +59,25 @@ jobs: bundler-cache: true rubygems: latest env: - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile + RAILS_VERSION: ${{ matrix.rails }} + BUNDLE_GEMFILE: ${{ github.workspace }}/Gemfile + + - name: Setup databases + env: RAILS_ENV: test + DATABASE_URL_PG: postgres://postgres:postgres@127.0.0.1:5432/closure_tree_test + DATABASE_URL_MYSQL: mysql2://root:root@127.0.0.1:3306/closure_tree_test + DATABASE_URL_SQLITE3: 'sqlite3::memory:' + run: | + cd test/dummy + bundle exec rails db:setup_all - - name: Test + - name: Run tests env: RAILS_ENV: test - RAILS_VERSION: ${{ matrix.rails }} - DB_ADAPTER: ${{ matrix.adapter }} - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile + DATABASE_URL_PG: postgres://postgres:postgres@127.0.0.1:5432/closure_tree_test + DATABASE_URL_MYSQL: mysql2://root:root@127.0.0.1:3306/closure_tree_test + DATABASE_URL_SQLITE3: 'sqlite3::memory:' WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }} - run: bin/rake + run: | + bundle exec rake test \ No newline at end of file diff --git a/.github/workflows/ci_jruby.yml b/.github/workflows/ci_jruby.yml deleted file mode 100644 index b455b15c..00000000 --- a/.github/workflows/ci_jruby.yml +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: CI Jruby - -on: - push: - branches: - - master - pull_request: - branches: - - master -concurrency: - group: ci-${{ github.head_ref }}-jruby - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - services: - mysql: - image: mysql/mysql-server - ports: - - "3306:3306" - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: closure_tree_test - MYSQL_ROOT_HOST: '%' - postgres: - image: 'postgres' - ports: ['5432:5432'] - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: closure_tree_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - strategy: - fail-fast: false - matrix: - rails: - - activerecord_7.1 - adapter: - - 'sqlite3:///:memory:' - - mysql2://root:root@0/closure_tree_test - - postgres://closure_tree:closure_tree@0/closure_tree_test - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: jruby - bundler-cache: true - rubygems: latest - env: - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - RAILS_ENV: test - - - name: Test - env: - RAILS_ENV: test - RAILS_VERSION: ${{ matrix.rails }} - DB_ADAPTER: ${{ matrix.adapter }} - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }} - run: bin/rake diff --git a/.github/workflows/ci_truffleruby.yml b/.github/workflows/ci_truffleruby.yml deleted file mode 100644 index 6bfaf2bf..00000000 --- a/.github/workflows/ci_truffleruby.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: CI Truffleruby - -on: - push: - branches: - - master - pull_request: - branches: - - master -concurrency: - group: ci-${{ github.head_ref }}-truffleruby - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - services: - mysql: - image: mysql/mysql-server - ports: - - "3306:3306" - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: closure_tree_test - MYSQL_ROOT_HOST: '%' - postgres: - image: 'postgres' - ports: ['5432:5432'] - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: closure_tree_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - strategy: - fail-fast: false - matrix: - ruby: - - truffleruby - rails: - - activerecord_7.1 - adapter: - - 'sqlite3:///:memory:' - - mysql2://root:root@0/closure_tree_test - - postgres://closure_tree:closure_tree@0/closure_tree_test - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: truffleruby - bundler-cache: true - rubygems: latest - env: - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - RAILS_ENV: test - - - name: Test - env: - RAILS_ENV: test - RAILS_VERSION: ${{ matrix.rails }} - DB_ADAPTER: ${{ matrix.adapter }} - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }} - run: bin/rake diff --git a/.gitignore b/.gitignore index f50c1be3..c1505a20 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ pkg/ rdoc/ doc/ *.sqlite3.db +*.sqlite3 *.log tmp/ .DS_Store diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0689d974..219bc0b8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1,2 @@ + {".":"8.0.0"} diff --git a/Appraisals b/Appraisals deleted file mode 100644 index 0d4cb8e4..00000000 --- a/Appraisals +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -appraise 'activerecord-7.1' do - gem 'activerecord', '~> 7.1.0' - gem 'railties' - - platforms :ruby, :truffleruby do - gem 'mysql2' - gem 'pg' - gem 'sqlite3', '< 2.0' - end - - platforms :jruby do - gem 'activerecord-jdbcmysql-adapter' - gem 'activerecord-jdbcpostgresql-adapter' - gem 'activerecord-jdbcsqlite3-adapter' - end -end - -appraise 'activerecord-7.2' do - gem 'activerecord', '~> 7.2.0' - gem 'railties' - - platforms :ruby do - gem 'mysql2' - gem 'pg' - gem 'sqlite3' - end -end - -appraise 'activerecord-8.0' do - gem 'activerecord', '~> 8.0.0' - gem 'railties' - - platforms :ruby do - gem 'mysql2' - gem 'pg' - gem 'sqlite3' - end -end - -appraise 'activerecord-edge' do - gem 'activerecord', github: 'rails/rails' - gem 'railties', github: 'rails/rails' - - platforms :ruby do - gem 'mysql2' - gem 'pg' - gem 'sqlite3' - end -end diff --git a/Gemfile b/Gemfile index 8753f543..80b33c68 100644 --- a/Gemfile +++ b/Gemfile @@ -4,4 +4,26 @@ source 'https://rubygems.org' gemspec -gem 'with_advisory_lock', github: 'closuretree/with_advisory_lock' \ No newline at end of file +gem 'dotenv' +gem 'railties' +gem 'with_advisory_lock', '>= 7' + +gem 'activerecord', "~> #{ENV['RAILS_VERSION'] || '8.0'}" + +platforms :mri, :truffleruby do + # Database adapters + gem 'mysql2' + gem 'pg' + gem 'sqlite3' +end + +# Testing gems +group :test do + gem 'maxitest' + gem 'mocha' +end + +# Development gems +group :development do + gem 'rubocop', require: false +end diff --git a/README.md b/README.md index 2a562d19..d53345d4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ closure_tree has some great features: * 2 SQL INSERTs on node creation * 3 SQL INSERT/UPDATEs on node reparenting * __Support for [concurrency](#concurrency)__ (using [with_advisory_lock](https://github.com/ClosureTree/with_advisory_lock)) -* __Tested against ActiveRecord 7.1+ with Ruby 3.3+__ +* __Tested against ActiveRecord 7.2+ with Ruby 3.3+__ * Support for reparenting children (and all their descendants) * Support for [single-table inheritance (STI)](#sti) within the hierarchy * ```find_or_create_by_path``` for [building out heterogeneous hierarchies quickly and conveniently](#find_or_create_by_path) @@ -52,7 +52,7 @@ for a description of different tree storage algorithms. ## Installation -Note that closure_tree only supports ActiveRecord 7.1 and later, and has test coverage for MySQL, PostgreSQL, and SQLite. +Note that closure_tree only supports ActiveRecord 7.2 and later, and has test coverage for MySQL, PostgreSQL, and SQLite. 1. Add `gem 'closure_tree'` to your Gemfile @@ -629,33 +629,17 @@ Upgrade to MySQL 5.7.12 or later if you see [this issue](https://github.com/Clos ## Testing with Closure Tree -Closure tree comes with some RSpec2/3 matchers which you may use for your tests: +Closure tree comes with test matchers which you may use in your tests: ```ruby -require 'spec_helper' +require 'test_helper' require 'closure_tree/test/matcher' -describe Category do - # Should syntax - it { should be_a_closure_tree } - # Expect syntax - it { is_expected.to be_a_closure_tree } -end - -describe Label do - # Should syntax - it { should be_a_closure_tree.ordered } - # Expect syntax - it { is_expected.to be_a_closure_tree.ordered } -end - -describe TodoList::Item do - # Should syntax - it { should be_a_closure_tree.ordered(:priority_order) } - # Expect syntax - it { is_expected.to be_a_closure_tree.ordered(:priority_order) } +class CategoryTest < ActiveSupport::TestCase + test "should be a closure tree" do + assert Category.new.is_a?(ClosureTree::Model) + end end - ``` ## Testing @@ -663,23 +647,19 @@ end Closure tree is [tested under every valid combination](https://github.com/ClosureTree/closure_tree/blob/master/.github/workflows/ci.yml) of * Ruby 3.3+ -* ActiveRecord 7.1+ +* ActiveRecord 7.2+ * PostgreSQL, MySQL, and SQLite. Concurrency tests are only run with MySQL and PostgreSQL. ```shell $ bundle -$ appraisal bundle # this will install the matrix of dependencies -$ appraisal rake # this will run the tests in all combinations -$ appraisal activerecord-7.0 rake # this will run the tests in AR 7.0 only -$ appraisal activerecord-7.0 rake spec # this will run rspec in AR 7.0 only -$ appraisal activerecord-7.0 rake test # this will run minitest in AR 7.0 only +$ rake test # this will run the tests ``` By default the test are run with sqlite3 only. You run test with other databases by passing the database url as environment variable: ```shell -$ DATABASE_URL=postgres://localhost/my_database appraisal activerecord-7.0 rake test +$ DATABASE_URL=postgres://localhost/my_database rake test ``` ## Change log diff --git a/Rakefile b/Rakefile index 2c9ce43c..a9ba3a35 100644 --- a/Rakefile +++ b/Rakefile @@ -23,3 +23,7 @@ namespace :test do end end end + +require_relative 'test/dummy/config/application' + +Rails.application.load_tasks diff --git a/bin/appraisal b/bin/appraisal deleted file mode 100755 index 0e7ba65d..00000000 --- a/bin/appraisal +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'appraisal' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -require "pathname" -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", - Pathname.new(__FILE__).realpath) - -bundle_binstub = File.expand_path("../bundle", __FILE__) - -if File.file?(bundle_binstub) - if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ - load(bundle_binstub) - else - abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. -Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") - end -end - -require "rubygems" -require "bundler/setup" - -load Gem.bin_path("appraisal", "appraisal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 00000000..e2451072 --- /dev/null +++ b/bin/rails @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# This command will automatically be run when you run "rails" with Rails gems +# installed from the root of your application. + +ENGINE_ROOT = File.expand_path('..', __dir__) +APP_PATH = File.expand_path('../test/dummy/config/application', __dir__) + +# Set up gems listed in the Gemfile. +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) + +require 'rails/all' +require 'rails/engine/commands' diff --git a/bin/rake b/bin/rake index 9275675e..80d7e2a0 100755 --- a/bin/rake +++ b/bin/rake @@ -8,11 +8,11 @@ # this file is here to facilitate running it. # -require "pathname" -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", - Pathname.new(__FILE__).realpath) +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', + Pathname.new(__FILE__).realpath) -bundle_binstub = File.expand_path("../bundle", __FILE__) +bundle_binstub = File.expand_path('bundle', __dir__) if File.file?(bundle_binstub) if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ @@ -23,7 +23,7 @@ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this end end -require "rubygems" -require "bundler/setup" +require 'rubygems' +require 'bundler/setup' -load Gem.bin_path("rake", "rake") +load Gem.bin_path('rake', 'rake') diff --git a/bin/rspec b/bin/rspec deleted file mode 100755 index a6c78521..00000000 --- a/bin/rspec +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'rspec' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -require "pathname" -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", - Pathname.new(__FILE__).realpath) - -bundle_binstub = File.expand_path("../bundle", __FILE__) - -if File.file?(bundle_binstub) - if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ - load(bundle_binstub) - else - abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. -Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") - end -end - -require "rubygems" -require "bundler/setup" - -load Gem.bin_path("rspec-core", "rspec") diff --git a/closure_tree.gemspec b/closure_tree.gemspec index d014d301..bfb39be4 100644 --- a/closure_tree.gemspec +++ b/closure_tree.gemspec @@ -9,32 +9,29 @@ Gem::Specification.new do |gem| gem.email = %w[matthew+github@mceachen.org terminale@gmail.com] gem.homepage = 'https://github.com/ClosureTree/closure_tree/' - gem.summary = %q(Easily and efficiently make your ActiveRecord model support hierarchies) + gem.summary = 'Easily and efficiently make your ActiveRecord model support hierarchies' gem.license = 'MIT' gem.metadata = { - 'bug_tracker_uri' => "https://github.com/ClosureTree/closure_tree/issues", - 'changelog_uri' => "https://github.com/ClosureTree/closure_tree/blob/master/CHANGELOG.md", + 'bug_tracker_uri' => 'https://github.com/ClosureTree/closure_tree/issues', + 'changelog_uri' => 'https://github.com/ClosureTree/closure_tree/blob/master/CHANGELOG.md', 'documentation_uri' => "https://www.rubydoc.info/gems/closure_tree/#{gem.version}", - 'homepage_uri' => "https://closuretree.github.io/closure_tree/", - 'source_code_uri' => "https://github.com/ClosureTree/closure_tree", + 'homepage_uri' => 'https://closuretree.github.io/closure_tree/', + 'source_code_uri' => 'https://github.com/ClosureTree/closure_tree', + 'rubygems_mfa_required' => 'true' } - gem.files = `git ls-files`.split($/).reject do |f| - f.match(%r{^(test|img|gemfiles)}) - end + gem.files = Dir.glob('{lib}/**/*') + Dir.glob('bin/*') + %w[README.md CHANGELOG.md MIT-LICENSE closure_tree.gemspec] - gem.test_files = gem.files.grep(%r{^test/}) gem.required_ruby_version = '>= 3.3.0' - gem.add_runtime_dependency 'activerecord', '>= 7.1.0' - gem.add_runtime_dependency 'with_advisory_lock', '>= 6.0.0' + gem.add_dependency 'activerecord', '>= 7.2.0' + gem.add_dependency 'with_advisory_lock', '>= 7.0.0' - gem.add_development_dependency 'appraisal' gem.add_development_dependency 'database_cleaner' - gem.add_development_dependency 'parallel' gem.add_development_dependency 'minitest' gem.add_development_dependency 'minitest-reporters' + gem.add_development_dependency 'parallel' gem.add_development_dependency 'simplecov' gem.add_development_dependency 'timecop' # gem.add_development_dependency 'byebug' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..11c51b4c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + pg: + image: postgres:17-alpine + environment: + POSTGRES_USER: closure_tree + POSTGRES_PASSWORD: closure_tree_pass + POSTGRES_DB: closure_tree_test + ports: + - "5434:5432" + mysql: + image: mysql:8 + environment: + MYSQL_USER: closure_tree + MYSQL_PASSWORD: closure_tree_pass + MYSQL_DATABASE: closure_tree_test + MYSQL_RANDOM_ROOT_PASSWORD: "yes" + MYSQL_ROOT_HOST: '%' + ports: + - "3367:3306" \ No newline at end of file diff --git a/gemfiles/activerecord_7.1.gemfile b/gemfiles/activerecord_7.1.gemfile deleted file mode 100644 index 433f5e74..00000000 --- a/gemfiles/activerecord_7.1.gemfile +++ /dev/null @@ -1,21 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "with_advisory_lock", github: "closuretree/with_advisory_lock" -gem "activerecord", "~> 7.1.0" -gem "railties" - -platforms :ruby, :truffleruby do - gem "mysql2" - gem "pg" - gem "sqlite3", "< 2.0" -end - -platforms :jruby do - gem "activerecord-jdbcmysql-adapter" - gem "activerecord-jdbcpostgresql-adapter" - gem "activerecord-jdbcsqlite3-adapter" -end - -gemspec path: "../" diff --git a/gemfiles/activerecord_7.2.gemfile b/gemfiles/activerecord_7.2.gemfile deleted file mode 100644 index db97d653..00000000 --- a/gemfiles/activerecord_7.2.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "with_advisory_lock", github: "closuretree/with_advisory_lock" -gem "activerecord", "~> 7.2.0" -gem "railties" - -platforms :ruby do - gem "mysql2" - gem "pg" - gem "sqlite3" -end - -gemspec path: "../" diff --git a/gemfiles/activerecord_8.0.gemfile b/gemfiles/activerecord_8.0.gemfile deleted file mode 100644 index a2a4411a..00000000 --- a/gemfiles/activerecord_8.0.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "with_advisory_lock", github: "closuretree/with_advisory_lock" -gem "activerecord", "~> 8.0.0" -gem "railties" - -platforms :ruby do - gem "mysql2" - gem "pg" - gem "sqlite3" -end - -gemspec path: "../" diff --git a/gemfiles/activerecord_edge.gemfile b/gemfiles/activerecord_edge.gemfile deleted file mode 100644 index c30e4bfc..00000000 --- a/gemfiles/activerecord_edge.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "with_advisory_lock", github: "closuretree/with_advisory_lock" -gem "activerecord", github: "rails/rails" -gem "railties", github: "rails/rails" - -platforms :ruby do - gem "mysql2" - gem "pg" - gem "sqlite3" -end - -gemspec path: "../" diff --git a/lib/closure_tree.rb b/lib/closure_tree.rb index fb03939c..59f505fc 100644 --- a/lib/closure_tree.rb +++ b/lib/closure_tree.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_record' module ClosureTree @@ -14,6 +16,7 @@ module ClosureTree autoload :DeterministicOrdering autoload :NumericDeterministicOrdering autoload :Configuration + autoload :AdapterSupport def self.configure yield configuration @@ -25,6 +28,23 @@ def self.configuration end ActiveSupport.on_load :active_record do - ActiveRecord::Base.send :extend, ClosureTree::HasClosureTree - ActiveRecord::Base.send :extend, ClosureTree::HasClosureTreeRoot + ActiveRecord::Base.extend ClosureTree::HasClosureTree + ActiveRecord::Base.extend ClosureTree::HasClosureTreeRoot +end + +# Adapter injection for different database types +ActiveSupport.on_load :active_record_postgresqladapter do + prepend ClosureTree::AdapterSupport +end + +ActiveSupport.on_load :active_record_mysql2adapter do + prepend ClosureTree::AdapterSupport +end + +ActiveSupport.on_load :active_record_trilogyadapter do + prepend ClosureTree::AdapterSupport +end + +ActiveSupport.on_load :active_record_sqlite3adapter do + prepend ClosureTree::AdapterSupport end diff --git a/lib/closure_tree/active_record_support.rb b/lib/closure_tree/active_record_support.rb index 0ee7283b..356f5a07 100644 --- a/lib/closure_tree/active_record_support.rb +++ b/lib/closure_tree/active_record_support.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + module ClosureTree module ActiveRecordSupport delegate :quote, to: :connection def remove_prefix_and_suffix(table_name, model = ActiveRecord::Base) - pre, suff = model.table_name_prefix, model.table_name_suffix + pre = model.table_name_prefix + suff = model.table_name_suffix if table_name.start_with?(pre) && table_name.end_with?(suff) table_name[pre.size..-(suff.size + 1)] else diff --git a/lib/closure_tree/adapter_support.rb b/lib/closure_tree/adapter_support.rb new file mode 100644 index 00000000..cfbe0c95 --- /dev/null +++ b/lib/closure_tree/adapter_support.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ClosureTree + module AdapterSupport + extend ActiveSupport::Concern + + # This module is now only used to ensure the adapter has been loaded + # The actual advisory lock functionality is handled through the model's + # with_advisory_lock method from the with_advisory_lock gem + end +end diff --git a/lib/closure_tree/configuration.rb b/lib/closure_tree/configuration.rb index b34c547d..effeb4af 100644 --- a/lib/closure_tree/configuration.rb +++ b/lib/closure_tree/configuration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClosureTree class Configuration # :nodoc: attr_accessor :database_less diff --git a/lib/closure_tree/deterministic_ordering.rb b/lib/closure_tree/deterministic_ordering.rb index a541269f..6b604d22 100644 --- a/lib/closure_tree/deterministic_ordering.rb +++ b/lib/closure_tree/deterministic_ordering.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClosureTree module DeterministicOrdering def order_value diff --git a/lib/closure_tree/digraphs.rb b/lib/closure_tree/digraphs.rb index 241d6373..438d9fcc 100644 --- a/lib/closure_tree/digraphs.rb +++ b/lib/closure_tree/digraphs.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClosureTree module Digraphs extend ActiveSupport::Concern @@ -14,13 +16,13 @@ def to_digraph_label class_methods do # Renders the given scope as a DOT digraph, suitable for rendering by Graphviz def to_dot_digraph(tree_scope) - id_to_instance = tree_scope.reduce({}) { |h, ea| h[ea.id] = ea; h } + id_to_instance = tree_scope.each_with_object({}) do |ea, h| + h[ea.id] = ea + end output = StringIO.new output << "digraph G {\n" tree_scope.each do |ea| - if id_to_instance.key? ea._ct_parent_id - output << " \"#{ea._ct_parent_id}\" -> \"#{ea._ct_id}\"\n" - end + output << " \"#{ea._ct_parent_id}\" -> \"#{ea._ct_id}\"\n" if id_to_instance.key? ea._ct_parent_id output << " \"#{ea._ct_id}\" [label=\"#{ea.to_digraph_label}\"]\n" end output << "}\n" diff --git a/lib/closure_tree/finders.rb b/lib/closure_tree/finders.rb index 813c06bc..7c8574b3 100644 --- a/lib/closure_tree/finders.rb +++ b/lib/closure_tree/finders.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClosureTree module Finders extend ActiveSupport::Concern @@ -5,6 +7,7 @@ module Finders # Find a descendant node whose +ancestry_path+ will be ```self.ancestry_path + path``` def find_by_path(path, attributes = {}) return self if path.empty? + self.class.find_by_path(path, attributes, id) end @@ -20,13 +23,13 @@ def find_or_create_by_path(path, attributes = {}) _ct.with_advisory_lock do # shenanigans because children.create is bound to the superclass # (in the case of polymorphism): - child = self.children.where(attrs).first || begin + child = children.where(attrs).first || begin # Support STI creation by using base_class: _ct.create(self.class, attrs).tap do |ea| # We know that there isn't a cycle, because we just created it, and # cycle detection is expensive when the node is deep. ea._ct_skip_cycle_detection! - self.children << ea + children << ea end end child.find_or_create_by_path(subpath, attributes) @@ -38,10 +41,10 @@ def find_all_by_generation(generation_level) INNER JOIN ( SELECT descendant_id FROM #{_ct.quoted_hierarchy_table_name} - WHERE ancestor_id = #{_ct.quote(self.id)} + WHERE ancestor_id = #{_ct.quote(id)} GROUP BY descendant_id HAVING MAX(#{_ct.quoted_hierarchy_table_name}.generations) = #{generation_level.to_i} - ) #{ _ct.t_alias_keyword } descendants ON (#{_ct.quoted_table_name}.#{_ct.base_class.primary_key} = descendants.descendant_id) + ) #{_ct.t_alias_keyword} descendants ON (#{_ct.quoted_table_name}.#{_ct.base_class.primary_key} = descendants.descendant_id) SQL _ct.scope_with_order(s) end @@ -51,7 +54,6 @@ def without_self(scope) end class_methods do - def without_instance(instance) if instance.new_record? all @@ -76,38 +78,46 @@ def leaves FROM #{_ct.quoted_hierarchy_table_name} GROUP BY ancestor_id HAVING MAX(#{_ct.quoted_hierarchy_table_name}.generations) = 0 - ) #{ _ct.t_alias_keyword } leaves ON (#{_ct.quoted_table_name}.#{primary_key} = leaves.ancestor_id) + ) #{_ct.t_alias_keyword} leaves ON (#{_ct.quoted_table_name}.#{primary_key} = leaves.ancestor_id) SQL _ct.scope_with_order(s.readonly(false)) end def with_ancestor(*ancestors) ancestor_ids = ancestors.map { |ea| ea.is_a?(ActiveRecord::Base) ? ea._ct_id : ea } - scope = ancestor_ids.blank? ? all : joins(:ancestor_hierarchies). - where("#{_ct.hierarchy_table_name}.ancestor_id" => ancestor_ids). - where("#{_ct.hierarchy_table_name}.generations > 0"). - readonly(false) + scope = if ancestor_ids.blank? + all + else + joins(:ancestor_hierarchies) + .where("#{_ct.hierarchy_table_name}.ancestor_id" => ancestor_ids) + .where("#{_ct.hierarchy_table_name}.generations > 0") + .readonly(false) + end _ct.scope_with_order(scope) end def with_descendant(*descendants) descendant_ids = descendants.map { |ea| ea.is_a?(ActiveRecord::Base) ? ea._ct_id : ea } - scope = descendant_ids.blank? ? all : joins(:descendant_hierarchies). - where("#{_ct.hierarchy_table_name}.descendant_id" => descendant_ids). - where("#{_ct.hierarchy_table_name}.generations > 0"). - readonly(false) + scope = if descendant_ids.blank? + all + else + joins(:descendant_hierarchies) + .where("#{_ct.hierarchy_table_name}.descendant_id" => descendant_ids) + .where("#{_ct.hierarchy_table_name}.generations > 0") + .readonly(false) + end _ct.scope_with_order(scope) end def lowest_common_ancestor(*descendants) descendants = descendants.first if descendants.length == 1 && descendants.first.respond_to?(:each) ancestor_id = hierarchy_class - .where(descendant_id: descendants) - .group(:ancestor_id) - .having("COUNT(ancestor_id) = #{descendants.count}") - .order(Arel.sql('MIN(generations) ASC')) - .limit(1) - .pluck(:ancestor_id).first + .where(descendant_id: descendants) + .group(:ancestor_id) + .having("COUNT(ancestor_id) = #{descendants.count}") + .order(Arel.sql('MIN(generations) ASC')) + .limit(1) + .pluck(:ancestor_id).first find_by(primary_key => ancestor_id) if ancestor_id end @@ -118,13 +128,13 @@ def find_all_by_generation(generation_level) SELECT #{primary_key} as root_id FROM #{_ct.quoted_table_name} WHERE #{_ct.quoted_parent_column_name} IS NULL - ) #{ _ct.t_alias_keyword } roots ON (1 = 1) + ) #{_ct.t_alias_keyword} roots ON (1 = 1) INNER JOIN ( SELECT ancestor_id, descendant_id FROM #{_ct.quoted_hierarchy_table_name} GROUP BY ancestor_id, descendant_id HAVING MAX(generations) = #{generation_level.to_i} - ) #{ _ct.t_alias_keyword } descendants ON ( + ) #{_ct.t_alias_keyword} descendants ON ( #{_ct.quoted_table_name}.#{primary_key} = descendants.descendant_id AND roots.root_id = descendants.ancestor_id ) @@ -135,16 +145,16 @@ def find_all_by_generation(generation_level) # Find the node whose +ancestry_path+ is +path+ def find_by_path(path, attributes = {}, parent_id = nil) return nil if path.blank? + path = _ct.build_ancestry_attr_path(path, attributes) - if path.size > _ct.max_join_tables - return _ct.find_by_large_path(path, attributes, parent_id) - end + return _ct.find_by_large_path(path, attributes, parent_id) if path.size > _ct.max_join_tables + scope = where(path.pop) last_joined_table = _ct.table_name path.reverse.each_with_index do |ea, idx| next_joined_table = "p#{idx}" scope = scope.joins(<<-SQL.squish) - INNER JOIN #{_ct.quoted_table_name} #{ _ct.t_alias_keyword } #{next_joined_table} + INNER JOIN #{_ct.quoted_table_name} #{_ct.t_alias_keyword} #{next_joined_table} ON #{next_joined_table}.#{_ct.quoted_id_column_name} = #{connection.quote_table_name(last_joined_table)}.#{_ct.quoted_parent_column_name} SQL diff --git a/lib/closure_tree/has_closure_tree.rb b/lib/closure_tree/has_closure_tree.rb index b0bc5b1a..21b10f46 100644 --- a/lib/closure_tree/has_closure_tree.rb +++ b/lib/closure_tree/has_closure_tree.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClosureTree module HasClosureTree def has_closure_tree(options = {}) @@ -37,6 +39,6 @@ def has_closure_tree(options = {}) raise e unless ClosureTree.configuration.database_less end - alias_method :acts_as_tree, :has_closure_tree + alias acts_as_tree has_closure_tree end end diff --git a/lib/closure_tree/has_closure_tree_root.rb b/lib/closure_tree/has_closure_tree_root.rb index 70e327d1..e01c9421 100644 --- a/lib/closure_tree/has_closure_tree_root.rb +++ b/lib/closure_tree/has_closure_tree_root.rb @@ -1,38 +1,33 @@ +# frozen_string_literal: true + module ClosureTree class MultipleRootError < StandardError; end class RootOrderingDisabledError < StandardError; end module HasClosureTreeRoot - def has_closure_tree_root(assoc_name, options = {}) - options[:class_name] ||= assoc_name.to_s.sub(/\Aroot_/, "").classify - options[:foreign_key] ||= self.name.underscore << "_id" + options[:class_name] ||= assoc_name.to_s.sub(/\Aroot_/, '').classify + options[:foreign_key] ||= name.underscore << '_id' has_one assoc_name, -> { where(parent: nil) }, **options # Fetches the association, eager loading all children and given associations define_method("#{assoc_name}_including_tree") do |*args| reload = false - reload = args.shift if args && (args.first == true || args.first == false) + reload = args.shift if args && [true, false].include?(args.first) assoc_map = args assoc_map = [nil] if assoc_map.blank? # Memoize @closure_tree_roots ||= {} @closure_tree_roots[assoc_name] ||= {} - unless reload - if @closure_tree_roots[assoc_name].has_key?(assoc_map) - return @closure_tree_roots[assoc_name][assoc_map] - end - end + return @closure_tree_roots[assoc_name][assoc_map] if !reload && @closure_tree_roots[assoc_name].key?(assoc_map) roots = options[:class_name].constantize.where(parent: nil, options[:foreign_key] => id).to_a return nil if roots.empty? - if roots.size > 1 - raise MultipleRootError.new("#{self.class.name}: has_closure_tree_root requires a single root") - end + raise MultipleRootError, "#{self.class.name}: has_closure_tree_root requires a single root" if roots.size > 1 temp_root = roots.first root = nil @@ -68,11 +63,11 @@ def has_closure_tree_root(assoc_name, options = {}) end # Pre-assign inverse association back to this class, if it exists on target class. - if inverse - inverse_assoc = node.association(inverse.name) - inverse_assoc.loaded! - inverse_assoc.target = self - end + next unless inverse + + inverse_assoc = node.association(inverse.name) + inverse_assoc.loaded! + inverse_assoc.target = self end @closure_tree_roots[assoc_name][assoc_map] = root diff --git a/lib/closure_tree/hash_tree.rb b/lib/closure_tree/hash_tree.rb index f8be73b5..f70a2bec 100644 --- a/lib/closure_tree/hash_tree.rb +++ b/lib/closure_tree/hash_tree.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClosureTree module HashTree extend ActiveSupport::Concern @@ -7,7 +9,6 @@ def hash_tree(options = {}) end class_methods do - # There is no default depth limit. This might be crazy-big, depending # on your tree shape. Hash huge trees at your own peril! def hash_tree(options = {}) diff --git a/lib/closure_tree/hash_tree_support.rb b/lib/closure_tree/hash_tree_support.rb index a718ec01..6b2a3d45 100644 --- a/lib/closure_tree/hash_tree_support.rb +++ b/lib/closure_tree/hash_tree_support.rb @@ -1,19 +1,21 @@ +# frozen_string_literal: true + module ClosureTree module HashTreeSupport def default_tree_scope(scope, limit_depth = nil) - # Deepest generation, within limit, for each descendant - # NOTE: Postgres requires HAVING clauses to always contains aggregate functions (!!) - having_clause = limit_depth ? "HAVING MAX(generations) <= #{limit_depth - 1}" : '' - generation_depth = <<-SQL.squish + # Deepest generation, within limit, for each descendant + # NOTE: Postgres requires HAVING clauses to always contains aggregate functions (!!) + having_clause = limit_depth ? "HAVING MAX(generations) <= #{limit_depth - 1}" : '' + generation_depth = <<-SQL.squish INNER JOIN ( SELECT descendant_id, MAX(generations) as depth FROM #{quoted_hierarchy_table_name} GROUP BY descendant_id #{having_clause} - ) #{ t_alias_keyword } generation_depth + ) #{t_alias_keyword} generation_depth ON #{quoted_table_name}.#{model_class.primary_key} = generation_depth.descendant_id - SQL - scope_with_order(scope.joins(generation_depth), 'generation_depth.depth') + SQL + scope_with_order(scope.joins(generation_depth), 'generation_depth.depth') end def hash_tree(tree_scope, limit_depth = nil) diff --git a/lib/closure_tree/hierarchy_maintenance.rb b/lib/closure_tree/hierarchy_maintenance.rb index ab8ce849..029286a9 100644 --- a/lib/closure_tree/hierarchy_maintenance.rb +++ b/lib/closure_tree/hierarchy_maintenance.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_support/concern' module ClosureTree @@ -21,11 +23,12 @@ def _ct_skip_sort_order_maintenance! def _ct_validate if !(defined? @_ct_skip_cycle_detection) && - !new_record? && # don't validate for cycles if we're a new record - changes[_ct.parent_column_name] && # don't validate for cycles if we didn't change our parent - parent.present? && # don't validate if we're root - parent.self_and_ancestors.include?(self) # < this is expensive :\ - errors.add(_ct.parent_column_sym, I18n.t('closure_tree.loop_error', default: 'You cannot add an ancestor as a descendant')) + !new_record? && # don't validate for cycles if we're a new record + changes[_ct.parent_column_name] && # don't validate for cycles if we didn't change our parent + parent.present? && # don't validate if we're root + parent.self_and_ancestors.include?(self) # < this is expensive :\ + errors.add(_ct.parent_column_sym, + I18n.t('closure_tree.loop_error', default: 'You cannot add an ancestor as a descendant')) end end @@ -35,10 +38,8 @@ def _ct_before_save end def _ct_after_save - if public_send(:saved_changes)[_ct.parent_column_name] || @was_new_record - rebuild! - end - if public_send(:saved_changes)[_ct.parent_column_name] && !@was_new_record + rebuild! if saved_changes[_ct.parent_column_name] || @was_new_record + if saved_changes[_ct.parent_column_name] && !@was_new_record # Resetting the ancestral collections addresses # https://github.com/mceachen/closure_tree/issues/68 ancestor_hierarchies.reload @@ -52,9 +53,7 @@ def _ct_after_save def _ct_before_destroy _ct.with_advisory_lock do delete_hierarchy_references - if _ct.options[:dependent] == :nullify - self.class.find(self.id).children.find_each { |c| c.rebuild! } - end + self.class.find(id).children.find_each(&:rebuild!) if _ct.options[:dependent] == :nullify end true # don't prevent destruction end @@ -62,7 +61,7 @@ def _ct_before_destroy def rebuild!(called_by_rebuild = false) _ct.with_advisory_lock do delete_hierarchy_references unless (defined? @was_new_record) && @was_new_record - hierarchy_class.create!(:ancestor => self, :descendant => self, :generations => 0) + hierarchy_class.create!(ancestor: self, descendant: self, generations: 0) unless root? _ct.connection.execute <<-SQL.squish INSERT INTO #{_ct.quoted_hierarchy_table_name} @@ -76,7 +75,7 @@ def rebuild!(called_by_rebuild = false) if _ct.order_is_numeric? && !@_ct_skip_sort_order_maintenance _ct_reorder_prior_siblings_if_parent_changed # Prevent double-reordering of siblings: - _ct_reorder_siblings if !called_by_rebuild + _ct_reorder_siblings unless called_by_rebuild end children.find_each { |c| c.rebuild!(true) } @@ -99,7 +98,7 @@ def delete_hierarchy_references FROM #{_ct.quoted_hierarchy_table_name} WHERE ancestor_id = #{_ct.quote(id)} OR descendant_id = #{_ct.quote(id)} - ) #{ _ct.t_alias_keyword } x ) + ) #{_ct.t_alias_keyword} x ) SQL end end @@ -118,8 +117,8 @@ def rebuild! def cleanup! hierarchy_table = hierarchy_class.arel_table - [:descendant_id, :ancestor_id].each do |foreign_key| - alias_name = foreign_key.to_s.split('_').first + "s" + %i[descendant_id ancestor_id].each do |foreign_key| + alias_name = "#{foreign_key.to_s.split('_').first}s" alias_table = Arel::Table.new(table_name).alias(alias_name) arel_join = hierarchy_table.join(alias_table, Arel::Nodes::OuterJoin) .on(alias_table[primary_key].eq(hierarchy_table[foreign_key])) diff --git a/lib/closure_tree/model.rb b/lib/closure_tree/model.rb index a3de39a5..dee315cd 100644 --- a/lib/closure_tree/model.rb +++ b/lib/closure_tree/model.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_support/concern' module ClosureTree @@ -5,44 +7,42 @@ module Model extend ActiveSupport::Concern included do - belongs_to :parent, nil, - class_name: _ct.model_class.to_s, - foreign_key: _ct.parent_column_name, - inverse_of: :children, - touch: _ct.options[:touch], - optional: true + class_name: _ct.model_class.to_s, + foreign_key: _ct.parent_column_name, + inverse_of: :children, + touch: _ct.options[:touch], + optional: true order_by_generations = -> { Arel.sql("#{_ct.quoted_hierarchy_table_name}.generations ASC") } - has_many :children, *_ct.has_many_order_with_option, **{ - class_name: _ct.model_class.to_s, - foreign_key: _ct.parent_column_name, - dependent: _ct.options[:dependent], - inverse_of: :parent } do - # We have to redefine hash_tree because the activerecord relation is already scoped to parent_id. - def hash_tree(options = {}) - # we want limit_depth + 1 because we don't do self_and_descendants. - limit_depth = options[:limit_depth] - _ct.hash_tree(@association.owner.descendants, limit_depth ? limit_depth + 1 : nil) - end + has_many :children, *_ct.has_many_order_with_option, class_name: _ct.model_class.to_s, + foreign_key: _ct.parent_column_name, + dependent: _ct.options[:dependent], + inverse_of: :parent do + # We have to redefine hash_tree because the activerecord relation is already scoped to parent_id. + def hash_tree(options = {}) + # we want limit_depth + 1 because we don't do self_and_descendants. + limit_depth = options[:limit_depth] + _ct.hash_tree(@association.owner.descendants, limit_depth ? limit_depth + 1 : nil) end + end has_many :ancestor_hierarchies, *_ct.has_many_order_without_option(order_by_generations), - class_name: _ct.hierarchy_class_name, - foreign_key: 'descendant_id' + class_name: _ct.hierarchy_class_name, + foreign_key: 'descendant_id' has_many :self_and_ancestors, *_ct.has_many_order_without_option(order_by_generations), - through: :ancestor_hierarchies, - source: :ancestor + through: :ancestor_hierarchies, + source: :ancestor has_many :descendant_hierarchies, *_ct.has_many_order_without_option(order_by_generations), - class_name: _ct.hierarchy_class_name, - foreign_key: 'ancestor_id' + class_name: _ct.hierarchy_class_name, + foreign_key: 'ancestor_id' has_many :self_and_descendants, *_ct.has_many_order_with_option(order_by_generations), - through: :descendant_hierarchies, - source: :descendant + through: :descendant_hierarchies, + source: :descendant end # Delegate to the Support instance on the class: @@ -80,7 +80,7 @@ def depth ancestor_hierarchies.size - 1 end - alias_method :level, :depth + alias level depth # enumerable of ancestors, immediate parent is first, root is last. def ancestors @@ -147,17 +147,17 @@ def ancestor_of?(node) # node is record's ancestor def descendant_of?(node) - self.ancestors.include? node + ancestors.include? node end # node is record's parent def child_of?(node) - self.parent == node + parent == node end # node and record have a same root def family_of?(node) - self.root == node.root + root == node.root end # Alias for appending to the children collection. diff --git a/lib/closure_tree/numeric_deterministic_ordering.rb b/lib/closure_tree/numeric_deterministic_ordering.rb index 4afe5785..caaefe75 100644 --- a/lib/closure_tree/numeric_deterministic_ordering.rb +++ b/lib/closure_tree/numeric_deterministic_ordering.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_support/concern' # This module is only included if the order column is an integer. @@ -10,10 +12,10 @@ module NumericDeterministicOrdering end def _ct_reorder_prior_siblings_if_parent_changed - if public_send(:saved_change_to_attribute?, _ct.parent_column_name) && !@was_new_record - was_parent_id = public_send(:attribute_before_last_save, _ct.parent_column_name) - _ct.reorder_with_parent_id(was_parent_id) - end + return unless saved_change_to_attribute?(_ct.parent_column_name) && !@was_new_record + + was_parent_id = attribute_before_last_save(_ct.parent_column_name) + _ct.reorder_with_parent_id(was_parent_id) end def _ct_reorder_siblings(minimum_sort_order_value = nil) @@ -33,7 +35,7 @@ def self_and_descendants_preordered JOIN #{_ct.quoted_table_name} anc ON anc.#{_ct.quoted_id_column_name} = anc_hier.ancestor_id JOIN #{_ct.quoted_hierarchy_table_name} depths - ON depths.ancestor_id = #{_ct.quote(self.id)} AND depths.descendant_id = anc.#{_ct.quoted_id_column_name} + ON depths.ancestor_id = #{_ct.quote(id)} AND depths.descendant_id = anc.#{_ct.quoted_id_column_name} SQL self_and_descendants @@ -43,7 +45,6 @@ def self_and_descendants_preordered end class_methods do - # If node is nil, order the whole tree. def _ct_sum_order_by(node = nil) stats_sql = <<-SQL.squish @@ -57,17 +58,15 @@ def _ct_sum_order_by(node = nil) depth_column = node ? 'depths.generations' : 'depths.max_depth' - node_score = "(1 + anc.#{_ct.quoted_order_column(false)}) * " + - "power(#{h['total_descendants']}, #{h['max_depth'].to_i + 1} - #{depth_column})" + node_score = "(1 + anc.#{_ct.quoted_order_column(false)}) * " \ + "power(#{h['total_descendants']}, #{h['max_depth'].to_i + 1} - #{depth_column})" # We want the NULLs to be first in case we are not ordering roots and they have NULL order. Arel.sql("SUM(#{node_score}) IS NULL DESC, SUM(#{node_score})") end def roots_and_descendants_preordered - if _ct.dont_order_roots - raise ClosureTree::RootOrderingDisabledError.new("Root ordering is disabled on this model") - end + raise ClosureTree::RootOrderingDisabledError, 'Root ordering is disabled on this model' if _ct.dont_order_roots join_sql = <<-SQL.squish JOIN #{_ct.quoted_hierarchy_table_name} anc_hier @@ -78,7 +77,7 @@ def roots_and_descendants_preordered SELECT descendant_id, max(generations) AS max_depth FROM #{_ct.quoted_hierarchy_table_name} GROUP BY descendant_id - ) #{ _ct.t_alias_keyword } depths ON depths.descendant_id = anc.#{_ct.quoted_id_column_name} + ) #{_ct.t_alias_keyword} depths ON depths.descendant_id = anc.#{_ct.quoted_id_column_name} SQL joins(join_sql) .group("#{_ct.quoted_table_name}.#{_ct.quoted_id_column_name}") @@ -111,10 +110,10 @@ def prepend_sibling(sibling_node) end def add_sibling(sibling, add_after = true) - fail "can't add self as sibling" if self == sibling + raise "can't add self as sibling" if self == sibling if _ct.dont_order_roots && parent.nil? - raise ClosureTree::RootOrderingDisabledError.new("Root ordering is disabled on this model") + raise ClosureTree::RootOrderingDisabledError, 'Root ordering is disabled on this model' end # Make sure self isn't dirty, because we're going to call reload: @@ -122,31 +121,30 @@ def add_sibling(sibling, add_after = true) _ct.with_advisory_lock do prior_sibling_parent = sibling.parent - reorder_from_value = if prior_sibling_parent == self.parent - [self.order_value, sibling.order_value].compact.min - else - self.order_value - end - - sibling.order_value = self.order_value - sibling.parent = self.parent + reorder_from_value = if prior_sibling_parent == parent + [order_value, sibling.order_value].compact.min + else + order_value + end + + sibling.order_value = order_value + sibling.parent = parent sibling._ct_skip_sort_order_maintenance! sibling.save # may be a no-op _ct_reorder_siblings(reorder_from_value) # The sort order should be correct now except for self and sibling, which may need to flip: - sibling_is_after = self.reload.order_value < sibling.reload.order_value + sibling_is_after = reload.order_value < sibling.reload.order_value if add_after != sibling_is_after # We need to flip the sort orders: - self_ov, sib_ov = self.order_value, sibling.order_value + self_ov = order_value + sib_ov = sibling.order_value update_order_value(sib_ov) sibling.update_order_value(self_ov) end - if prior_sibling_parent != self.parent - prior_sibling_parent.try(:_ct_reorder_children) - end + prior_sibling_parent.try(:_ct_reorder_children) if prior_sibling_parent != parent sibling end end diff --git a/lib/closure_tree/numeric_order_support.rb b/lib/closure_tree/numeric_order_support.rb index faeec052..46b41008 100644 --- a/lib/closure_tree/numeric_order_support.rb +++ b/lib/closure_tree/numeric_order_support.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + module ClosureTree module NumericOrderSupport - def self.adapter_for_connection(connection) adapter_name = connection.adapter_name.downcase if adapter_name.include?('postgresql') || adapter_name.include?('postgis') @@ -15,11 +16,12 @@ def self.adapter_for_connection(connection) module MysqlAdapter def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil) return if parent_id.nil? && dont_order_roots + min_where = if minimum_sort_order_value - "AND #{quoted_order_column} >= #{minimum_sort_order_value}" - else - "" - end + "AND #{quoted_order_column} >= #{minimum_sort_order_value}" + else + '' + end connection.execute 'SET @i = 0' connection.execute <<-SQL.squish UPDATE #{quoted_table_name} @@ -33,11 +35,12 @@ def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil) module PostgreSQLAdapter def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil) return if parent_id.nil? && dont_order_roots + min_where = if minimum_sort_order_value - "AND #{quoted_order_column} >= #{minimum_sort_order_value}" - else - "" - end + "AND #{quoted_order_column} >= #{minimum_sort_order_value}" + else + '' + end connection.execute <<-SQL.squish UPDATE #{quoted_table_name} SET #{quoted_order_column(false)} = t.seq + #{minimum_sort_order_value.to_i - 1} @@ -59,12 +62,11 @@ def rows_updated(result) module GenericAdapter def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil) return if parent_id.nil? && dont_order_roots - scope = model_class. - where(parent_column_sym => parent_id). - order(nulls_last_order_by) - if minimum_sort_order_value - scope = scope.where("#{quoted_order_column} >= #{minimum_sort_order_value}") - end + + scope = model_class + .where(parent_column_sym => parent_id) + .order(nulls_last_order_by) + scope = scope.where("#{quoted_order_column} >= #{minimum_sort_order_value}") if minimum_sort_order_value scope.each_with_index do |ea, idx| ea.update_order_value(idx + minimum_sort_order_value.to_i) end diff --git a/lib/closure_tree/support.rb b/lib/closure_tree/support.rb index 31013886..f57ef530 100644 --- a/lib/closure_tree/support.rb +++ b/lib/closure_tree/support.rb @@ -1,9 +1,10 @@ +# frozen_string_literal: true + require 'closure_tree/support_flags' require 'closure_tree/support_attributes' require 'closure_tree/numeric_order_support' require 'closure_tree/active_record_support' require 'closure_tree/hash_tree_support' -require 'with_advisory_lock' # This class and mixins are an effort to reduce the namespace pollution to models that act_as_tree. module ClosureTree @@ -13,26 +14,23 @@ class Support include ClosureTree::ActiveRecordSupport include ClosureTree::HashTreeSupport - attr_reader :model_class - attr_reader :options + attr_reader :model_class, :options def initialize(model_class, options) @model_class = model_class - - # Detect if we're using SQLite and disable advisory locks - default_with_advisory_lock = !connection.adapter_name.downcase.include?('sqlite') - + @options = { - :parent_column_name => 'parent_id', - :dependent => :nullify, # or :destroy or :delete_all -- see the README - :name_column => 'name', - :with_advisory_lock => default_with_advisory_lock, - :numeric_order => false + parent_column_name: 'parent_id', + dependent: :nullify, # or :destroy or :delete_all -- see the README + name_column: 'name', + with_advisory_lock: true, # This will be overridden by adapter support + numeric_order: false }.merge(options) raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path' - if order_is_numeric? - extend NumericOrderSupport.adapter_for_connection(connection) - end + + return unless order_is_numeric? + + extend NumericOrderSupport.adapter_for_connection(connection) end def hierarchy_class_for_model @@ -47,7 +45,7 @@ def ==(other) end alias :eql? :== def hash - ancestor_id.hash << 31 ^ descendant_id.hash + (ancestor_id.hash << 31) ^ descendant_id.hash end end hierarchy_class.table_name = hierarchy_table_name @@ -59,21 +57,19 @@ def hierarchy_table_name # because they may have overridden the table name, which is what we want to be consistent with # in order for the schema to make sense. tablename = options[:hierarchy_table_name] || - remove_prefix_and_suffix(table_name, model_class).singularize + "_hierarchies" + "#{remove_prefix_and_suffix(table_name, model_class).singularize}_hierarchies" [model_class.table_name_prefix, tablename, model_class.table_name_suffix].join end def with_order_option(opts) - if order_option? - opts[:order] = [opts[:order], order_by].compact.join(",") - end + opts[:order] = [opts[:order], order_by].compact.join(',') if order_option? opts end def scope_with_order(scope, additional_order_by = nil) if order_option? - scope.order(*([additional_order_by, order_by].compact)) + scope.order(*[additional_order_by, order_by].compact) else additional_order_by ? scope.order(additional_order_by) : scope end @@ -81,10 +77,10 @@ def scope_with_order(scope, additional_order_by = nil) # lambda-ize the order, but don't apply the default order_option def has_many_order_without_option(order_by_opt) - [lambda { order(order_by_opt.call) }] + [-> { order(order_by_opt.call) }] end - def has_many_order_with_option(order_by_opt=nil) + def has_many_order_with_option(order_by_opt = nil) order_options = [order_by_opt, order_by].compact [lambda { order_options = order_options.map { |o| o.is_a?(Proc) ? o.call : o } @@ -105,9 +101,9 @@ def where_eq(column_name, value) end def with_advisory_lock(&block) - if options[:with_advisory_lock] + if options[:with_advisory_lock] && connection.supports_advisory_locks? && model_class.respond_to?(:with_advisory_lock) model_class.with_advisory_lock(advisory_lock_name) do - transaction { yield } + transaction(&block) end else yield @@ -119,7 +115,7 @@ def build_ancestry_attr_path(path, attributes) unless path.first.is_a?(Hash) if subclass? && has_inheritance_column? attributes = attributes.with_indifferent_access - attributes[inheritance_column] ||= self.sti_name + attributes[inheritance_column] ||= sti_name end path = path.map { |ea| attributes.merge(name_column => ea) } end @@ -127,11 +123,9 @@ def build_ancestry_attr_path(path, attributes) end def scoped_attributes(scope, attributes, target_table = model_class.table_name) - table_prefixed_attributes = Hash[ - attributes.map do |column_name, column_value| - ["#{target_table}.#{column_name}", column_value] - end - ] + table_prefixed_attributes = attributes.transform_keys do |column_name| + "#{target_table}.#{column_name}" + end scope.where(table_prefixed_attributes) end @@ -146,6 +140,7 @@ def find_by_large_path(path, attributes = {}, parent_id = nil) path.in_groups(max_join_tables, false).each do |subpath| child = model_class.find_by_path(subpath, attributes, next_parent_id) return nil if child.nil? + next_parent_id = child._ct_id end child @@ -164,7 +159,7 @@ def create(model_class, attributes) end def create!(model_class, attributes) - create(model_class, attributes).tap { |ea| ea.save! } + create(model_class, attributes).tap(&:save!) end end end diff --git a/lib/closure_tree/support_attributes.rb b/lib/closure_tree/support_attributes.rb index 38879ade..2d63c6f9 100644 --- a/lib/closure_tree/support_attributes.rb +++ b/lib/closure_tree/support_attributes.rb @@ -1,11 +1,18 @@ +# frozen_string_literal: true + require 'forwardable' +require 'zlib' + module ClosureTree module SupportAttributes extend Forwardable def_delegators :model_class, :connection, :transaction, :table_name, :base_class, :inheritance_column, :column_names def advisory_lock_name - Digest::SHA1.hexdigest("ClosureTree::#{base_class.name}")[0..32] + # Use CRC32 for a shorter, consistent hash + # This gives us 8 hex characters which is plenty for uniqueness + # and leaves room for prefixes + "ct_#{Zlib.crc32(base_class.name.to_s).to_s(16)}" end def quoted_table_name @@ -17,7 +24,7 @@ def quoted_value(value) end def hierarchy_class_name - options[:hierarchy_class_name] || model_class.to_s + "Hierarchy" + options[:hierarchy_class_name] || "#{model_class}Hierarchy" end def primary_key_column @@ -84,7 +91,7 @@ def nulls_last_order_by end def order_by_order(reverse = false) - desc = !!(order_by.to_s =~ /DESC\z/) + desc = !(order_by.to_s =~ /DESC\z/).nil? desc = !desc if reverse desc ? 'DESC' : 'ASC' end @@ -111,13 +118,13 @@ def order_column_sym def quoted_order_column(include_table_name = true) require_order_column - prefix = include_table_name ? "#{quoted_table_name}." : "" + prefix = include_table_name ? "#{quoted_table_name}." : '' "#{prefix}#{connection.quote_column_name(order_column)}" end # table_name alias keyword , like "AS". When used on table name alias, Oracle Database don't support used 'AS' def t_alias_keyword - (ActiveRecord::Base.connection.adapter_name.to_sym == :OracleEnhanced) ? "" : "AS" + ActiveRecord::Base.connection.adapter_name.to_sym == :OracleEnhanced ? '' : 'AS' end end end diff --git a/lib/closure_tree/support_flags.rb b/lib/closure_tree/support_flags.rb index ec8586cb..c4a8c825 100644 --- a/lib/closure_tree/support_flags.rb +++ b/lib/closure_tree/support_flags.rb @@ -1,7 +1,7 @@ +# frozen_string_literal: true + module ClosureTree module SupportFlags - - def order_option? order_by.present? end diff --git a/lib/closure_tree/test/matcher.rb b/lib/closure_tree/test/matcher.rb index bb01efa8..dc306cc1 100644 --- a/lib/closure_tree/test/matcher.rb +++ b/lib/closure_tree/test/matcher.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'closure_tree' module ClosureTree @@ -19,13 +21,13 @@ def matches?(subject) end # Checking if hierarchy table exists (common error) - unless @subject.hierarchy_class.table_exists? + unless @subject.hierarchy_class.table_exists? @message = "expected #{@subject.name}'s hierarchy table '#{@subject.hierarchy_class.table_name}' to exist" return false end if @ordered - unless @subject._ct.options.include?(:order) + unless @subject._ct.options.include?(:order) @message = "expected #{@subject.name} to be an ordered closure tree" return false end @@ -36,13 +38,13 @@ def matches?(subject) end if @with_advisory_lock && !@subject._ct.options[:with_advisory_lock] - @message = "expected #{@subject.name} to have advisory lock" - return false + @message = "expected #{@subject.name} to have advisory lock" + return false end if @without_advisory_lock && @subject._ct.options[:with_advisory_lock] - @message = "expected #{@subject.name} to not have advisory lock" - return false + @message = "expected #{@subject.name} to not have advisory lock" + return false end return true @@ -70,13 +72,13 @@ def failure_message @message || "expected #{@subject.name} to #{description}" end - alias_method :failure_message_for_should, :failure_message + alias failure_message_for_should failure_message def failure_message_when_negated "expected #{@subject.name} not be a closure tree, but it is." end - alias_method :failure_message_for_should_not, :failure_message_when_negated + alias failure_message_for_should_not failure_message_when_negated def description "be a#{@ordered} closure tree#{@with_advisory_lock}" diff --git a/lib/closure_tree/version.rb b/lib/closure_tree/version.rb index 4111d6f3..ee507b98 100644 --- a/lib/closure_tree/version.rb +++ b/lib/closure_tree/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClosureTree VERSION = Gem::Version.new('8.0.0') end diff --git a/lib/generators/closure_tree/config_generator.rb b/lib/generators/closure_tree/config_generator.rb index 5689e778..b38709df 100644 --- a/lib/generators/closure_tree/config_generator.rb +++ b/lib/generators/closure_tree/config_generator.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + module ClosureTree module Generators # :nodoc: class ConfigGenerator < Rails::Generators::Base # :nodoc: - source_root File.expand_path('../templates', __FILE__) + source_root File.expand_path('templates', __dir__) desc 'Install closure tree config.' def config diff --git a/lib/generators/closure_tree/migration_generator.rb b/lib/generators/closure_tree/migration_generator.rb index 64f2ba7a..505c5d50 100644 --- a/lib/generators/closure_tree/migration_generator.rb +++ b/lib/generators/closure_tree/migration_generator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'closure_tree/active_record_support' require 'forwardable' require 'rails/generators' @@ -36,10 +38,10 @@ def target_class def ct @ct ||= if target_class.respond_to?(:_ct) - target_class._ct - else - fail "Please RTFM and add the `has_closure_tree` (or `acts_as_tree`) annotation to #{class_name} before creating the migration." - end + target_class._ct + else + raise "Please RTFM and add the `has_closure_tree` (or `acts_as_tree`) annotation to #{class_name} before creating the migration." + end end def migration_version diff --git a/lib/generators/closure_tree/templates/config.rb b/lib/generators/closure_tree/templates/config.rb index ace2ed63..19ba0892 100644 --- a/lib/generators/closure_tree/templates/config.rb +++ b/lib/generators/closure_tree/templates/config.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ClosureTree.configure do |config| # Some PaaS like Heroku don't have available the db in some build steps like # assets:precompile, this is skipped when this value is true, default = false diff --git a/mktree.rb b/mktree.rb index cc980032..511e2156 100755 --- a/mktree.rb +++ b/mktree.rb @@ -1,8 +1,9 @@ #!/usr/bin/env bundle exec ruby -I lib:spec +# frozen_string_literal: true # Simple benchmark utility to create a closure tree based on the topology of the current filesystem -#ENV['NONUKES'] = '1' +# ENV['NONUKES'] = '1' require 'spec_helper' require 'findler' require 'pathname' @@ -22,8 +23,8 @@ def path_array iter = f.iterator Tag.with_advisory_lock('closure_tree') do while (nxt = iter.next_file) && ((cnt += 1) < 1000) - t = Tag.find_or_create_by_path(nxt.path_array) - puts "created #{nxt.to_s}" + Tag.find_or_create_by_path(nxt.path_array) + puts "created #{nxt}" end end @@ -31,8 +32,7 @@ def path_array puts "TagHierarchy.all.size: #{TagHierarchy.all.size}" puts 'Tag.roots performance:' -puts Benchmark.measure { Tag.roots.size } +puts(Benchmark.measure { Tag.roots.size }) puts 'Tag.leaves performance:' -puts Benchmark.measure { Tag.leaves.size } - +puts(Benchmark.measure { Tag.leaves.size }) diff --git a/test/closure_tree/cache_invalidation_test.rb b/test/closure_tree/cache_invalidation_test.rb index 4d722c4d..419e084a 100644 --- a/test/closure_tree/cache_invalidation_test.rb +++ b/test/closure_tree/cache_invalidation_test.rb @@ -1,10 +1,11 @@ +# frozen_string_literal: true + require 'test_helper' class CacheInvalidationTest < ActiveSupport::TestCase def setup - skip_on_jruby "Timecop is incompatible with JRuby and ActiveRecord 7.1+" Timecop.travel(10.seconds.ago) do - #create a long tree with 2 branch + # create a long tree with 2 branch @root = MenuItem.create( name: SecureRandom.hex(10) ) @@ -21,17 +22,17 @@ def setup end end - test "touch option should invalidate cache for all it ancestors" do + test 'touch option should invalidate cache for all it ancestors' do old_time_stamp = @first_leaf.ancestors.pluck(:updated_at) @first_leaf.touch new_time_stamp = @first_leaf.ancestors.pluck(:updated_at) assert_not_equal old_time_stamp, new_time_stamp, 'Cache not invalidated for all ancestors' end - test "touch option should not invalidate cache for another branch" do + test 'touch option should not invalidate cache for another branch' do old_time_stamp = @second_leaf.updated_at @first_leaf.touch new_time_stamp = @second_leaf.updated_at assert_equal old_time_stamp, new_time_stamp, 'Cache incorrectly invalidated for another branch' end -end \ No newline at end of file +end diff --git a/test/closure_tree/generator_test.rb b/test/closure_tree/generator_test.rb index 1b61a6ce..ac949cc3 100644 --- a/test/closure_tree/generator_test.rb +++ b/test/closure_tree/generator_test.rb @@ -43,7 +43,7 @@ def test_generator_output_with_namespaced_model_with_slash def test_should_run_all_tasks_in_generator_without_errors gen = generator %w[tag] output = capture_io { gen.invoke_all } - assert output, "Generator should complete without errors" + assert output, 'Generator should complete without errors' end end end diff --git a/test/closure_tree/has_closure_tree_root_test.rb b/test/closure_tree/has_closure_tree_root_test.rb index 835fa48e..de248268 100644 --- a/test/closure_tree/has_closure_tree_root_test.rb +++ b/test/closure_tree/has_closure_tree_root_test.rb @@ -1,4 +1,6 @@ -require "test_helper" +# frozen_string_literal: true + +require 'test_helper' class HasClosureTreeRootTest < ActiveSupport::TestCase setup do @@ -6,17 +8,17 @@ class HasClosureTreeRootTest < ActiveSupport::TestCase end teardown do - FileUtils.remove_entry_secure ENV['FLOCK_DIR'] + FileUtils.remove_entry_secure ENV.fetch('FLOCK_DIR', nil) end def create_tree(group) - @ct1 = ContractType.create!(name: "Type1") - @ct2 = ContractType.create!(name: "Type2") - @user1 = User.create!(email: "1@example.com", group_id: group.id) - @user2 = User.create!(email: "2@example.com", group_id: group.id) - @user3 = User.create!(email: "3@example.com", group_id: group.id) - @user4 = User.create!(email: "4@example.com", group_id: group.id) - @user5 = User.create!(email: "5@example.com", group_id: group.id) - @user6 = User.create!(email: "6@example.com", group_id: group.id) + @ct1 = ContractType.create!(name: 'Type1') + @ct2 = ContractType.create!(name: 'Type2') + @user1 = User.create!(email: '1@example.com', group_id: group.id) + @user2 = User.create!(email: '2@example.com', group_id: group.id) + @user3 = User.create!(email: '3@example.com', group_id: group.id) + @user4 = User.create!(email: '4@example.com', group_id: group.id) + @user5 = User.create!(email: '5@example.com', group_id: group.id) + @user6 = User.create!(email: '6@example.com', group_id: group.id) # The tree (contract types in parens) # @@ -32,49 +34,51 @@ def create_tree(group) @user3.children << @user5 @user3.children << @user6 - @user1.contracts.create!(title: "Contract 1", contract_type: @ct1) - @user2.contracts.create!(title: "Contract 2", contract_type: @ct1) - @user3.contracts.create!(title: "Contract 3", contract_type: @ct1) - @user3.contracts.create!(title: "Contract 4", contract_type: @ct2) - @user4.contracts.create!(title: "Contract 5", contract_type: @ct2) - @user5.contracts.create!(title: "Contract 6", contract_type: @ct1) - @user6.contracts.create!(title: "Contract 7", contract_type: @ct2) + @user1.contracts.create!(title: 'Contract 1', contract_type: @ct1) + @user2.contracts.create!(title: 'Contract 2', contract_type: @ct1) + @user3.contracts.create!(title: 'Contract 3', contract_type: @ct1) + @user3.contracts.create!(title: 'Contract 4', contract_type: @ct2) + @user4.contracts.create!(title: 'Contract 5', contract_type: @ct2) + @user5.contracts.create!(title: 'Contract 6', contract_type: @ct1) + @user6.contracts.create!(title: 'Contract 7', contract_type: @ct2) end - test "loads all nodes in a constant number of queries" do - group = Group.create!(name: "TheGrouping") + test 'loads all nodes in a constant number of queries' do + group = Group.create!(name: 'TheGrouping') create_tree(group) reloaded_group = group.reload exceed_query_limit(2) do root = reloaded_group.root_user_including_tree - assert_equal "2@example.com", root.children[0].email - assert_equal "3@example.com", root.children[0].parent.children[1].email + assert_equal '2@example.com', root.children[0].email + assert_equal '3@example.com', root.children[0].parent.children[1].email end end - test "loads all nodes plus single association in a constant number of queries" do - group = Group.create!(name: "TheGrouping") + test 'loads all nodes plus single association in a constant number of queries' do + group = Group.create!(name: 'TheGrouping') create_tree(group) reloaded_group = group.reload exceed_query_limit(3) do root = reloaded_group.root_user_including_tree(:contracts) - assert_equal "2@example.com", root.children[0].email - assert_equal "3@example.com", root.children[0].parent.children[1].email - assert_equal "Contract 7", root.children[0].children[0].contracts[0].user.parent.parent.children[1].children[1].contracts[0].title + assert_equal '2@example.com', root.children[0].email + assert_equal '3@example.com', root.children[0].parent.children[1].email + assert_equal 'Contract 7', + root.children[0].children[0].contracts[0].user.parent.parent.children[1].children[1].contracts[0].title end end - test "loads all nodes and associations in a constant number of queries" do - group = Group.create!(name: "TheGrouping") + test 'loads all nodes and associations in a constant number of queries' do + group = Group.create!(name: 'TheGrouping') create_tree(group) reloaded_group = group.reload exceed_query_limit(4) do root = reloaded_group.root_user_including_tree(contracts: :contract_type) - assert_equal "2@example.com", root.children[0].email - assert_equal "3@example.com", root.children[0].parent.children[1].email + assert_equal '2@example.com', root.children[0].email + assert_equal '3@example.com', root.children[0].parent.children[1].email assert_equal %w[Type1 Type2], root.children[1].contracts.map(&:contract_type).map(&:name) - assert_equal "Type1", root.children[1].children[0].contracts[0].contract_type.name - assert_equal "Type2", root.children[0].children[0].contracts[0].user.parent.parent.children[1].children[1].contracts[0].contract_type.name + assert_equal 'Type1', root.children[1].children[0].contracts[0].contract_type.name + assert_equal 'Type2', + root.children[0].children[0].contracts[0].user.parent.parent.children[1].children[1].contracts[0].contract_type.name end end -end \ No newline at end of file +end diff --git a/test/closure_tree/label_order_value_test.rb b/test/closure_tree/label_order_value_test.rb new file mode 100644 index 00000000..968a3323 --- /dev/null +++ b/test/closure_tree/label_order_value_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'test_helper' + +class LabelOrderValueTest < ActiveSupport::TestCase + def setup + Label.delete_all + LabelHierarchy.delete_all + end + + test 'should set order_value on roots for Label' do + root = Label.create(name: 'root') + assert_equal 0, root.order_value + end + + test 'should set order_value with siblings for Label' do + root = Label.create(name: 'root') + a = root.children.create(name: 'a') + b = root.children.create(name: 'b') + c = root.children.create(name: 'c') + + assert_equal 0, a.order_value + assert_equal 1, b.order_value + assert_equal 2, c.order_value + end + + test 'should reset order_value when a node is moved to another location for Label' do + root = Label.create(name: 'root') + a = root.children.create(name: 'a') + b = root.children.create(name: 'b') + c = root.children.create(name: 'c') + + root2 = Label.create(name: 'root2') + root2.add_child b + + assert_equal 0, a.order_value + assert_equal 0, b.order_value + assert_equal 1, c.reload.order_value + end + + test 'should set order_value on roots for LabelWithoutRootOrdering' do + root = LabelWithoutRootOrdering.create(name: 'root') + assert_nil root.order_value + end + + test 'should set order_value with siblings for LabelWithoutRootOrdering' do + root = LabelWithoutRootOrdering.create(name: 'root') + a = root.children.create(name: 'a') + b = root.children.create(name: 'b') + c = root.children.create(name: 'c') + + assert_equal 0, a.order_value + assert_equal 1, b.order_value + assert_equal 2, c.order_value + end +end diff --git a/test/closure_tree/label_test.rb b/test/closure_tree/label_test.rb index 3f36dc3d..4bfab697 100644 --- a/test/closure_tree/label_test.rb +++ b/test/closure_tree/label_test.rb @@ -1,17 +1,21 @@ # frozen_string_literal: true -require "test_helper" +require 'test_helper' module CorrectOrderValue - def self.shared_examples(&block) - describe "correct order_value" do + def self.shared_examples(model, expected_root_order_value) + describe 'correct order_value' do before do - instance_exec(&block) - @root = @model.create(name: "root") + @model = model + @expected_root_order_value = expected_root_order_value + # Clean up any existing data + @model.delete_all + @model.hierarchy_class.delete_all + @root = @model.create(name: 'root') @a, @b, @c = %w[a b c].map { |n| @root.children.create(name: n) } end - it "should set order_value on roots" do + it 'should set order_value on roots' do if @expected_root_order_value.nil? assert_nil @root.order_value else @@ -19,14 +23,14 @@ def self.shared_examples(&block) end end - it "should set order_value with siblings" do + it 'should set order_value with siblings' do assert_equal 0, @a.order_value assert_equal 1, @b.order_value assert_equal 2, @c.order_value end - it "should reset order_value when a node is moved to another location" do - root2 = @model.create(name: "root2") + it 'should reset order_value when a node is moved to another location' do + root2 = @model.create(name: 'root2') root2.add_child @b assert_equal 0, @a.order_value assert_equal 0, @b.order_value @@ -50,7 +54,7 @@ def create_label_tree Label.update_all("#{Label._ct.order_column} = id") end -def create_preorder_tree(suffix = "") +def create_preorder_tree(suffix = '') %w[ a/l/n/r a/l/n/q @@ -61,7 +65,7 @@ def create_preorder_tree(suffix = "") a/b/c/d/g a/b/c/d/f a/b/c/d/e - ].shuffle.each { |ea| Label.find_or_create_by_path(ea.split("/").collect { |ea| "#{ea}#{suffix}" }) } + ].shuffle.each { |ea| Label.find_or_create_by_path(ea.split('/').collect { |ea| "#{ea}#{suffix}" }) } Label.roots.each_with_index do |root, root_idx| root.order_value = root_idx @@ -78,8 +82,12 @@ def create_preorder_tree(suffix = "") end describe Label do - describe "destruction" do - it "properly destroys descendents created with find_or_create_by_path" do + before do + Label.delete_all + Label.hierarchy_class.delete_all + end + describe 'destruction' do + it 'properly destroys descendents created with find_or_create_by_path' do c = Label.find_or_create_by_path %w[a b c] b = c.parent a = c.root @@ -87,21 +95,21 @@ def create_preorder_tree(suffix = "") refute Label.exists?(id: [a.id, b.id, c.id]) end - it "properly destroys descendents created with add_child" do - a = Label.create(name: "a") - b = a.add_child Label.new(name: "b") - c = b.add_child Label.new(name: "c") + it 'properly destroys descendents created with add_child' do + a = Label.create(name: 'a') + b = a.add_child Label.new(name: 'b') + c = b.add_child Label.new(name: 'c') a.destroy refute Label.exists?(a.id) refute Label.exists?(b.id) refute Label.exists?(c.id) end - it "properly destroys descendents created with <<" do - a = Label.create(name: "a") - b = Label.new(name: "b") + it 'properly destroys descendents created with <<' do + a = Label.create(name: 'a') + b = Label.new(name: 'b') a.children << b - c = Label.new(name: "c") + c = Label.new(name: 'c') b.children << c a.destroy refute Label.exists?(a.id) @@ -110,8 +118,13 @@ def create_preorder_tree(suffix = "") end end - describe "roots" do - it "sorts alphabetically" do + describe 'roots' do + before do + Label.delete_all + Label.hierarchy_class.delete_all + end + + it 'sorts alphabetically' do expected = (0..10).to_a expected.shuffle.each do |ea| Label.create! do |l| @@ -119,24 +132,24 @@ def create_preorder_tree(suffix = "") l.order_value = ea end end - assert_equal expected, Label.roots.collect { |ea| ea.order_value } + assert_equal(expected, Label.roots.collect(&:order_value)) end end - describe "Base Label class" do - it "should find or create by path" do + describe 'Base Label class' do + it 'should find or create by path' do # class method: c = Label.find_or_create_by_path(%w[grandparent parent child]) assert_equal %w[grandparent parent child], c.ancestry_path - assert_equal "child", c.name - assert_equal "parent", c.parent.name + assert_equal 'child', c.name + assert_equal 'parent', c.parent.name end end - describe "Parent/child inverse relationships" do - it "should associate both sides of the parent and child relationships" do - parent = Label.new(name: "parent") - child = parent.children.build(name: "child") + describe 'Parent/child inverse relationships' do + it 'should associate both sides of the parent and child relationships' do + parent = Label.new(name: 'parent') + child = parent.children.build(name: 'child') assert parent.root? refute parent.leaf? refute child.root? @@ -144,31 +157,31 @@ def create_preorder_tree(suffix = "") end end - describe "DateLabel" do - it "should find or create by path" do + describe 'DateLabel' do + it 'should find or create by path' do date = DateLabel.find_or_create_by_path(%w[2011 November 23]) assert_equal %w[2011 November 23], date.ancestry_path date.self_and_ancestors.each { |ea| assert_equal DateLabel, ea.class } - assert_equal "23", date.name - assert_equal "November", date.parent.name + assert_equal '23', date.name + assert_equal 'November', date.parent.name end end - describe "DirectoryLabel" do - it "should find or create by path" do + describe 'DirectoryLabel' do + it 'should find or create by path' do dir = DirectoryLabel.find_or_create_by_path(%w[grandparent parent child]) assert_equal %w[grandparent parent child], dir.ancestry_path - assert_equal "child", dir.name - assert_equal "parent", dir.parent.name - assert_equal "grandparent", dir.parent.parent.name - assert_equal "grandparent", dir.root.name + assert_equal 'child', dir.name + assert_equal 'parent', dir.parent.name + assert_equal 'grandparent', dir.parent.parent.name + assert_equal 'grandparent', dir.root.name refute_equal Label.find_or_create_by_path(%w[grandparent parent child]), dir.id dir.self_and_ancestors.each { |ea| assert_equal DirectoryLabel, ea.class } end end - describe "Mixed class tree" do - describe "preorder tree" do + describe 'Mixed class tree' do + describe 'preorder tree' do before do classes = [Label, DateLabel, DirectoryLabel, EventLabel] create_preorder_tree do |ea| @@ -176,101 +189,101 @@ def create_preorder_tree(suffix = "") end end - it "finds roots with specific classes" do - assert_equal Label.where(name: "a").to_a, Label.roots + it 'finds roots with specific classes' do + assert_equal Label.where(name: 'a').to_a, Label.roots assert DirectoryLabel.roots.empty? assert EventLabel.roots.empty? end - it "all is limited to subclasses" do + it 'all is limited to subclasses' do assert_equal %w[f h l n p].sort, DateLabel.all.map(&:name).sort assert_equal %w[g q].sort, DirectoryLabel.all.map(&:name).sort assert_equal %w[r], EventLabel.all.map(&:name) end - it "returns descendents regardless of subclass" do + it 'returns descendents regardless of subclass' do assert_equal %w[Label DateLabel DirectoryLabel EventLabel].sort, Label.root.descendants.map { |ea| ea.class.to_s }.uniq.sort end end - it "supports children << and add_child" do - a = EventLabel.create!(name: "a") - b = DateLabel.new(name: "b") + it 'supports children << and add_child' do + a = EventLabel.create!(name: 'a') + b = DateLabel.new(name: 'b') a.children << b - c = Label.new(name: "c") + c = Label.new(name: 'c') b.add_child(c) - assert_equal [EventLabel, DateLabel, Label], a.self_and_descendants.collect { |ea| ea.class } - assert_equal %w[a b c], a.self_and_descendants.collect { |ea| ea.name } + assert_equal([EventLabel, DateLabel, Label], a.self_and_descendants.collect(&:class)) + assert_equal(%w[a b c], a.self_and_descendants.collect(&:name)) end end - describe "find_all_by_generation" do + describe 'find_all_by_generation' do before do create_label_tree end - it "finds roots from the class method" do + it 'finds roots from the class method' do assert_equal [@a1, @a2], Label.find_all_by_generation(0).to_a end - it "finds roots from themselves" do + it 'finds roots from themselves' do assert_equal [@a1], @a1.find_all_by_generation(0).to_a end - it "finds itself for non-roots" do + it 'finds itself for non-roots' do assert_equal [@b1], @b1.find_all_by_generation(0).to_a end - it "finds children for roots" do + it 'finds children for roots' do assert_equal [@b1, @b2], Label.find_all_by_generation(1).to_a end - it "finds children" do + it 'finds children' do assert_equal [@b1], @a1.find_all_by_generation(1).to_a assert_equal [@c1, @c2], @b1.find_all_by_generation(1).to_a end - it "finds grandchildren for roots" do + it 'finds grandchildren for roots' do assert_equal [@c1, @c2, @c3], Label.find_all_by_generation(2).to_a end - it "finds grandchildren" do + it 'finds grandchildren' do assert_equal [@c1, @c2], @a1.find_all_by_generation(2).to_a assert_equal [@d1, @d2], @b1.find_all_by_generation(2).to_a end - it "finds great-grandchildren for roots" do + it 'finds great-grandchildren for roots' do assert_equal [@d1, @d2, @d3], Label.find_all_by_generation(3).to_a end end - describe "loading through self_and_ scopes" do + describe 'loading through self_and_ scopes' do before do create_label_tree end - it "self_and_descendants should result in one select" do + it 'self_and_descendants should result in one select' do assert_database_queries_count(1) do a1_array = @a1.self_and_descendants - assert_equal(%w[a1 b1 c1 c2 d1 d2], a1_array.collect { |ea| ea.name }) + assert_equal(%w[a1 b1 c1 c2 d1 d2], a1_array.collect(&:name)) end end - it "self_and_ancestors should result in one select" do + it 'self_and_ancestors should result in one select' do assert_database_queries_count(1) do d1_array = @d1.self_and_ancestors - assert_equal(%w[d1 c1 b1 a1], d1_array.collect { |ea| ea.name }) + assert_equal(%w[d1 c1 b1 a1], d1_array.collect(&:name)) end end end - describe "deterministically orders with polymorphic siblings" do + describe 'deterministically orders with polymorphic siblings' do before do - @parent = Label.create!(name: "parent") - @a, @b, @c, @d, @e, @f = ("a".."f").map { |ea| EventLabel.new(name: ea) } + @parent = Label.create!(name: 'parent') + @a, @b, @c, @d, @e, @f = ('a'..'f').map { |ea| EventLabel.new(name: ea) } @parent.children << @a @a.append_sibling(@b) @b.append_sibling(@c) @@ -291,41 +304,41 @@ def roots_name_and_order name_and_order(Label.roots) end - it "order_values properly" do - assert_equal [["a", 0], ["b", 1], ["c", 2], ["d", 3]], children_name_and_order + it 'order_values properly' do + assert_equal [['a', 0], ['b', 1], ['c', 2], ['d', 3]], children_name_and_order end - it "when inserted before" do + it 'when inserted before' do @b.append_sibling(@a) - assert_equal [["b", 0], ["a", 1], ["c", 2], ["d", 3]], children_name_and_order + assert_equal [['b', 0], ['a', 1], ['c', 2], ['d', 3]], children_name_and_order end - it "when inserted after" do + it 'when inserted after' do @a.append_sibling(@c) - assert_equal [["a", 0], ["c", 1], ["b", 2], ["d", 3]], children_name_and_order + assert_equal [['a', 0], ['c', 1], ['b', 2], ['d', 3]], children_name_and_order end - it "when inserted before the first" do + it 'when inserted before the first' do @a.prepend_sibling(@d) - assert_equal [["d", 0], ["a", 1], ["b", 2], ["c", 3]], children_name_and_order + assert_equal [['d', 0], ['a', 1], ['b', 2], ['c', 3]], children_name_and_order end - it "when inserted after the last" do + it 'when inserted after the last' do @d.append_sibling(@b) - assert_equal [["a", 0], ["c", 1], ["d", 2], ["b", 3]], children_name_and_order + assert_equal [['a', 0], ['c', 1], ['d', 2], ['b', 3]], children_name_and_order end - it "prepends to root nodes" do + it 'prepends to root nodes' do @parent.prepend_sibling(@f) - assert_equal [["f", 0], ["parent", 1], ["e", 2]], roots_name_and_order + assert_equal [['f', 0], ['parent', 1], ['e', 2]], roots_name_and_order end end describe "doesn't order roots when requested" do before do - @root1 = LabelWithoutRootOrdering.create!(name: "root1") - @root2 = LabelWithoutRootOrdering.create!(name: "root2") - @a, @b, @c, @d, @e = ("a".."e").map { |ea| LabelWithoutRootOrdering.new(name: ea) } + @root1 = LabelWithoutRootOrdering.create!(name: 'root1') + @root2 = LabelWithoutRootOrdering.create!(name: 'root2') + @a, @b, @c, @d, @e = ('a'..'e').map { |ea| LabelWithoutRootOrdering.new(name: ea) } @root1.children << @a @root1.append_child(@c) @root1.prepend_child(@d) @@ -339,13 +352,13 @@ def roots_name_and_order @d.prepend_sibling(@e) end - it "order_values properly" do + it 'order_values properly' do assert @root1.reload.order_value.nil? orders_and_names = @root1.children.reload.map { |ea| [ea.name, ea.order_value] } - assert_equal [["e", 0], ["d", 1], ["a", 2], ["b", 3], ["c", 4]], orders_and_names + assert_equal [['e', 0], ['d', 1], ['a', 2], ['b', 3], ['c', 4]], orders_and_names end - it "raises on prepending and appending to root" do + it 'raises on prepending and appending to root' do assert_raises ClosureTree::RootOrderingDisabledError do @root1.prepend_sibling(@f) end @@ -355,43 +368,43 @@ def roots_name_and_order end end - it "returns empty array for siblings_before and after" do + it 'returns empty array for siblings_before and after' do assert_equal [], @root1.siblings_before assert_equal [], @root1.siblings_after end unless sqlite? - it "returns expected result for self_and_descendants_preordered" do + it 'returns expected result for self_and_descendants_preordered' do assert_equal [@root1, @e, @d, @a, @b, @c], @root1.self_and_descendants_preordered.to_a end end - it "raises on roots_and_descendants_preordered" do + it 'raises on roots_and_descendants_preordered' do assert_raises ClosureTree::RootOrderingDisabledError do LabelWithoutRootOrdering.roots_and_descendants_preordered end end end - describe "code in the readme" do - it "creates STI label hierarchies" do + describe 'code in the readme' do + it 'creates STI label hierarchies' do child = Label.find_or_create_by_path([ - {type: "DateLabel", name: "2014"}, - {type: "DateLabel", name: "August"}, - {type: "DateLabel", name: "5"}, - {type: "EventLabel", name: "Visit the Getty Center"} - ]) + { type: 'DateLabel', name: '2014' }, + { type: 'DateLabel', name: 'August' }, + { type: 'DateLabel', name: '5' }, + { type: 'EventLabel', name: 'Visit the Getty Center' } + ]) assert child.is_a?(EventLabel) - assert_equal "Visit the Getty Center", child.name + assert_equal 'Visit the Getty Center', child.name assert_equal %w[5 August 2014], child.ancestors.map(&:name) assert_equal [DateLabel, DateLabel, DateLabel], child.ancestors.map(&:class) end - it "appends and prepends siblings" do - root = Label.create(name: "root") - a = root.append_child(Label.new(name: "a")) - b = Label.create(name: "b") - c = Label.create(name: "c") + it 'appends and prepends siblings' do + root = Label.create(name: 'root') + a = root.append_child(Label.new(name: 'a')) + b = Label.create(name: 'b') + c = Label.create(name: 'c') a.append_sibling(b) assert_equal %w[a b], a.self_and_siblings.collect(&:name) @@ -413,17 +426,17 @@ def roots_name_and_order assert_equal %w[b c a], root.reload.children.collect(&:name) assert_equal [0, 1, 2], root.children.collect(&:order_value) - d = a.reload.append_sibling(Label.new(name: "d")) + d = a.reload.append_sibling(Label.new(name: 'd')) assert_equal %w[b c a d], d.self_and_siblings.collect(&:name) assert_equal [0, 1, 2, 3], d.self_and_siblings.collect(&:order_value) end end # https://github.com/mceachen/closure_tree/issues/84 - it "properly appends children with <<" do - root = Label.create(name: "root") - a = Label.create(name: "a", parent: root) - b = Label.create(name: "b", parent: root) + it 'properly appends children with <<' do + root = Label.create(name: 'root') + a = Label.create(name: 'a', parent: root) + b = Label.create(name: 'b', parent: root) # Add a child to root at end of children. root.children << b @@ -433,10 +446,10 @@ def roots_name_and_order assert_equal [0, 1], root.children.collect(&:order_value) end - describe "#add_sibling" do - it "should move a node before another node which has an uninitialized order_value" do + describe '#add_sibling' do + it 'should move a node before another node which has an uninitialized order_value' do f = Label.find_or_create_by_path %w[a b c d e fa] - f0 = f.prepend_sibling(Label.new(name: "fb")) # < not alpha sort, so name shouldn't matter + f0 = f.prepend_sibling(Label.new(name: 'fb')) # < not alpha sort, so name shouldn't matter assert_equal %w[a b c d e fb], f0.ancestry_path assert_equal [f0], f.siblings_before.to_a assert f0.siblings_before.empty? @@ -447,20 +460,22 @@ def roots_name_and_order end before do + Label.delete_all + Label.hierarchy_class.delete_all @f1 = Label.find_or_create_by_path %w[a1 b1 c1 d1 e1 f1] end - it "should move a node to another tree" do + it 'should move a node to another tree' do f2 = Label.find_or_create_by_path %w[a2 b2 c2 d2 e2 f2] @f1.add_sibling(f2) assert_equal %w[a1 b1 c1 d1 e1 f2], f2.ancestry_path assert_equal [@f1, f2], @f1.parent.reload.children end - it "should reorder old-parent siblings when a node moves to another tree" do + it 'should reorder old-parent siblings when a node moves to another tree' do f2 = Label.find_or_create_by_path %w[a2 b2 c2 d2 e2 f2] - f3 = f2.prepend_sibling(Label.new(name: "f3")) - _f4 = f2.append_sibling(Label.new(name: "f4")) + f3 = f2.prepend_sibling(Label.new(name: 'f3')) + _f4 = f2.append_sibling(Label.new(name: 'f4')) @f1.add_sibling(f2) assert_equal [0, 1], @f1.self_and_siblings.collect(&:order_value) assert_equal [0, 1], f3.self_and_siblings.collect(&:order_value) @@ -469,61 +484,55 @@ def roots_name_and_order end end - describe "order_value must be set" do - describe "with normal model" do - CorrectOrderValue.shared_examples do - @model = Label - @expected_root_order_value = 0 - end + describe 'order_value must be set' do + describe 'with normal model' do + CorrectOrderValue.shared_examples(Label, 0) end - describe "without root ordering" do - CorrectOrderValue.shared_examples do - @model = LabelWithoutRootOrdering - @expected_root_order_value = nil - end + describe 'without root ordering' do + CorrectOrderValue.shared_examples(LabelWithoutRootOrdering, nil) end end - describe "destructive reordering" do + describe 'destructive reordering' do before do # to make sure order_value isn't affected by additional nodes: create_preorder_tree - @root = Label.create(name: "root") - @a = @root.children.create!(name: "a") - @b = @a.append_sibling(Label.new(name: "b")) - @c = @b.append_sibling(Label.new(name: "c")) + @root = Label.create(name: 'root') + @a = @root.children.create!(name: 'a') + @b = @a.append_sibling(Label.new(name: 'b')) + @c = @b.append_sibling(Label.new(name: 'c')) end describe "doesn't create sort order gaps" do - it "from head" do + it 'from head' do @a.destroy assert_equal [@b, @c], @root.reload.children - assert_equal([0, 1], @root.children.map { |ea| ea.order_value }) + assert_equal([0, 1], @root.children.map(&:order_value)) end - it "from mid" do + it 'from mid' do @b.destroy assert_equal [@a, @c], @root.reload.children - assert_equal([0, 1], @root.children.map { |ea| ea.order_value }) + assert_equal([0, 1], @root.children.map(&:order_value)) end - it "from tail" do + it 'from tail' do @c.destroy assert_equal [@a, @b], @root.reload.children - assert_equal([0, 1], @root.children.map { |ea| ea.order_value }) + assert_equal([0, 1], @root.children.map(&:order_value)) end end - describe "add_sibling moves descendant nodes" do + describe 'add_sibling moves descendant nodes' do before do @roots = (0..10).map { |ea| Label.create(name: ea) } @first_root = @roots.first @last_root = @roots.last end - it "should retain sort orders of descendants when moving to a new parent" do - expected_order = ("a".."z").to_a.shuffle + it 'should retain sort orders of descendants when moving to a new parent' do + expected_order = ('a'..'z').to_a.shuffle expected_order.map { |ea| @first_root.add_child(Label.new(name: ea)) } actual_order = @first_root.children.reload.pluck(:name) assert_equal expected_order, actual_order @@ -531,13 +540,13 @@ def roots_name_and_order assert_equal(%w[10 0] + expected_order, @last_root.self_and_descendants.pluck(:name)) end - it "should retain sort orders of descendants when moving within the same new parent" do - path = ("a".."z").to_a + it 'should retain sort orders of descendants when moving within the same new parent' do + path = ('a'..'z').to_a z = @first_root.find_or_create_by_path(path) - z_children_names = (100..150).to_a.shuffle.map { |ea| ea.to_s } + z_children_names = (100..150).to_a.shuffle.map(&:to_s) z_children_names.reverse_each { |ea| z.prepend_child(Label.new(name: ea)) } assert_equal z_children_names, z.children.reload.pluck(:name) - a = @first_root.find_by_path(["a"]) + a = @first_root.find_by_path(['a']) # move b up to a's level: b = a.children.first a.add_sibling(b) @@ -553,22 +562,22 @@ def roots_name_and_order end end - describe "descendent destruction" do - it "properly destroys descendents created with add_child" do - a = Label.create(name: "a") - b = Label.new(name: "b") + describe 'descendent destruction' do + it 'properly destroys descendents created with add_child' do + a = Label.create(name: 'a') + b = Label.new(name: 'b') a.add_child b - c = Label.new(name: "c") + c = Label.new(name: 'c') b.add_child c a.destroy refute Label.exists?(id: [a.id, b.id, c.id]) end - it "properly destroys descendents created with <<" do - a = Label.create(name: "a") - b = Label.new(name: "b") + it 'properly destroys descendents created with <<' do + a = Label.create(name: 'a') + b = Label.new(name: 'b') a.children << b - c = Label.new(name: "c") + c = Label.new(name: 'c') b.children << c a.destroy refute Label.exists?(id: [a.id, b.id, c.id]) @@ -576,94 +585,99 @@ def roots_name_and_order end unless sqlite? - describe "preorder" do - it "returns descendants in proper order" do + describe 'preorder' do + before do + Label.delete_all + Label.hierarchy_class.delete_all + end + + it 'returns descendants in proper order' do create_preorder_tree a = Label.root - assert_equal "a", a.name - expected = ("a".."r").to_a - assert_equal expected, a.self_and_descendants_preordered.collect { |ea| ea.name } - assert_equal expected, Label.roots_and_descendants_preordered.collect { |ea| ea.name } + assert_equal 'a', a.name + expected = ('a'..'r').to_a + assert_equal(expected, a.self_and_descendants_preordered.collect(&:name)) + assert_equal(expected, Label.roots_and_descendants_preordered.collect(&:name)) # Let's create the second root by hand so we can explicitly set the sort order Label.create! do |l| - l.name = "a1" + l.name = 'a1' l.order_value = a.order_value + 1 end - create_preorder_tree("1") + create_preorder_tree('1') # Should be no change: - assert_equal expected, a.reload.self_and_descendants_preordered.collect { |ea| ea.name } - expected += ("a".."r").collect { |ea| "#{ea}1" } - assert_equal expected, Label.roots_and_descendants_preordered.collect { |ea| ea.name } + assert_equal(expected, a.reload.self_and_descendants_preordered.collect(&:name)) + expected += ('a'..'r').collect { |ea| "#{ea}1" } + assert_equal(expected, Label.roots_and_descendants_preordered.collect(&:name)) end end end - describe "hash_tree" do + describe 'hash_tree' do before do - @a = EventLabel.create(name: "a") - @b = DateLabel.create(name: "b") - @c = DirectoryLabel.create(name: "c") + @a = EventLabel.create(name: 'a') + @b = DateLabel.create(name: 'b') + @c = DirectoryLabel.create(name: 'c') (1..3).each { |i| DirectoryLabel.create!(name: "c#{i}", mother_id: @c.id) } end - it "should return tree with correct scope when called on class" do + it 'should return tree with correct scope when called on class' do tree = DirectoryLabel.hash_tree assert_equal 1, tree.keys.size assert_equal @c, tree.keys.first assert_equal 3, tree[@c].keys.size end - it "should return tree with correct scope when called on all" do + it 'should return tree with correct scope when called on all' do tree = DirectoryLabel.all.hash_tree assert_equal 1, tree.keys.size assert_equal @c, tree.keys.first assert_equal 3, tree[@c].keys.size end - it "should return tree with correct scope when called on scope chain" do - tree = Label.where(name: "b").hash_tree + it 'should return tree with correct scope when called on scope chain' do + tree = Label.where(name: 'b').hash_tree assert_equal 1, tree.keys.size assert_equal @b, tree.keys.first assert_equal({}, tree[@b]) end end - describe "relationship between nodes" do + describe 'relationship between nodes' do before do create_label_tree end - it "checks parent of node" do + it 'checks parent of node' do assert @a1.parent_of?(@b1) assert @c2.parent_of?(@d2) refute @c1.parent_of?(@b1) end - it "checks children of node" do + it 'checks children of node' do assert @d1.child_of?(@c1) assert @c2.child_of?(@b1) refute @c3.child_of?(@b1) end - it "checks root of node" do + it 'checks root of node' do assert @a1.root_of?(@d1) assert @a1.root_of?(@c2) refute @a2.root_of?(@c2) end - it "checks ancestor of node" do + it 'checks ancestor of node' do assert @a1.ancestor_of?(@d1) assert @b1.ancestor_of?(@d1) refute @b1.ancestor_of?(@c3) end - it "checks descendant of node" do + it 'checks descendant of node' do assert @c1.descendant_of?(@a1) assert @d2.descendant_of?(@a1) refute @b1.descendant_of?(@a2) end - it "checks descendant of node" do + it 'checks descendant of node' do assert @b1.family_of?(@b1) assert @a1.family_of?(@c1) assert @d3.family_of?(@a2) diff --git a/test/closure_tree/matcher_test.rb b/test/closure_tree/matcher_test.rb index ec11ea71..21809088 100644 --- a/test/closure_tree/matcher_test.rb +++ b/test/closure_tree/matcher_test.rb @@ -1,19 +1,21 @@ +# frozen_string_literal: true + require 'test_helper' require 'closure_tree/test/matcher' class MatcherTest < ActiveSupport::TestCase include ClosureTree::Test::Matcher - + setup do ENV['FLOCK_DIR'] = Dir.mktmpdir end teardown do - FileUtils.remove_entry_secure ENV['FLOCK_DIR'] + FileUtils.remove_entry_secure ENV.fetch('FLOCK_DIR', nil) end - test "be_a_closure_tree matcher" do - assert_closure_tree UUIDTag + test 'be_a_closure_tree matcher' do + assert_closure_tree UuidTag assert_closure_tree User assert_closure_tree Label, ordered: true assert_closure_tree Metal, ordered: :sort_order @@ -21,13 +23,13 @@ class MatcherTest < ActiveSupport::TestCase refute_closure_tree Contract end - test "ordered option" do + test 'ordered option' do assert_closure_tree Label, ordered: true - assert_closure_tree UUIDTag, ordered: true + assert_closure_tree UuidTag, ordered: true assert_closure_tree Metal, ordered: :sort_order end - test "advisory_lock option" do + test 'advisory_lock option' do # SQLite doesn't support advisory locks, so skip these tests when using SQLite if ActiveRecord::Base.connection.adapter_name.downcase.include?('sqlite') skip "SQLite doesn't support advisory locks" @@ -38,7 +40,7 @@ class MatcherTest < ActiveSupport::TestCase end end - test "without_advisory_lock option" do + test 'without_advisory_lock option' do assert_closure_tree MenuItem, with_advisory_lock: false end @@ -46,33 +48,33 @@ class MatcherTest < ActiveSupport::TestCase def assert_closure_tree(model, options = {}) assert model.is_a?(Class), "Expected #{model} to be a Class" - assert model.respond_to?(:_ct), + assert model.respond_to?(:_ct), "Expected #{model} to have closure_tree enabled" - + if options[:ordered] order_column = options[:ordered] == true ? :sort_order : options[:ordered] - assert model._ct.options[:order], + assert model._ct.options[:order], "Expected #{model} to be ordered" if order_column != true && order_column != :sort_order - assert_equal order_column.to_s, model._ct.options[:order], + assert_equal order_column.to_s, model._ct.options[:order], "Expected #{model} to be ordered by #{order_column}" end end - - if options.key?(:with_advisory_lock) - expected = options[:with_advisory_lock] - actual = model._ct.options[:with_advisory_lock] - if expected - assert actual, "Expected #{model} to have advisory lock" - else - refute actual, "Expected #{model} not to have advisory lock" - end + + return unless options.key?(:with_advisory_lock) + + expected = options[:with_advisory_lock] + actual = model._ct.options[:with_advisory_lock] + if expected + assert actual, "Expected #{model} to have advisory lock" + else + refute actual, "Expected #{model} not to have advisory lock" end end - + def refute_closure_tree(model) assert model.is_a?(Class), "Expected #{model} to be a Class" - refute model.respond_to?(:_ct), + refute model.respond_to?(:_ct), "Expected #{model} not to have closure_tree enabled" end -end \ No newline at end of file +end diff --git a/test/closure_tree/metal_test.rb b/test/closure_tree/metal_test.rb index 3a52adf1..c84038f1 100644 --- a/test/closure_tree/metal_test.rb +++ b/test/closure_tree/metal_test.rb @@ -25,15 +25,6 @@ def assert_correctness(grandchild) ] end - if false - before do - # ensure the correct root is used in find_or_create_by_path: - [Metal, Adamantium, Unobtanium].each do |metal| - metal.find_or_create_by_path(%w[parent child grandchild]) - end - end - end - it 'creates children from the proper root' do assert_correctness(Metal.find_or_create_by_path(attr_path)) end diff --git a/test/closure_tree/multi_database_test.rb b/test/closure_tree/multi_database_test.rb new file mode 100644 index 00000000..3883a5dc --- /dev/null +++ b/test/closure_tree/multi_database_test.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'test_helper' + +class MultiDatabaseTest < ActiveSupport::TestCase + def setup + super + # Create memory tables - always recreate for clean state + SqliteRecord.connection.create_table :memory_tags, force: true do |t| + t.string :name + t.integer :parent_id + t.timestamps + end + + SqliteRecord.connection.create_table :memory_tag_hierarchies, id: false, force: true do |t| + t.integer :ancestor_id, null: false + t.integer :descendant_id, null: false + t.integer :generations, null: false + end + + SqliteRecord.connection.add_index :memory_tag_hierarchies, %i[ancestor_id descendant_id generations], + unique: true, name: 'memory_tag_anc_desc_idx' + SqliteRecord.connection.add_index :memory_tag_hierarchies, [:descendant_id], name: 'memory_tag_desc_idx' + end + + def teardown + # Clean up SQLite tables after each test + SqliteRecord.connection.drop_table :memory_tag_hierarchies, if_exists: true + SqliteRecord.connection.drop_table :memory_tags, if_exists: true + super + end + + def test_postgresql_with_advisory_lock + skip 'PostgreSQL not configured' unless postgresql?(ApplicationRecord.connection) + + tag = Tag.create!(name: 'PostgreSQL Root') + child = nil + + # Advisory locks should work on PostgreSQL + Tag.with_advisory_lock('test_lock') do + child = tag.children.create!(name: 'PostgreSQL Child') + end + + assert_equal tag, child.parent + assert tag.descendants.include?(child) + end + + def test_mysql_with_advisory_lock + skip 'MySQL not configured' unless mysql?(MysqlRecord.connection) + + tag = MysqlTag.create!(name: 'MySQL Root') + child = nil + + # Advisory locks should work on MySQL + MysqlTag.with_advisory_lock('test_lock') do + child = tag.children.create!(name: 'MySQL Child') + end + + assert_equal tag, child.parent + assert tag.descendants.include?(child) + end + + def test_sqlite_without_advisory_lock + tag = MemoryTag.create!(name: 'SQLite Root') + + # Advisory locks should be disabled for SQLite but operations should still work + # Closure tree internally handles the lack of advisory locks + child = tag.children.create!(name: 'SQLite Child') + + assert_equal tag, child.parent + assert tag.descendants.include?(child) + end + + def test_concurrent_operations_different_databases + # Create roots in different databases + pg_tag = nil + if postgresql?(ApplicationRecord.connection) + pg_tag = Tag.create!(name: 'PG Root') + # Create child directly, not in a thread, to avoid database cleaner issues + pg_tag.children.create!(name: 'PG Child 1') + end + + mysql_tag = MysqlTag.create!(name: 'MySQL Root') + sqlite_tag = MemoryTag.create!(name: 'SQLite Root') + + # Test concurrent operations only for MySQL and SQLite + threads = [] + + threads << Thread.new do + MysqlRecord.connection_pool.with_connection do + tag = MysqlTag.find(mysql_tag.id) + tag.children.create!(name: 'MySQL Child 1') + end + end + + threads << Thread.new do + SqliteRecord.connection_pool.with_connection do + tag = MemoryTag.find(sqlite_tag.id) + tag.children.create!(name: 'SQLite Child 1') + end + end + + threads.each(&:join) + + # Verify all children were created + assert_equal 1, pg_tag.reload.children.count if pg_tag + assert_equal 1, mysql_tag.reload.children.count + assert_equal 1, sqlite_tag.reload.children.count + end +end diff --git a/test/closure_tree/parallel_test.rb b/test/closure_tree/parallel_test.rb index 3d6202b3..6faeeab0 100644 --- a/test/closure_tree/parallel_test.rb +++ b/test/closure_tree/parallel_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "test_helper" +require 'test_helper' # We don't need to run the expensive parallel tests for every combination of prefix/suffix. # Those affect SQL generation, not parallelism. @@ -22,7 +22,7 @@ class WorkerBase def_delegators :@thread, :join, :wakeup, :status, :to_s def log(msg) - puts("#{Thread.current}: #{msg}") if ENV["VERBOSE"] + puts("#{Thread.current}: #{msg}") if ENV['VERBOSE'] end def initialize(target, name) @@ -30,11 +30,11 @@ def initialize(target, name) @name = name @thread = Thread.new do ActiveRecord::Base.connection_pool.with_connection { before_work } if respond_to? :before_work - log "going to sleep..." + log 'going to sleep...' sleep - log "woke up..." + log 'woke up...' ActiveRecord::Base.connection_pool.with_connection { work } - log "done." + log 'done.' end end end @@ -59,14 +59,28 @@ def work end end -describe "Concurrent creation" do +describe 'Concurrent creation' do before do @target = nil @iterations = 5 + + # Clean up SQLite database file if it exists + db_file = 'test/dummy/db/test.sqlite3' + if File.exist?(db_file) + SqliteRecord.connection.disconnect! + File.delete(db_file) + SqliteRecord.connection.reconnect! + end + Tag.delete_all + Tag.hierarchy_class.delete_all + User.delete_all + User.hierarchy_class.delete_all + Label.delete_all + Label.hierarchy_class.delete_all end def log(msg) - puts(msg) if ENV["VERBOSE"] + puts(msg) if ENV['VERBOSE'] end def run_workers(worker_class = FindOrCreateWorker) @@ -74,53 +88,53 @@ def run_workers(worker_class = FindOrCreateWorker) @names.each do |name| workers = max_threads.times.map { worker_class.new(@target, name) } # Wait for all the threads to get ready: - while true - unready_workers = workers.select { |ea| ea.status != "sleep" } - if unready_workers.empty? - break - else - log "Not ready to wakeup: #{unready_workers.map { |ea| [ea.to_s, ea.status] }}" - sleep(0.1) - end + loop do + unready_workers = workers.reject { |ea| ea.status == 'sleep' } + break if unready_workers.empty? + + log "Not ready to wakeup: #{unready_workers.map { |ea| [ea.to_s, ea.status] }}" + sleep(0.1) end sleep(0.25) # OK, GO! - log "Calling .wakeup on all workers..." + log 'Calling .wakeup on all workers...' workers.each(&:wakeup) sleep(0.25) # Then wait for them to finish: - log "Calling .join on all workers..." + log 'Calling .join on all workers...' workers.each(&:join) end # Ensure we're still connected: - ActiveRecord::Base.connection_pool.connection + ActiveRecord::Base.connection_pool.with_connection do |connection| + connection.execute('SELECT 1') + end end - it "will not create dupes from class methods" do - skip("unsupported") unless run_parallel_tests? + it 'will not create dupes from class methods' do + skip('unsupported') unless run_parallel_tests? run_workers - assert_equal @names.sort, Tag.roots.collect { |ea| ea.name }.sort + assert_equal @names.sort, Tag.roots.collect(&:name).sort # No dupe children: %w[a b c].each do |ea| assert_equal @iterations, Tag.where(name: ea).size end end - it "will not create dupes from instance methods" do - skip("unsupported") unless run_parallel_tests? + it 'will not create dupes from instance methods' do + skip('unsupported') unless run_parallel_tests? - @target = Tag.create!(name: "root") + @target = Tag.create!(name: 'root') run_workers - assert_equal @names.sort, @target.reload.children.collect { |ea| ea.name }.sort + assert_equal @names.sort, @target.reload.children.collect(&:name).sort assert_equal @iterations, Tag.where(name: @names).size %w[a b c].each do |ea| assert_equal @iterations, Tag.where(name: ea).size end end - it "creates dupe roots without advisory locks" do - skip("unsupported") unless run_parallel_tests? + it 'creates dupe roots without advisory locks' do + skip('unsupported') unless run_parallel_tests? # disable with_advisory_lock: Tag.stub(:with_advisory_lock, ->(_lock_name, &block) { block.call }) do @@ -130,16 +144,18 @@ def run_workers(worker_class = FindOrCreateWorker) end end - it "fails to deadlock while simultaneously deleting items from the same hierarchy" do - skip("unsupported") unless run_parallel_tests? + it 'fails to deadlock while simultaneously deleting items from the same hierarchy' do + skip('unsupported') unless run_parallel_tests? - target = User.find_or_create_by_path((1..200).to_a.map { |ea| ea.to_s }) + target = User.find_or_create_by_path((1..200).to_a.map(&:to_s)) emails = target.self_and_ancestors.to_a.map(&:email).shuffle - Parallel.map(emails, in_threads: max_threads) do |email| - ActiveRecord::Base.connection_pool.with_connection do - User.transaction do - log "Destroying #{email}..." - User.where(email: email).destroy_all + User.stub(:rebuild!, -> {}) do + Parallel.map(emails, in_threads: max_threads) do |email| + ActiveRecord::Base.connection_pool.with_connection do + User.transaction do + log "Destroying #{email}..." + User.where(email: email).destroy_all + end end end end @@ -147,16 +163,16 @@ def run_workers(worker_class = FindOrCreateWorker) assert User.all.empty? end - it "fails to deadlock from prepending siblings" do - skip("unsupported") unless run_parallel_tests? + it 'fails to deadlock from prepending siblings' do + skip('unsupported') unless run_parallel_tests? @target = Label.find_or_create_by_path %w[root parent] run_workers(SiblingPrependerWorker) children = Label.roots - uniq_order_values = children.collect { |ea| ea.order_value }.uniq + uniq_order_values = children.collect(&:order_value).uniq assert_equal uniq_order_values.size, children.size # The only non-root node should be "root": - assert_equal([@target.parent], Label.all.select { |ea| ea.root? }) + assert_equal([@target.parent], Label.all.select(&:root?)) end end diff --git a/test/closure_tree/tag_test.rb b/test/closure_tree/tag_test.rb index 62fde579..0625f0ab 100644 --- a/test/closure_tree/tag_test.rb +++ b/test/closure_tree/tag_test.rb @@ -3,6 +3,7 @@ require 'test_helper' require 'support/tag_examples' -describe Tag do +class TagTest < ActiveSupport::TestCase + TAG_CLASS = Tag include TagExamples end diff --git a/test/closure_tree/user_test.rb b/test/closure_tree/user_test.rb index 5522cf17..4291fcb5 100644 --- a/test/closure_tree/user_test.rb +++ b/test/closure_tree/user_test.rb @@ -1,86 +1,91 @@ # frozen_string_literal: true -require "test_helper" +require 'test_helper' -describe "empty db" do - describe "empty db" do - it "should return no entities" do +describe 'empty db' do + before do + User.delete_all + User.hierarchy_class.delete_all + end + + describe 'empty db' do + it 'should return no entities' do assert User.roots.empty? assert User.leaves.empty? end end - describe "1 user db" do - it "should return the only entity as a root and leaf" do - a = User.create!(email: "me@domain.com") + describe '1 user db' do + it 'should return the only entity as a root and leaf' do + a = User.create!(email: 'me@domain.com') assert_equal [a], User.roots assert_equal [a], User.leaves end end - describe "2 user db" do - it "should return a simple root and leaf" do - root = User.create!(email: "first@t.co") - leaf = root.children.create!(email: "second@t.co") + describe '2 user db' do + it 'should return a simple root and leaf' do + root = User.create!(email: 'first@t.co') + leaf = root.children.create!(email: 'second@t.co') assert_equal [root], User.roots assert_equal [leaf], User.leaves end end - describe "3 User collection.create db" do + describe '3 User collection.create db' do before do - @root = User.create! email: "poppy@t.co" - @mid = @root.children.create! email: "matt@t.co" - @leaf = @mid.children.create! email: "james@t.co" + @root = User.create! email: 'poppy@t.co' + @mid = @root.children.create! email: 'matt@t.co' + @leaf = @mid.children.create! email: 'james@t.co' @root_id = @root.id end - it "should create all Users" do + it 'should create all Users' do assert_equal [@root, @mid, @leaf], User.all.to_a.sort end - it "orders self_and_ancestor_ids nearest generation first" do + it 'orders self_and_ancestor_ids nearest generation first' do assert_equal [@leaf.id, @mid.id, @root.id], @leaf.self_and_ancestor_ids end - it "orders self_and_descendant_ids nearest generation first" do + it 'orders self_and_descendant_ids nearest generation first' do assert_equal [@root.id, @mid.id, @leaf.id], @root.self_and_descendant_ids end - it "should have children" do + it 'should have children' do assert_equal [@mid], @root.children.to_a assert_equal [@leaf], @mid.children.to_a assert_equal [], @leaf.children.to_a end - it "roots should have children" do + it 'roots should have children' do assert_equal [@mid], User.roots.first.children.to_a end - it "should return a root and leaf without middle User" do + it 'should return a root and leaf without middle User' do assert_equal [@root], User.roots.to_a assert_equal [@leaf], User.leaves.to_a end - it "should delete leaves" do + it 'should delete leaves' do User.leaves.destroy_all assert_equal [@root], User.roots.to_a # untouched assert_equal [@mid], User.leaves.to_a end - it "should delete roots and maintain hierarchies" do + it 'should delete roots and maintain hierarchies' do User.roots.destroy_all assert_mid_and_leaf_remain end - it "should root all children" do + it 'should root all children' do @root.destroy assert_mid_and_leaf_remain end def assert_mid_and_leaf_remain - assert ReferralHierarchy.where(ancestor_id: @root_id).empty? - assert ReferralHierarchy.where(descendant_id: @root_id).empty? + assert User.hierarchy_class.where(ancestor_id: @root_id).empty? + assert User.hierarchy_class.where(descendant_id: @root_id).empty? assert_equal %w[matt@t.co], @mid.ancestry_path assert_equal %w[matt@t.co james@t.co], @leaf.ancestry_path assert_equal [@mid, @leaf].sort, @mid.self_and_descendants.to_a.sort @@ -89,7 +94,7 @@ def assert_mid_and_leaf_remain end end - it "supports users with contracts" do + it 'supports users with contracts' do u = User.find_or_create_by_path(%w[a@t.co b@t.co c@t.co]) assert_equal [], u.descendant_ids assert_equal [u.parent.id, u.root.id], u.ancestor_ids @@ -102,9 +107,9 @@ def assert_mid_and_leaf_remain assert_equal [c1, c2].sort, u.root.indirect_contracts.to_a.sort end - it "supports << on shallow unsaved hierarchies" do - a = User.new(email: "a") - b = User.new(email: "b") + it 'supports << on shallow unsaved hierarchies' do + a = User.new(email: 'a') + b = User.new(email: 'b') a.children << b a.save assert_equal [a], User.roots @@ -112,17 +117,17 @@ def assert_mid_and_leaf_remain assert_equal %w[a b], b.ancestry_path end - it "supports << on deep unsaved hierarchies" do - a = User.new(email: "a") - b1 = User.new(email: "b1") + it 'supports << on deep unsaved hierarchies' do + a = User.new(email: 'a') + b1 = User.new(email: 'b1') a.children << b1 - b2 = User.new(email: "b2") + b2 = User.new(email: 'b2') a.children << b2 - c1 = User.new(email: "c1") + c1 = User.new(email: 'c1') b2.children << c1 - c2 = User.new(email: "c2") + c2 = User.new(email: 'c2') b2.children << c2 - d = User.new(email: "d") + d = User.new(email: 'd') c2.children << d a.save @@ -131,29 +136,29 @@ def assert_mid_and_leaf_remain assert_equal %w[a b2 c2 d], d.ancestry_path end - it "supports siblings" do + it 'supports siblings' do refute User._ct.order_option? - a = User.create(email: "a") - b1 = a.children.create(email: "b1") - b2 = a.children.create(email: "b2") - b3 = a.children.create(email: "b3") + a = User.create(email: 'a') + b1 = a.children.create(email: 'b1') + b2 = a.children.create(email: 'b2') + b3 = a.children.create(email: 'b3') assert a.siblings.empty? assert_equal [b2, b3].sort, b1.siblings.to_a.sort end - describe "when a user is not yet saved" do - it "supports siblings" do + describe 'when a user is not yet saved' do + it 'supports siblings' do refute User._ct.order_option? - a = User.create(email: "a") - b1 = a.children.new(email: "b1") - b2 = a.children.create(email: "b2") - b3 = a.children.create(email: "b3") + a = User.create(email: 'a') + b1 = a.children.new(email: 'b1') + b2 = a.children.create(email: 'b2') + b3 = a.children.create(email: 'b3') assert a.siblings.empty? assert_equal [b2, b3].sort, b1.siblings.to_a.sort end end - it "properly nullifies descendents" do + it 'properly nullifies descendents' do c = User.find_or_create_by_path %w[a b c] b = c.parent c.root.destroy @@ -161,15 +166,15 @@ def assert_mid_and_leaf_remain assert_equal [c.id], b.child_ids end - describe "roots" do - it "works on models without ordering" do - expected = ("a".."z").to_a + describe 'roots' do + it 'works on models without ordering' do + expected = ('a'..'z').to_a expected.shuffle.each do |ea| User.create! do |u| u.email = ea end end - assert_equal(expected, User.roots.collect { |ea| ea.email }.sort) + assert_equal(expected, User.roots.collect(&:email).sort) end end end diff --git a/test/closure_tree/uuid_tag_test.rb b/test/closure_tree/uuid_tag_test.rb index e2f2f276..d1c6b4bc 100644 --- a/test/closure_tree/uuid_tag_test.rb +++ b/test/closure_tree/uuid_tag_test.rb @@ -3,6 +3,7 @@ require 'test_helper' require 'support/tag_examples' -describe UUIDTag do +describe UuidTag do + TAG_CLASS = UuidTag include TagExamples end diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile new file mode 100644 index 00000000..488c551f --- /dev/null +++ b/test/dummy/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative 'config/application' + +Rails.application.load_tasks diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb new file mode 100644 index 00000000..1ff0944d --- /dev/null +++ b/test/dummy/app/controllers/application_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base + # Prevent CSRF attacks by raising an exception. + # For APIs, you may want to use :null_session instead. + protect_from_forgery with: :exception +end diff --git a/test/dummy/app/models/adamantium.rb b/test/dummy/app/models/adamantium.rb new file mode 100644 index 00000000..6f743cc9 --- /dev/null +++ b/test/dummy/app/models/adamantium.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Adamantium < Metal +end diff --git a/test/dummy/app/models/application_record.rb b/test/dummy/app/models/application_record.rb new file mode 100644 index 00000000..848e8e7b --- /dev/null +++ b/test/dummy/app/models/application_record.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true + establish_connection(:primary) +end diff --git a/test/dummy/app/models/contract.rb b/test/dummy/app/models/contract.rb new file mode 100644 index 00000000..fa8f0b53 --- /dev/null +++ b/test/dummy/app/models/contract.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Contract < ApplicationRecord + belongs_to :user, inverse_of: :contracts + belongs_to :contract_type, inverse_of: :contracts, optional: true +end diff --git a/test/dummy/app/models/contract_type.rb b/test/dummy/app/models/contract_type.rb new file mode 100644 index 00000000..a33e93ac --- /dev/null +++ b/test/dummy/app/models/contract_type.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ContractType < ApplicationRecord + has_many :contracts, inverse_of: :contract_type +end diff --git a/test/dummy/app/models/cuisine_type.rb b/test/dummy/app/models/cuisine_type.rb new file mode 100644 index 00000000..bb055485 --- /dev/null +++ b/test/dummy/app/models/cuisine_type.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CuisineType < ApplicationRecord + acts_as_tree +end diff --git a/test/dummy/app/models/date_label.rb b/test/dummy/app/models/date_label.rb new file mode 100644 index 00000000..57db7adf --- /dev/null +++ b/test/dummy/app/models/date_label.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class DateLabel < Label +end diff --git a/test/dummy/app/models/destroyed_tag.rb b/test/dummy/app/models/destroyed_tag.rb new file mode 100644 index 00000000..3388efa0 --- /dev/null +++ b/test/dummy/app/models/destroyed_tag.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class DestroyedTag < ApplicationRecord +end diff --git a/test/dummy/app/models/directory_label.rb b/test/dummy/app/models/directory_label.rb new file mode 100644 index 00000000..8198ed6a --- /dev/null +++ b/test/dummy/app/models/directory_label.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class DirectoryLabel < Label +end diff --git a/test/dummy/app/models/event_label.rb b/test/dummy/app/models/event_label.rb new file mode 100644 index 00000000..741eda5f --- /dev/null +++ b/test/dummy/app/models/event_label.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class EventLabel < Label +end diff --git a/test/dummy/app/models/group.rb b/test/dummy/app/models/group.rb new file mode 100644 index 00000000..ccf20c37 --- /dev/null +++ b/test/dummy/app/models/group.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Group < ApplicationRecord + has_closure_tree_root :root_user +end diff --git a/test/dummy/app/models/grouping.rb b/test/dummy/app/models/grouping.rb new file mode 100644 index 00000000..555f89de --- /dev/null +++ b/test/dummy/app/models/grouping.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Grouping < ApplicationRecord + has_closure_tree_root :root_person, class_name: 'User', foreign_key: :group_id +end diff --git a/test/dummy/app/models/label.rb b/test/dummy/app/models/label.rb new file mode 100644 index 00000000..a59f0c12 --- /dev/null +++ b/test/dummy/app/models/label.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Label < ApplicationRecord + # make sure order doesn't matter + acts_as_tree order: :column_whereby_ordering_is_inferred, # <- symbol, and not "sort_order" + numeric_order: true, + parent_column_name: 'mother_id', + dependent: :destroy + + def to_s + "#{self.class}: #{name}" + end +end diff --git a/test/dummy/app/models/label_without_root_ordering.rb b/test/dummy/app/models/label_without_root_ordering.rb new file mode 100644 index 00000000..6e5773d6 --- /dev/null +++ b/test/dummy/app/models/label_without_root_ordering.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class LabelWithoutRootOrdering < ActiveRecord::Base + self.table_name = 'labels' + has_closure_tree parent_column_name: 'mother_id', + name_column: 'name', + order: 'column_whereby_ordering_is_inferred', + numeric_order: true, + dont_order_roots: true +end diff --git a/test/dummy/app/models/memory_tag.rb b/test/dummy/app/models/memory_tag.rb new file mode 100644 index 00000000..c8d39441 --- /dev/null +++ b/test/dummy/app/models/memory_tag.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class MemoryTag < SqliteRecord + has_closure_tree +end diff --git a/test/dummy/app/models/menu_item.rb b/test/dummy/app/models/menu_item.rb new file mode 100644 index 00000000..8dac6448 --- /dev/null +++ b/test/dummy/app/models/menu_item.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class MenuItem < ApplicationRecord + has_closure_tree touch: true, with_advisory_lock: false +end diff --git a/test/dummy/app/models/metal.rb b/test/dummy/app/models/metal.rb new file mode 100644 index 00000000..7d477f56 --- /dev/null +++ b/test/dummy/app/models/metal.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Metal < ApplicationRecord + self.table_name = "#{table_name_prefix}metal#{table_name_suffix}" + has_closure_tree order: 'sort_order', name_column: 'value' + self.inheritance_column = 'metal_type' +end diff --git a/test/dummy/app/models/mysql_record.rb b/test/dummy/app/models/mysql_record.rb new file mode 100644 index 00000000..152df7c0 --- /dev/null +++ b/test/dummy/app/models/mysql_record.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class MysqlRecord < ApplicationRecord + self.abstract_class = true + connects_to database: { writing: :secondary, reading: :secondary } +end diff --git a/test/dummy/app/models/mysql_tag.rb b/test/dummy/app/models/mysql_tag.rb new file mode 100644 index 00000000..a38ebf37 --- /dev/null +++ b/test/dummy/app/models/mysql_tag.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class MysqlTag < MysqlRecord + has_closure_tree +end diff --git a/test/dummy/app/models/namespace.rb b/test/dummy/app/models/namespace.rb new file mode 100644 index 00000000..b7c05d56 --- /dev/null +++ b/test/dummy/app/models/namespace.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Namespace + def self.table_name_prefix + 'namespace_' + end +end diff --git a/test/dummy/app/models/namespace/type.rb b/test/dummy/app/models/namespace/type.rb new file mode 100644 index 00000000..36b326a0 --- /dev/null +++ b/test/dummy/app/models/namespace/type.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Namespace + class Type < ApplicationRecord + has_closure_tree dependent: :destroy + end +end diff --git a/test/dummy/app/models/sqlite_record.rb b/test/dummy/app/models/sqlite_record.rb new file mode 100644 index 00000000..b508a71f --- /dev/null +++ b/test/dummy/app/models/sqlite_record.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class SqliteRecord < ApplicationRecord + self.abstract_class = true + connects_to database: { writing: :sqlite, reading: :sqlite } +end diff --git a/test/dummy/app/models/tag.rb b/test/dummy/app/models/tag.rb new file mode 100644 index 00000000..c59dea92 --- /dev/null +++ b/test/dummy/app/models/tag.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Tag < ApplicationRecord + has_closure_tree dependent: :destroy, order: :name + before_destroy :add_destroyed_tag + + def to_s + name + end + + def add_destroyed_tag + # Proof for the tests that the destroy rather than the delete method was called: + DestroyedTag.create(name: to_s) + end +end diff --git a/test/dummy/app/models/team.rb b/test/dummy/app/models/team.rb new file mode 100644 index 00000000..0a336efd --- /dev/null +++ b/test/dummy/app/models/team.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Team < ApplicationRecord + has_closure_tree_root :root_user, class_name: 'User', foreign_key: :grp_id +end diff --git a/test/dummy/app/models/unobtanium.rb b/test/dummy/app/models/unobtanium.rb new file mode 100644 index 00000000..c5ae3133 --- /dev/null +++ b/test/dummy/app/models/unobtanium.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Unobtanium < Metal +end diff --git a/test/dummy/app/models/user.rb b/test/dummy/app/models/user.rb new file mode 100644 index 00000000..6fda0b26 --- /dev/null +++ b/test/dummy/app/models/user.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class User < ApplicationRecord + acts_as_tree parent_column_name: 'referrer_id', + name_column: 'email', + hierarchy_class_name: 'ReferralHierarchy', + hierarchy_table_name: 'referral_hierarchies' + + has_many :contracts, inverse_of: :user + belongs_to :group, optional: true + + def indirect_contracts + Contract.where(user_id: descendant_ids) + end + + def to_s + email + end +end diff --git a/test/dummy/app/models/user_set.rb b/test/dummy/app/models/user_set.rb new file mode 100644 index 00000000..33720b8e --- /dev/null +++ b/test/dummy/app/models/user_set.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class UserSet < ApplicationRecord + has_closure_tree_root :root_user, class_name: 'User' +end diff --git a/test/dummy/app/models/uuid_tag.rb b/test/dummy/app/models/uuid_tag.rb new file mode 100644 index 00000000..246cfc58 --- /dev/null +++ b/test/dummy/app/models/uuid_tag.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class UuidTag < ApplicationRecord + self.primary_key = :uuid + before_create :set_uuid + has_closure_tree dependent: :destroy, order: 'name', parent_column_name: 'parent_uuid' + before_destroy :add_destroyed_tag + + def set_uuid + self.uuid = SecureRandom.uuid + end + + def to_s + name + end + + def add_destroyed_tag + # Proof for the tests that the destroy rather than the delete method was called: + DestroyedTag.create(name: to_s) + end +end diff --git a/test/dummy/config.ru b/test/dummy/config.ru new file mode 100644 index 00000000..5df2ee2f --- /dev/null +++ b/test/dummy/config.ru @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require_relative 'config/environment' + +run Rails.application +Rails.application.load_server diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb new file mode 100644 index 00000000..c5b54a9e --- /dev/null +++ b/test/dummy/config/application.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require File.expand_path('boot', __dir__) + +require 'rails' +require 'active_model/railtie' +require 'active_record/railtie' + +Bundler.require(*Rails.groups) +require 'closure_tree' + +module Dummy + class Application < Rails::Application + config.load_defaults [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join('.') + config.eager_load = false + + # Test environment settings + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.action_dispatch.show_exceptions = false + end +end diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb new file mode 100644 index 00000000..50c2bdf4 --- /dev/null +++ b/test/dummy/config/boot.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml new file mode 100644 index 00000000..ce2725a8 --- /dev/null +++ b/test/dummy/config/database.yml @@ -0,0 +1,17 @@ +default: &default + pool: 50 + timeout: 5000 + +test: + primary: + <<: *default + url: "<%= ENV['DATABASE_URL_PG'] || 'postgresql://closure_tree:closure_tree_pass@127.0.0.1:5434/closure_tree_test' %>" + secondary: + <<: *default + url: "<%= ENV['DATABASE_URL_MYSQL'] || 'mysql2://closure_tree:closure_tree_pass@127.0.0.1:3367/closure_tree_test' %>" + properties: + allowPublicKeyRetrieval: true + sqlite: + <<: *default + adapter: sqlite3 + database: "test/dummy/db/test.sqlite3" \ No newline at end of file diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb new file mode 100644 index 00000000..d5abe558 --- /dev/null +++ b/test/dummy/config/environment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load the Rails application. +require_relative 'application' + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb new file mode 100644 index 00000000..7191cbdf --- /dev/null +++ b/test/dummy/config/environments/test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.cache_classes = true + config.eager_load = false + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.action_dispatch.show_exceptions = false + config.active_support.deprecation = :stderr + config.active_support.test_order = :random + config.active_record.maintain_test_schema = false +end diff --git a/test/dummy/db/mysql_schema.rb b/test/dummy/db/mysql_schema.rb new file mode 100644 index 00000000..a887b1a1 --- /dev/null +++ b/test/dummy/db/mysql_schema.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +ActiveRecord::Schema.define(version: 1) do + create_table 'mysql_tags', force: true do |t| + t.string 'name' + end + + create_table 'mysql_tag_audits', id: false, force: true do |t| + t.string 'tag_name' + end + + create_table 'mysql_labels', id: false, force: true do |t| + t.string 'name' + end +end diff --git a/test/support/schema.rb b/test/dummy/db/schema.rb similarity index 79% rename from test/support/schema.rb rename to test/dummy/db/schema.rb index cca75c50..6994077c 100644 --- a/test/support/schema.rb +++ b/test/dummy/db/schema.rb @@ -154,4 +154,37 @@ add_foreign_key(:menu_item_hierarchies, :menu_items, column: 'descendant_id', on_delete: :cascade) add_foreign_key(:tag_hierarchies, :tags, column: 'ancestor_id', on_delete: :cascade) add_foreign_key(:tag_hierarchies, :tags, column: 'descendant_id', on_delete: :cascade) + + # Multi-database test models + create_table 'mysql_tags' do |t| + t.string 'name' + t.references 'parent' + t.timestamps null: false + end + + create_table 'mysql_tag_hierarchies', id: false do |t| + t.references 'ancestor', null: false + t.references 'descendant', null: false + t.integer 'generations', null: false + end + + add_index 'mysql_tag_hierarchies', %i[ancestor_id descendant_id generations], unique: true, + name: 'mysql_tag_anc_desc_idx' + add_index 'mysql_tag_hierarchies', [:descendant_id], name: 'mysql_tag_desc_idx' + + create_table 'sqlite_tags' do |t| + t.string 'name' + t.references 'parent' + t.timestamps null: false + end + + create_table 'sqlite_tag_hierarchies', id: false do |t| + t.references 'ancestor', null: false + t.references 'descendant', null: false + t.integer 'generations', null: false + end + + add_index 'sqlite_tag_hierarchies', %i[ancestor_id descendant_id generations], unique: true, + name: 'sqlite_tag_anc_desc_idx' + add_index 'sqlite_tag_hierarchies', [:descendant_id], name: 'sqlite_tag_desc_idx' end diff --git a/test/dummy/db/secondary_schema.rb b/test/dummy/db/secondary_schema.rb new file mode 100644 index 00000000..d7b141ff --- /dev/null +++ b/test/dummy/db/secondary_schema.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `rails +# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 0) do + create_table 'mysql_tags', force: true do |t| + t.string 'name' + t.integer 'parent_id' + t.datetime 'created_at' + t.datetime 'updated_at' + end + + create_table 'mysql_tag_hierarchies', id: false, force: true do |t| + t.integer 'ancestor_id', null: false + t.integer 'descendant_id', null: false + t.integer 'generations', null: false + end + + add_index 'mysql_tag_hierarchies', %i[ancestor_id descendant_id generations], unique: true, + name: 'mysql_tag_anc_des_idx' + add_index 'mysql_tag_hierarchies', [:descendant_id], name: 'mysql_tag_desc_idx' +end diff --git a/test/dummy/db/sqlite_schema.rb b/test/dummy/db/sqlite_schema.rb new file mode 100644 index 00000000..ec31da96 --- /dev/null +++ b/test/dummy/db/sqlite_schema.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `rails +# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 0) do + create_table 'sqlite_tags', force: true do |t| + t.string 'name' + t.integer 'parent_id' + t.datetime 'created_at' + t.datetime 'updated_at' + end + + create_table 'sqlite_tag_hierarchies', id: false, force: true do |t| + t.integer 'ancestor_id', null: false + t.integer 'descendant_id', null: false + t.integer 'generations', null: false + end + + add_index 'sqlite_tag_hierarchies', %i[ancestor_id descendant_id generations], unique: true, + name: 'sqlite_tag_anc_desc_idx' + add_index 'sqlite_tag_hierarchies', [:descendant_id], name: 'sqlite_tag_desc_idx' +end diff --git a/test/dummy/lib/tasks/db.rake b/test/dummy/lib/tasks/db.rake new file mode 100644 index 00000000..368e5db1 --- /dev/null +++ b/test/dummy/lib/tasks/db.rake @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +namespace :db do + desc 'Setup all databases' + task setup_all: :environment do + # Load primary database schema + ActiveRecord::Base.establish_connection(:primary) + ActiveRecord::Base.connection.disconnect! if ActiveRecord::Base.connection.active? + ActiveRecord::Base.establish_connection(:primary) + load Rails.root.join('db/schema.rb') + + # Load secondary (MySQL) database schema if configured + if ENV['DATABASE_URL_MYSQL'].present? + ActiveRecord::Base.establish_connection(:secondary) + ActiveRecord::Base.connection.disconnect! if ActiveRecord::Base.connection.active? + ActiveRecord::Base.establish_connection(:secondary) + load Rails.root.join('db/secondary_schema.rb') + end + + # SQLite is in-memory so it will be created automatically + ActiveRecord::Base.establish_connection(:primary) + end + + desc 'Drop all databases' + task :drop_all do + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config) + ActiveRecord::Tasks::DatabaseTasks.drop_current + end + end + + desc 'Reset all databases' + task reset_all: %i[drop_all setup_all] +end diff --git a/test/support/models.rb b/test/support/models.rb deleted file mode 100644 index a49e0134..00000000 --- a/test/support/models.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -class ApplicationRecord < ActiveRecord::Base - self.abstract_class = true - - # connects_to database: { writing: :primary, reading: :primary } -end - -class SecondDatabaseRecord < ActiveRecord::Base - self.abstract_class = true - - # connects_to database: { writing: :secondary, reading: :secondary } -end -class Tag < ApplicationRecord - has_closure_tree dependent: :destroy, order: :name - before_destroy :add_destroyed_tag - - def to_s - name - end - - def add_destroyed_tag - # Proof for the tests that the destroy rather than the delete method was called: - DestroyedTag.create(name: to_s) - end -end - -class UUIDTag < ApplicationRecord - self.primary_key = :uuid - before_create :set_uuid - has_closure_tree dependent: :destroy, order: 'name', parent_column_name: 'parent_uuid' - before_destroy :add_destroyed_tag - - def set_uuid - self.uuid = SecureRandom.uuid - end - - def to_s - name - end - - def add_destroyed_tag - # Proof for the tests that the destroy rather than the delete method was called: - DestroyedTag.create(name: to_s) - end -end - -class DestroyedTag < ApplicationRecord -end - -class Group < ApplicationRecord - has_closure_tree_root :root_user -end - -class Grouping < ApplicationRecord - has_closure_tree_root :root_person, class_name: 'User', foreign_key: :group_id -end - -class UserSet < ApplicationRecord - has_closure_tree_root :root_user, class_name: 'Useur' -end - -class Team < ApplicationRecord - has_closure_tree_root :root_user, class_name: 'User', foreign_key: :grp_id -end - -class User < ApplicationRecord - acts_as_tree parent_column_name: 'referrer_id', - name_column: 'email', - hierarchy_class_name: 'ReferralHierarchy', - hierarchy_table_name: 'referral_hierarchies' - - has_many :contracts, inverse_of: :user - belongs_to :group # Can't use and don't need inverse_of here when using has_closure_tree_root. - - def indirect_contracts - Contract.where(user_id: descendant_ids) - end - - def to_s - email - end -end - -class Contract < ApplicationRecord - belongs_to :user, inverse_of: :contracts - belongs_to :contract_type, inverse_of: :contracts -end - -class ContractType < ApplicationRecord - has_many :contracts, inverse_of: :contract_type -end - -class Label < ApplicationRecord - # make sure order doesn't matter - acts_as_tree order: :column_whereby_ordering_is_inferred, # <- symbol, and not "sort_order" - numeric_order: true, - parent_column_name: 'mother_id', - dependent: :destroy - - def to_s - "#{self.class}: #{name}" - end -end - -class EventLabel < Label -end - -class DateLabel < Label -end - -class DirectoryLabel < Label -end - -class LabelWithoutRootOrdering < ApplicationRecord - # make sure order doesn't matter - acts_as_tree order: :column_whereby_ordering_is_inferred, # <- symbol, and not "sort_order" - numeric_order: true, - dont_order_roots: true, - parent_column_name: 'mother_id', - hierarchy_table_name: 'label_hierarchies' - - self.table_name = "#{table_name_prefix}labels#{table_name_suffix}" - - def to_s - "#{self.class}: #{name}" - end -end - -class CuisineType < ApplicationRecord - acts_as_tree -end - -module Namespace - def self.table_name_prefix - 'namespace_' - end - - class Type < ApplicationRecord - has_closure_tree dependent: :destroy - end -end - -class Metal < ApplicationRecord - self.table_name = "#{table_name_prefix}metal#{table_name_suffix}" - has_closure_tree order: 'sort_order', name_column: 'value' - self.inheritance_column = 'metal_type' -end - -class Adamantium < Metal -end - -class Unobtanium < Metal -end - -class MenuItem < SecondDatabaseRecord - has_closure_tree touch: true, with_advisory_lock: false -end diff --git a/test/support/query_counter.rb b/test/support/query_counter.rb index bd59744c..d5b3ad99 100644 --- a/test/support/query_counter.rb +++ b/test/support/query_counter.rb @@ -3,19 +3,19 @@ # https://stackoverflow.com/a/43810063/1683557 module QueryCounter - def sql_queries(&block) + def sql_queries(&) queries = [] counter = lambda { |*, payload| queries << payload.fetch(:sql) unless %w[CACHE SCHEMA].include?(payload.fetch(:name)) } - ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block) + ActiveSupport::Notifications.subscribed(counter, 'sql.active_record', &) queries end - def assert_database_queries_count(expected, &block) - queries = sql_queries(&block) + def assert_database_queries_count(expected, &) + queries = sql_queries(&) assert_equal( expected, queries.count, diff --git a/test/support/tag_examples.rb b/test/support/tag_examples.rb index 74a312bc..d73fe985 100644 --- a/test/support/tag_examples.rb +++ b/test/support/tag_examples.rb @@ -1,919 +1,969 @@ # frozen_string_literal: true +require 'active_support/concern' + module TagExamples - def self.included(mod) - @@described_class = mod.name.safe_constantize - end + extend ActiveSupport::Concern + + included do + def setup + super + @tag_class = self.class.const_get(:TAG_CLASS) || Tag + @tag_hierarchy_class = @tag_class.hierarchy_class + # Clean up any existing data to ensure test isolation + @tag_class.delete_all + @tag_hierarchy_class.delete_all + end - describe 'TagExamples' do - before do - @tag_class = @@described_class - @tag_hierarchy_class = @@described_class.hierarchy_class + define_method 'test_should_build_hierarchy_classname_correctly' do + assert_equal @tag_hierarchy_class, @tag_class.hierarchy_class + assert_equal @tag_hierarchy_class.to_s, @tag_class._ct.hierarchy_class_name + assert_equal @tag_hierarchy_class.to_s, @tag_class._ct.short_hierarchy_class_name end - describe 'class setup' do + define_method 'test_should_have_a_correct_parent_column_name' do + expected_parent_column_name = @tag_class == UuidTag ? 'parent_uuid' : 'parent_id' + assert_equal expected_parent_column_name, @tag_class._ct.parent_column_name + end - it 'should build hierarchy classname correctly' do - assert_equal @tag_hierarchy_class, @tag_class.hierarchy_class - assert_equal @tag_hierarchy_class.to_s, @tag_class._ct.hierarchy_class_name - assert_equal @tag_hierarchy_class.to_s, @tag_class._ct.short_hierarchy_class_name - end + define_method 'test_should_return_no_entities_when_db_is_empty' do + assert_empty @tag_class.roots + assert_empty @tag_class.leaves + end - it 'should have a correct parent column name' do - expected_parent_column_name = @tag_class == UUIDTag ? 'parent_uuid' : 'parent_id' - assert_equal expected_parent_column_name, @tag_class._ct.parent_column_name - end + define_method 'test_find_or_create_by_path_with_strings' do + a = @tag_class.create!(name: 'a') + assert_equal(%w[a b c], a.find_or_create_by_path(%w[b c]).ancestry_path) end - describe 'from empty db' do - describe 'with no tags' do - it 'should return no entities' do - assert_empty @tag_class.roots - assert_empty @tag_class.leaves - end + define_method 'test_find_or_create_by_path_with_hashes' do + a = @tag_class.create!(name: 'a', title: 'A') + subject = a.find_or_create_by_path([ + { name: 'b', title: 'B' }, + { name: 'c', title: 'C' } + ]) + assert_equal(%w[a b c], subject.ancestry_path) + assert_equal(%w[C B A], subject.self_and_ancestors.map(&:title)) + end - it '#find_or_create_by_path with strings' do - a = @tag_class.create!(name: 'a') - assert_equal(%w[a b c], a.find_or_create_by_path(%w[b c]).ancestry_path) - end + define_method 'test_single_tag_should_be_a_leaf_and_root' do + tag = @tag_class.create!(name: 'tag') + assert tag.leaf? + assert tag.root? + assert_nil tag.parent + assert_equal [tag], @tag_class.all + assert_equal [tag], @tag_class.roots + assert_equal [tag], @tag_class.leaves + end - it '#find_or_create_by_path with hashes' do - a = @tag_class.create!(name: 'a', title: 'A') - subject = a.find_or_create_by_path([ - { name: 'b', title: 'B' }, - { name: 'c', title: 'C' } - ]) - assert_equal(%w[a b c], subject.ancestry_path) - assert_equal(%w[C B A], subject.self_and_ancestors.map(&:title)) - end - end + define_method 'test_should_not_find_tag_with_invalid_path_arguments' do + tag = @tag_class.create!(name: 'tag') + assert_nil @tag_class.find_by_path(['']) + assert_nil @tag_class.find_by_path([]) + assert_nil @tag_class.find_by_path(nil) + assert_nil @tag_class.find_by_path('') + assert_nil @tag_class.find_by_path([nil]) + assert_nil @tag_class.find_by_path([tag.name, '']) + assert_nil @tag_class.find_by_path([tag.name, nil]) + end - describe 'with 1 tag' do - before do - @tag = @tag_class.create!(name: 'tag') - end + define_method 'test_should find tag by valid path' do + tag = @tag_class.create!(name: 'tag') + assert_equal tag, @tag_class.find_by_path([tag.name]) + assert_equal tag, @tag_class.find_by_path(tag.name) + end - it 'should be a leaf' do - assert @tag.leaf? - end + define_method 'test_adds children through add_child' do + tag = @tag_class.create!(name: 'tag') + child = @tag_class.create!(name: 'tag 2') + tag.add_child child + + assert tag.root? + refute tag.leaf? + refute child.root? + assert child.leaf? + assert_equal tag, child.reload.parent + assert_equal [child], tag.reload.children.to_a + end - it 'should be a root' do - assert @tag.root? - end + define_method 'test_adds children through collection' do + tag = @tag_class.create!(name: 'tag') + child = @tag_class.create!(name: 'tag 2') + tag.children << child + + assert tag.root? + refute tag.leaf? + refute child.root? + assert child.leaf? + assert_equal tag, child.reload.parent + assert_equal [child], tag.reload.children.to_a + end - it 'has no parent' do - assert_nil @tag.parent - end + define_method 'test_returns simple root and leaf with 2 tags' do + root = @tag_class.create!(name: 'root') + leaf = root.add_child(@tag_class.create!(name: 'leaf')) - it 'should return the only entity as a root and leaf' do - assert_equal [@tag], @tag_class.all - assert_equal [@tag], @tag_class.roots - assert_equal [@tag], @tag_class.leaves - end + assert_equal [root], @tag_class.roots + assert_equal [leaf], @tag_class.leaves + assert_equal [leaf.id], root.child_ids + assert_empty leaf.child_ids + end - it 'should not be found by passing find_by_path an array of blank strings' do - assert_nil @tag_class.find_by_path(['']) - end + define_method 'test_3 tag collection.create hierarchy' do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' + DestroyedTag.delete_all - it 'should not be found by passing find_by_path an empty array' do - assert_nil @tag_class.find_by_path([]) - end + assert_equal [root, mid, leaf].sort, @tag_class.all.to_a.sort + assert_equal [root], @tag_class.roots + assert_equal [leaf], @tag_class.leaves + end - it 'should not be found by passing find_by_path nil' do - assert_nil @tag_class.find_by_path(nil) - end + define_method 'test_deletes leaves' do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + mid.children.create! name: 'leaf' + DestroyedTag.delete_all - it 'should not be found by passing find_by_path an empty string' do - assert_nil @tag_class.find_by_path('') - end + @tag_class.leaves.destroy_all + assert_equal [root], @tag_class.roots + assert_equal [mid], @tag_class.leaves + end - it 'should not be found by passing find_by_path an array of nils' do - assert_nil @tag_class.find_by_path([nil]) - end + define_method 'test_deletes everything when deleting roots' do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + mid.children.create! name: 'leaf' + DestroyedTag.delete_all + + @tag_class.roots.destroy_all + assert_empty @tag_class.all + assert_empty @tag_class.roots + assert_empty @tag_class.leaves + assert_equal %w[root mid leaf].sort, DestroyedTag.all.map(&:name).sort + end - it 'should not be found by passing find_by_path an array with an additional blank string' do - assert_nil @tag_class.find_by_path([@tag.name, '']) - end + define_method 'test_fixes self_and_ancestors properly on reparenting' do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + mid.children.create! name: 'leaf' - it 'should not be found by passing find_by_path an array with an additional nil' do - assert_nil @tag_class.find_by_path([@tag.name, nil]) - end + t = @tag_class.create! name: 'moar leaf' + assert_equal [t], t.self_and_ancestors.to_a + mid.children << t + assert_equal [t, mid, root], t.self_and_ancestors.to_a + end - it 'should be found by passing find_by_path an array with its name' do - assert_equal @tag, @tag_class.find_by_path([@tag.name]) - end + define_method 'test_prevents ancestor loops' do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' - it 'should be found by passing find_by_path its name' do - assert_equal @tag, @tag_class.find_by_path(@tag.name) - end + leaf.add_child root + refute root.valid? + assert_includes root.reload.descendants, leaf + end - describe 'with child' do - before do - @child = @tag_class.create!(name: 'tag 2') - end - - def assert_roots_and_leaves - assert @tag.root? - refute @tag.leaf? - - refute @child.root? - assert @child.leaf? - end - - def assert_parent_and_children - assert_equal @tag, @child.reload.parent - assert_equal [@child], @tag.reload.children.to_a - end - - it 'adds children through add_child' do - @tag.add_child @child - assert_roots_and_leaves - assert_parent_and_children - end - - it 'adds children through collection' do - @tag.children << @child - assert_roots_and_leaves - assert_parent_and_children - end - end - end + define_method 'test_moves non-leaves' do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' - describe 'with 2 tags' do - before do - @root = @tag_class.create!(name: 'root') - @leaf = @root.add_child(@tag_class.create!(name: 'leaf')) - end + new_root = @tag_class.create! name: 'new_root' + new_root.children << mid + assert_empty root.reload.descendants + assert_equal [mid, leaf], new_root.descendants + assert_equal %w[new_root mid leaf], leaf.reload.ancestry_path + end - it 'should return a simple root and leaf' do - assert_equal [@root], @tag_class.roots - assert_equal [@leaf], @tag_class.leaves - end + define_method 'test_moves leaves' do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' - it 'should return child_ids for root' do - assert_equal [@leaf.id], @root.child_ids - end + new_root = @tag_class.create! name: 'new_root' + new_root.children << leaf + assert_equal [leaf], new_root.descendants + assert_equal [mid], root.reload.descendants + assert_equal %w[new_root leaf], leaf.reload.ancestry_path + end - it 'should return an empty array for leaves' do - assert_empty @leaf.child_ids - end - end + define_method 'test_3 tag explicit_create hierarchy' do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) - describe '3 tag collection.create db' do - before do - @root = @tag_class.create! name: 'root' - @mid = @root.children.create! name: 'mid' - @leaf = @mid.children.create! name: 'leaf' - DestroyedTag.delete_all - end + assert_equal [root, mid, leaf].sort, @tag_class.all.to_a.sort + assert_equal [root], @tag_class.roots + assert_equal [leaf], @tag_class.leaves + end - it 'should create all tags' do - assert_equal [@root, @mid, @leaf].sort, @tag_class.all.to_a.sort - end + define_method 'test_prevents parental loops from torso' do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) - it 'should return a root and leaf without middle tag' do - assert_equal [@root], @tag_class.roots - assert_equal [@leaf], @tag_class.leaves - end + mid.children << root + refute root.valid? + assert_equal [leaf], mid.reload.children + end - it 'should delete leaves' do - @tag_class.leaves.destroy_all - assert_equal [@root], @tag_class.roots # untouched - assert_equal [@mid], @tag_class.leaves - end + define_method 'test_prevents parental loops from toes' do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) - it 'should delete everything if you delete the roots' do - @tag_class.roots.destroy_all - assert_empty @tag_class.all - assert_empty @tag_class.roots - assert_empty @tag_class.leaves - assert_equal %w[root mid leaf].sort, DestroyedTag.all.map(&:name).sort - end + leaf.children << root + refute root.valid? + assert_empty leaf.reload.children + end - it 'fix self_and_ancestors properly on reparenting' do - t = @tag_class.create! name: 'moar leaf' - assert_equal [t], t.self_and_ancestors.to_a - @mid.children << t - assert_equal [t, @mid, @root], t.self_and_ancestors.to_a - end + define_method 'test_supports re-parenting' do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) - it 'prevents ancestor loops' do - @leaf.add_child @root - refute @root.valid? - assert_includes @root.reload.descendants, @leaf - end + root.children << leaf + assert_equal [leaf, mid], @tag_class.leaves + end - it 'moves non-leaves' do - new_root = @tag_class.create! name: 'new_root' - new_root.children << @mid - assert_empty @root.reload.descendants - assert_equal [@mid, @leaf], new_root.descendants - assert_equal %w[new_root mid leaf], @leaf.reload.ancestry_path - end + define_method 'test_cleans up hierarchy references for leaves' do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) - it 'moves leaves' do - new_root = @tag_class.create! name: 'new_root' - new_root.children << @leaf - assert_equal [@leaf], new_root.descendants - assert_equal [@mid], @root.reload.descendants - assert_equal %w[new_root leaf], @leaf.reload.ancestry_path - end - end + leaf.destroy + assert_empty @tag_hierarchy_class.where(ancestor_id: leaf.id) + assert_empty @tag_hierarchy_class.where(descendant_id: leaf.id) + end - describe '3 tag explicit_create db' do - before do - @root = @tag_class.create!(name: 'root') - @mid = @root.add_child(@tag_class.create!(name: 'mid')) - @leaf = @mid.add_child(@tag_class.create!(name: 'leaf')) - end + define_method 'test_cleans up hierarchy references' do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + mid.add_child(@tag_class.create!(name: 'leaf')) + + mid.destroy + assert_empty @tag_hierarchy_class.where(ancestor_id: mid.id) + assert_empty @tag_hierarchy_class.where(descendant_id: mid.id) + assert root.reload.root? + root_hiers = root.ancestor_hierarchies.to_a + assert_equal 1, root_hiers.size + assert_equal root_hiers, @tag_hierarchy_class.where(ancestor_id: root.id) + assert_equal root_hiers, @tag_hierarchy_class.where(descendant_id: root.id) + end - it 'should create all tags' do - assert_equal [@root, @mid, @leaf].sort, @tag_class.all.to_a.sort - end + define_method 'test_hierarchy models have different hash codes' do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + mid.add_child(@tag_class.create!(name: 'leaf')) - it 'should return a root and leaf without middle tag' do - assert_equal [@root], @tag_class.roots - assert_equal [@leaf], @tag_class.leaves - end + hashes = @tag_hierarchy_class.all.map(&:hash) + assert_equal hashes.uniq.sort, hashes.sort + end - it 'should prevent parental loops from torso' do - @mid.children << @root - refute @root.valid? - assert_equal [@leaf], @mid.reload.children - end + define_method 'test_equal hierarchy models have same hash code' do + root = @tag_class.create!(name: 'root') + root.add_child(@tag_class.create!(name: 'mid')) - it 'should prevent parental loops from toes' do - @leaf.children << @root - refute @root.valid? - assert_empty @leaf.reload.children - end + assert_equal @tag_hierarchy_class.first.hash, @tag_hierarchy_class.first.hash + end - it 'should support re-parenting' do - @root.children << @leaf - assert_equal [@leaf, @mid], @tag_class.leaves - end + define_method 'test_performs as the readme says' do + grandparent = @tag_class.create(name: 'Grandparent') + parent = grandparent.children.create(name: 'Parent') + child1 = @tag_class.create(name: 'First Child', parent: parent) + child2 = @tag_class.new(name: 'Second Child') + parent.children << child2 + child3 = @tag_class.new(name: 'Third Child') + parent.add_child child3 + + assert_equal(%w[Grandparent Parent], parent.ancestry_path) + assert_equal(['Grandparent', 'Parent', 'First Child'], child1.ancestry_path) + assert_equal(['Grandparent', 'Parent', 'Second Child'], child2.ancestry_path) + assert_equal(['Grandparent', 'Parent', 'Third Child'], child3.ancestry_path) + + d = @tag_class.find_or_create_by_path %w[a b c d] + h = @tag_class.find_or_create_by_path %w[e f g h] + e = h.root + d.add_child(e) + assert_equal %w[a b c d e f g h], h.ancestry_path + end - it 'cleans up hierarchy references for leaves' do - @leaf.destroy - assert_empty @tag_hierarchy_class.where(ancestor_id: @leaf.id) - assert_empty @tag_hierarchy_class.where(descendant_id: @leaf.id) - end + define_method 'test_roots sort alphabetically' do + expected = ('a'..'z').to_a + expected.shuffle.each { |ea| @tag_class.create!(name: ea) } + assert_equal expected, @tag_class.roots.collect(&:name) + end - it 'cleans up hierarchy references' do - @mid.destroy - assert_empty @tag_hierarchy_class.where(ancestor_id: @mid.id) - assert_empty @tag_hierarchy_class.where(descendant_id: @mid.id) - assert @root.reload.root? - root_hiers = @root.ancestor_hierarchies.to_a - assert_equal 1, root_hiers.size - assert_equal root_hiers, @tag_hierarchy_class.where(ancestor_id: @root.id) - assert_equal root_hiers, @tag_hierarchy_class.where(descendant_id: @root.id) - end + define_method 'test_finds global roots in simple tree' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - it 'should have different hash codes for each hierarchy model' do - hashes = @tag_hierarchy_class.all.map(&:hash) - assert_equal hashes.uniq.sort, hashes.sort - end + a1, a2, a3, = @tag_class.all.sort_by(&:name) + expected_roots = [a1, a2, a3] - it 'should return the same hash code for equal hierarchy models' do - assert_equal @tag_hierarchy_class.first.hash, @tag_hierarchy_class.first.hash - end - end + assert_equal expected_roots.sort, @tag_class.roots.to_a.sort + end - it 'performs as the readme says it does' do - skip "JRuby has issues with ActiveRecord 7.1+ datetime handling in transactions" if defined?(JRUBY_VERSION) - grandparent = @tag_class.create(name: 'Grandparent') - parent = grandparent.children.create(name: 'Parent') - child1 = @tag_class.create(name: 'First Child', parent: parent) - child2 = @tag_class.new(name: 'Second Child') - parent.children << child2 - child3 = @tag_class.new(name: 'Third Child') - parent.add_child child3 - assert_equal( - ['Grandparent', 'Parent', 'First Child', 'Second Child', 'Third Child'], - grandparent.self_and_descendants.collect(&:name) - ) - assert_equal(['Grandparent', 'Parent', 'First Child'], child1.ancestry_path) - assert_equal(['Grandparent', 'Parent', 'Third Child'], child3.ancestry_path) - d = @tag_class.find_or_create_by_path %w[a b c d] - h = @tag_class.find_or_create_by_path %w[e f g h] - e = h.root - d.add_child(e) # "d.children << e" would work too, of course - assert_equal %w[a b c d e f g h], h.ancestry_path - end + define_method 'test_returns root? for roots' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - it 'roots sort alphabetically' do - expected = ('a'..'z').to_a - expected.shuffle.each { |ea| @tag_class.create!(name: ea) } - assert_equal expected, @tag_class.roots.collect(&:name) - end + a1, a2, a3 = @tag_class.all.sort_by(&:name).select(&:root?) + [a1, a2, a3].each { |ea| assert(ea.root?) } + end - describe 'with simple tree' do - before do - @tag_class.find_or_create_by_path %w[a1 b1 c1a] - @tag_class.find_or_create_by_path %w[a1 b1 c1b] - @tag_class.find_or_create_by_path %w[a1 b1 c1c] - @tag_class.find_or_create_by_path %w[a1 b1b] - @tag_class.find_or_create_by_path %w[a2 b2] - @tag_class.find_or_create_by_path %w[a3] - - @a1, @a2, @a3, @b1, @b1b, @b2, @c1a, @c1b, @c1c = @tag_class.all.sort_by(&:name) - @expected_roots = [@a1, @a2, @a3] - @expected_leaves = [@c1a, @c1b, @c1c, @b1b, @b2, @a3] - @expected_siblings = [[@a1, @a2, @a3], [@b1, @b1b], [@c1a, @c1b, @c1c]] - @expected_only_children = @tag_class.all - @expected_siblings.flatten - end + define_method 'test_does not return root? for non-roots' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a2 b2] - it 'should find global roots' do - assert_equal @expected_roots.sort, @tag_class.roots.to_a.sort - end + _, _, b1, b2, c1a = @tag_class.all.sort_by(&:name) + [b1, b2, c1a].each { |ea| refute(ea.root?) } + end - it 'should return root? for roots' do - @expected_roots.each { |ea| assert(ea.root?) } - end + define_method 'test_returns the correct root' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a2 b2] - it 'should not return root? for non-roots' do - [@b1, @b2, @c1a, @c1b].each { |ea| refute(ea.root?) } - end + a1, a2, b1, b2, c1a, c1b = @tag_class.all.sort_by(&:name) - it 'should return the correct root' do - { @a1 => @a1, @a2 => @a2, @a3 => @a3, - @b1 => @a1, @b2 => @a2, @c1a => @a1, @c1b => @a1 }.each do |node, root| - assert_equal(root, node.root) - end - end + { a1 => a1, a2 => a2, b1 => a1, b2 => a2, c1a => a1, c1b => a1 }.each do |node, root| + assert_equal(root, node.root) + end + end - it 'should assemble global leaves' do - assert_equal @expected_leaves.sort, @tag_class.leaves.to_a.sort - end + define_method 'test_assembles global leaves' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - it 'assembles siblings properly' do - @expected_siblings.each do |siblings| - siblings.each do |ea| - assert_equal siblings.sort, ea.self_and_siblings.to_a.sort - assert_equal((siblings - [ea]).sort, ea.siblings.to_a.sort) - end - end - - @expected_only_children.each do |ea| - assert_equal [], ea.siblings - end - end + _, _, a3, _, b1b, b2, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_leaves = [c1a, c1b, c1c, b1b, b2, a3] - it 'assembles before_siblings' do - @expected_siblings.each do |siblings| - (siblings.size - 1).times do |i| - target = siblings[i] - expected_before = siblings.first(i) - assert_equal expected_before, target.siblings_before.to_a - end - end - end + assert_equal expected_leaves.sort, @tag_class.leaves.to_a.sort + end - it 'assembles after_siblings' do - @expected_siblings.each do |siblings| - (siblings.size - 1).times do |i| - target = siblings[i] - expected_after = siblings.last(siblings.size - 1 - i) - assert_equal expected_after, target.siblings_after.to_a - end - end - end + define_method 'test_assembles siblings properly' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - it 'should assemble instance leaves' do - { @a1 => [@b1b, @c1a, @c1b, @c1c], @b1 => [@c1a, @c1b, @c1c], @a2 => [@b2] }.each do |node, leaves| - assert_equal leaves, node.leaves.to_a - end + a1, a2, a3, b1, b1b, _, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_siblings = [[a1, a2, a3], [b1, b1b], [c1a, c1b, c1c]] + expected_only_children = @tag_class.all - expected_siblings.flatten - @expected_leaves.each { |ea| assert_equal [ea], ea.leaves.to_a } + expected_siblings.each do |siblings| + siblings.each do |ea| + assert_equal siblings.sort, ea.self_and_siblings.to_a.sort + assert_equal((siblings - [ea]).sort, ea.siblings.to_a.sort) end + end - it 'should return leaf? for leaves' do - @expected_leaves.each { |ea| assert ea.leaf? } - end + expected_only_children.each do |ea| + assert_equal [], ea.siblings + end + end - it 'can move roots' do - @c1a.children << @a2 - @b2.reload.children << @a3 - assert_equal %w[a1 b1 c1a a2 b2 a3], @a3.reload.ancestry_path - end + define_method 'test_assembles before_siblings' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1, a2, a3, b1, b1b, _, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_siblings = [[a1, a2, a3], [b1, b1b], [c1a, c1b, c1c]] - it 'cascade-deletes from roots' do - victim_names = @a1.self_and_descendants.map(&:name) - survivor_names = @tag_class.all.map(&:name) - victim_names - @a1.destroy - assert_equal survivor_names, @tag_class.all.map(&:name) + expected_siblings.each do |siblings| + (siblings.size - 1).times do |i| + target = siblings[i] + expected_before = siblings.first(i) + assert_equal expected_before, target.siblings_before.to_a end end + end - describe 'with_ancestor' do - it 'works with no rows' do - assert_empty @tag_class.with_ancestor.to_a - end + define_method 'test_assembles after_siblings' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - it 'finds only children' do - c = @tag_class.find_or_create_by_path %w[A B C] - a = c.parent.parent - b = c.parent - @tag_class.find_or_create_by_path %w[D E] - assert_equal [b, c], @tag_class.with_ancestor(a).to_a - end + a1, a2, a3, b1, b1b, _, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_siblings = [[a1, a2, a3], [b1, b1b], [c1a, c1b, c1c]] - it 'limits subsequent where clauses' do - a1c = @tag_class.find_or_create_by_path %w[A1 B C] - a2c = @tag_class.find_or_create_by_path %w[A2 B C] - # different paths! - refute_equal a2c, a1c - assert_equal [a1c, a2c].sort, @tag_class.where(name: 'C').to_a.sort - assert_equal [a1c], @tag_class.with_ancestor(a1c.parent.parent).where(name: 'C').to_a.sort + expected_siblings.each do |siblings| + (siblings.size - 1).times do |i| + target = siblings[i] + expected_after = siblings.last(siblings.size - 1 - i) + assert_equal expected_after, target.siblings_after.to_a end end + end - describe 'with_descendant' do - it 'works with no rows' do - assert_empty @tag_class.with_descendant.to_a - end + define_method 'test_assembles instance leaves' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - it 'finds only parents' do - c = @tag_class.find_or_create_by_path %w[A B C] - a = c.parent.parent - b = c.parent - _spurious_tags = @tag_class.find_or_create_by_path %w[D E] - assert_equal [a, b], @tag_class.with_descendant(c).to_a - end + a1, a2, a3, b1, b1b, b2, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_leaves = [c1a, c1b, c1c, b1b, b2, a3] - it 'limits subsequent where clauses' do - ac1 = @tag_class.create(name: 'A') - ac2 = @tag_class.create(name: 'A') + { a1 => [b1b, c1a, c1b, c1c], b1 => [c1a, c1b, c1c], a2 => [b2] }.each do |node, leaves| + assert_equal leaves, node.leaves.to_a + end - c1 = @tag_class.find_or_create_by_path %w[B C1] - ac1.children << c1.parent + expected_leaves.each { |ea| assert_equal [ea], ea.leaves.to_a } + end - c2 = @tag_class.find_or_create_by_path %w[B C2] - ac2.children << c2.parent + define_method 'test_returns leaf? for leaves' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - # different paths! - refute_equal ac2, ac1 - assert_equal [ac1, ac2].sort, @tag_class.where(name: 'A').to_a.sort - assert_equal [ac1], @tag_class.with_descendant(c1).where(name: 'A').to_a - end - end + _, _, a3, _, b1b, b2, c1a = @tag_class.all.sort_by(&:name) + expected_leaves = [c1a, b1b, b2, a3] - describe 'lowest_common_ancestor' do - before do - @t1 = @tag_class.create!(name: 't1') - @t11 = @tag_class.create!(name: 't11', parent: @t1) - @t111 = @tag_class.create!(name: 't111', parent: @t11) - @t112 = @tag_class.create!(name: 't112', parent: @t11) - @t12 = @tag_class.create!(name: 't12', parent: @t1) - @t121 = @tag_class.create!(name: 't121', parent: @t12) - @t2 = @tag_class.create!(name: 't2') - @t21 = @tag_class.create!(name: 't21', parent: @t2) - @t21 = @tag_class.create!(name: 't21', parent: @t2) - @t211 = @tag_class.create!(name: 't211', parent: @t21) - end + expected_leaves.each { |ea| assert ea.leaf? } + end - it 'finds the parent for siblings' do - assert_equal @t11, @tag_class.lowest_common_ancestor(@t112, @t111) - assert_equal @t1, @tag_class.lowest_common_ancestor(@t12, @t11) + define_method 'test_can move roots' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - assert_equal @t11, @tag_class.lowest_common_ancestor([@t112, @t111]) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t12, @t11]) + _, a2, a3, _, b2, c1a = @tag_class.all.sort_by(&:name) - assert_equal @t11, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t112 t111])) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t11])) - end + c1a.children << a2 + b2.reload.children << a3 + assert_equal %w[a1 b1 c1a a2 b2 a3], a3.reload.ancestry_path + end - it 'finds the grandparent for cousins' do - assert_equal @t1, @tag_class.lowest_common_ancestor(@t112, @t111, @t121) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t112, @t111, @t121]) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t112 t111 t121])) - end + define_method 'test_cascade-deletes from roots' do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] - it 'finds the parent/grandparent for aunt-uncle/niece-nephew' do - assert_equal @t1, @tag_class.lowest_common_ancestor(@t12, @t112) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t12, @t112]) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t112])) - end + a1 = @tag_class.all.min_by(&:name) - it 'finds the self/parent for parent/child' do - assert_equal @t12, @tag_class.lowest_common_ancestor(@t12, @t121) - assert_equal @t1, @tag_class.lowest_common_ancestor(@t1, @t12) + victim_names = a1.self_and_descendants.map(&:name) + survivor_names = @tag_class.all.map(&:name) - victim_names + a1.destroy + assert_equal survivor_names, @tag_class.all.map(&:name) + end - assert_equal @t12, @tag_class.lowest_common_ancestor([@t12, @t121]) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t1, @t12]) + define_method 'test_with_ancestor works with no rows' do + assert_empty @tag_class.with_ancestor.to_a + end - assert_equal @t12, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t121])) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t1 t12])) - end + define_method 'test_with_ancestor finds only children' do + c = @tag_class.find_or_create_by_path %w[A B C] + a = c.parent.parent + b = c.parent + @tag_class.find_or_create_by_path %w[D E] + assert_equal [b, c], @tag_class.with_ancestor(a).to_a + end - it 'finds the self/grandparent for grandparent/grandchild' do - assert_equal @t2, @tag_class.lowest_common_ancestor(@t211, @t2) - assert_equal @t1, @tag_class.lowest_common_ancestor(@t111, @t1) + define_method 'test_with_ancestor limits subsequent where clauses' do + a1c = @tag_class.find_or_create_by_path %w[A1 B C] + a2c = @tag_class.find_or_create_by_path %w[A2 B C] + refute_equal a2c, a1c + assert_equal [a1c, a2c].sort, @tag_class.where(name: 'C').to_a.sort + assert_equal [a1c], @tag_class.with_ancestor(a1c.parent.parent).where(name: 'C').to_a.sort + end - assert_equal @t2, @tag_class.lowest_common_ancestor([@t211, @t2]) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t111, @t1]) + define_method 'test_with_descendant works with no rows' do + assert_empty @tag_class.with_descendant.to_a + end - assert_equal @t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t211 t2])) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t111 t1])) - end + define_method 'test_with_descendant finds only parents' do + c = @tag_class.find_or_create_by_path %w[A B C] + a = c.parent.parent + b = c.parent + _spurious_tags = @tag_class.find_or_create_by_path %w[D E] + assert_equal [a, b], @tag_class.with_descendant(c).to_a + end - it 'finds the grandparent for a whole extended family' do - assert_equal @t1, @tag_class.lowest_common_ancestor(@t1, @t11, @t111, @t112, @t12, @t121) - assert_equal @t2, @tag_class.lowest_common_ancestor(@t2, @t21, @t211) + define_method 'test_with_descendant limits subsequent where clauses' do + ac1 = @tag_class.create(name: 'A') + ac2 = @tag_class.create(name: 'A') - assert_equal @t1, @tag_class.lowest_common_ancestor([@t1, @t11, @t111, @t112, @t12, @t121]) - assert_equal @t2, @tag_class.lowest_common_ancestor([@t2, @t21, @t211]) + c1 = @tag_class.find_or_create_by_path %w[B C1] + ac1.children << c1.parent - assert_equal @t1, - @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t1 t11 t111 t112 t12 t121])) - assert_equal @t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t2 t21 t211])) - end + c2 = @tag_class.find_or_create_by_path %w[B C2] + ac2.children << c2.parent - it 'is nil for no items' do - assert_nil @tag_class.lowest_common_ancestor - assert_nil @tag_class.lowest_common_ancestor([]) - assert_nil @tag_class.lowest_common_ancestor(@tag_class.none) - end + refute_equal ac2, ac1 + assert_equal [ac1, ac2].sort, @tag_class.where(name: 'A').to_a.sort + assert_equal [ac1], @tag_class.with_descendant(c1).where(name: 'A').to_a + end - it 'is nil if there are no common ancestors' do - assert_nil @tag_class.lowest_common_ancestor(@t111, @t211) - assert_nil @tag_class.lowest_common_ancestor([@t111, @t211]) - assert_nil @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t111 t211])) - end + define_method 'test_lowest_common_ancestor finds parent for siblings' do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t112 = @tag_class.create!(name: 't112', parent: t11) + t12 = @tag_class.create!(name: 't12', parent: t1) + + assert_equal t11, @tag_class.lowest_common_ancestor(t112, t111) + assert_equal t1, @tag_class.lowest_common_ancestor(t12, t11) + assert_equal t11, @tag_class.lowest_common_ancestor([t112, t111]) + assert_equal t1, @tag_class.lowest_common_ancestor([t12, t11]) + assert_equal t11, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t112 t111])) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t11])) + end - it 'is itself for single item' do - assert_equal @t111, @tag_class.lowest_common_ancestor(@t111) - assert_equal @t2, @tag_class.lowest_common_ancestor(@t2) + define_method 'test_lowest_common_ancestor finds grandparent for cousins' do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t112 = @tag_class.create!(name: 't112', parent: t11) + t12 = @tag_class.create!(name: 't12', parent: t1) + t121 = @tag_class.create!(name: 't121', parent: t12) + + assert_equal t1, @tag_class.lowest_common_ancestor(t112, t111, t121) + assert_equal t1, @tag_class.lowest_common_ancestor([t112, t111, t121]) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t112 t111 t121])) + end - assert_equal @t111, @tag_class.lowest_common_ancestor([@t111]) - assert_equal @t2, @tag_class.lowest_common_ancestor([@t2]) + define_method 'test_lowest_common_ancestor for aunt-uncle/niece-nephew' do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t112 = @tag_class.create!(name: 't112', parent: t11) + t12 = @tag_class.create!(name: 't12', parent: t1) - assert_equal @t111, @tag_class.lowest_common_ancestor(@tag_class.where(name: 't111')) - assert_equal @t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: 't2')) - end - end + assert_equal t1, @tag_class.lowest_common_ancestor(t12, t112) + assert_equal t1, @tag_class.lowest_common_ancestor([t12, t112]) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t112])) + end - describe 'paths' do - describe 'with grandchild ' do - before do - @child = @tag_class.find_or_create_by_path([ - { name: 'grandparent', title: 'Nonnie' }, - { name: 'parent', title: 'Mom' }, - { name: 'child', title: 'Kid' } - ]) - @parent = @child.parent - @grandparent = @parent.parent - end - - it 'should build ancestry path' do - assert_equal %w[grandparent parent child], @child.ancestry_path - assert_equal %w[grandparent parent child], @child.ancestry_path(:name) - assert_equal %w[Nonnie Mom Kid], @child.ancestry_path(:title) - end - - it 'assembles ancestors' do - assert_equal [@parent, @grandparent], @child.ancestors - assert_equal [@child, @parent, @grandparent], @child.self_and_ancestors - end - - it 'should find by path' do - # class method: - assert_equal @child, @tag_class.find_by_path(%w[grandparent parent child]) - # instance method: - assert_equal @child, @parent.find_by_path(%w[child]) - assert_equal @child, @grandparent.find_by_path(%w[parent child]) - assert_nil @parent.find_by_path(%w[child larvae]) - end - - it 'should respect attribute hashes with both selection and creation' do - expected_title = 'something else' - attrs = { title: expected_title } - existing_title = @grandparent.title - new_grandparent = @tag_class.find_or_create_by_path(%w[grandparent], attrs) - refute_equal @grandparent, new_grandparent - assert_equal expected_title, new_grandparent.title - assert_equal existing_title, @grandparent.reload.title - end - - it 'should create a hierarchy with a given attribute' do - expected_title = 'unicorn rainbows' - attrs = { title: expected_title } - child = @tag_class.find_or_create_by_path(%w[grandparent parent child], attrs) - refute_equal @child, child - [child, child.parent, child.parent.parent].each do |ea| - assert_equal expected_title, ea.title - end - end - end + define_method 'test_lowest_common_ancestor for parent/child' do + t1 = @tag_class.create!(name: 't1') + t12 = @tag_class.create!(name: 't12', parent: t1) + t121 = @tag_class.create!(name: 't121', parent: t12) + + assert_equal t12, @tag_class.lowest_common_ancestor(t12, t121) + assert_equal t1, @tag_class.lowest_common_ancestor(t1, t12) + assert_equal t12, @tag_class.lowest_common_ancestor([t12, t121]) + assert_equal t1, @tag_class.lowest_common_ancestor([t1, t12]) + assert_equal t12, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t121])) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t1 t12])) + end - it 'finds correctly rooted paths' do - _decoy = @tag_class.find_or_create_by_path %w[a b c d] - b_d = @tag_class.find_or_create_by_path %w[b c d] - assert_equal b_d, @tag_class.find_by_path(%w[b c d]) - assert_nil @tag_class.find_by_path(%w[c d]) - end + define_method 'test_lowest_common_ancestor for grandparent/grandchild' do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t2 = @tag_class.create!(name: 't2') + t21 = @tag_class.create!(name: 't21', parent: t2) + t211 = @tag_class.create!(name: 't211', parent: t21) + + assert_equal t2, @tag_class.lowest_common_ancestor(t211, t2) + assert_equal t1, @tag_class.lowest_common_ancestor(t111, t1) + assert_equal t2, @tag_class.lowest_common_ancestor([t211, t2]) + assert_equal t1, @tag_class.lowest_common_ancestor([t111, t1]) + assert_equal t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t211 t2])) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t111 t1])) + end - it 'find_by_path for 1 node' do - b = @tag_class.find_or_create_by_path %w[a b] - b2 = b.root.find_by_path(%w[b]) - assert_equal b, b2 - end + define_method 'test_lowest_common_ancestor for whole extended family' do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t112 = @tag_class.create!(name: 't112', parent: t11) + t12 = @tag_class.create!(name: 't12', parent: t1) + t121 = @tag_class.create!(name: 't121', parent: t12) + t2 = @tag_class.create!(name: 't2') + t21 = @tag_class.create!(name: 't21', parent: t2) + t211 = @tag_class.create!(name: 't211', parent: t21) + + assert_equal t1, @tag_class.lowest_common_ancestor(t1, t11, t111, t112, t12, t121) + assert_equal t2, @tag_class.lowest_common_ancestor(t2, t21, t211) + assert_equal t1, @tag_class.lowest_common_ancestor([t1, t11, t111, t112, t12, t121]) + assert_equal t2, @tag_class.lowest_common_ancestor([t2, t21, t211]) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t1 t11 t111 t112 t12 t121])) + assert_equal t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t2 t21 t211])) + end - it 'find_by_path for 2 nodes' do - path = %w[a b c] - c = @tag_class.find_or_create_by_path path - permutations = path.permutation.to_a - correct = %w[b c] - assert_equal c, c.root.find_by_path(correct) - (permutations - correct).each do |bad_path| - assert_nil c.root.find_by_path(bad_path) - end - end + define_method 'test_lowest_common_ancestor is nil for no items' do + assert_nil @tag_class.lowest_common_ancestor + assert_nil @tag_class.lowest_common_ancestor([]) + assert_nil @tag_class.lowest_common_ancestor(@tag_class.none) + end - it 'find_by_path for 3 nodes' do - d = @tag_class.find_or_create_by_path %w[a b c d] - assert_equal d, d.root.find_by_path(%w[b c d]) - assert_equal d, @tag_class.find_by_path(%w[a b c d]) - assert_nil @tag_class.find_by_path(%w[d]) - end + define_method 'test_lowest_common_ancestor is nil for no common ancestors' do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t2 = @tag_class.create!(name: 't2') + t21 = @tag_class.create!(name: 't21', parent: t2) + t211 = @tag_class.create!(name: 't211', parent: t21) + + assert_nil @tag_class.lowest_common_ancestor(t111, t211) + assert_nil @tag_class.lowest_common_ancestor([t111, t211]) + assert_nil @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t111 t211])) + end - it 'should return nil for missing nodes' do - assert_nil @tag_class.find_by_path(%w[missing]) - assert_nil @tag_class.find_by_path(%w[grandparent missing]) - assert_nil @tag_class.find_by_path(%w[grandparent parent missing]) - assert_nil @tag_class.find_by_path(%w[grandparent parent missing child]) - end + define_method 'test_lowest_common_ancestor is itself for single item' do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t2 = @tag_class.create!(name: 't2') + + assert_equal t111, @tag_class.lowest_common_ancestor(t111) + assert_equal t2, @tag_class.lowest_common_ancestor(t2) + assert_equal t111, @tag_class.lowest_common_ancestor([t111]) + assert_equal t2, @tag_class.lowest_common_ancestor([t2]) + assert_equal t111, @tag_class.lowest_common_ancestor(@tag_class.where(name: 't111')) + assert_equal t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: 't2')) + end - describe '.find_or_create_by_path' do - it 'uses existing records' do - grandparent = @tag_class.find_or_create_by_path(%w[grandparent]) - assert_equal grandparent, grandparent - child = @tag_class.find_or_create_by_path(%w[grandparent parent child]) - assert_equal child, child - end - - it 'creates 2-deep trees with strings' do - subject = @tag_class.find_or_create_by_path(%w[events anniversary]) - assert_equal %w[events anniversary], subject.ancestry_path - end - - it 'creates 2-deep trees with hashes' do - subject = @tag_class.find_or_create_by_path([ - { name: 'test1', title: 'TEST1' }, - { name: 'test2', title: 'TEST2' } - ]) - assert_equal %w[test1 test2], subject.ancestry_path - # `self_and_ancestors` and `ancestors` is ordered parent-first. (!!) - assert_equal %w[TEST2 TEST1], subject.self_and_ancestors.map(&:title) - end - end + define_method 'test_builds ancestry path' do + child = @tag_class.find_or_create_by_path([ + { name: 'grandparent', title: 'Nonnie' }, + { name: 'parent', title: 'Mom' }, + { name: 'child', title: 'Kid' } + ]) + parent = child.parent + parent.parent + + assert_equal %w[grandparent parent child], child.ancestry_path + assert_equal %w[grandparent parent child], child.ancestry_path(:name) + assert_equal %w[Nonnie Mom Kid], child.ancestry_path(:title) + end + + define_method 'test_assembles ancestors' do + child = @tag_class.find_or_create_by_path([ + { name: 'grandparent', title: 'Nonnie' }, + { name: 'parent', title: 'Mom' }, + { name: 'child', title: 'Kid' } + ]) + parent = child.parent + grandparent = parent.parent + + assert_equal [parent, grandparent], child.ancestors + assert_equal [child, parent, grandparent], child.self_and_ancestors + end + + define_method 'test_finds by path' do + child = @tag_class.find_or_create_by_path([ + { name: 'grandparent', title: 'Nonnie' }, + { name: 'parent', title: 'Mom' }, + { name: 'child', title: 'Kid' } + ]) + parent = child.parent + grandparent = parent.parent + + assert_equal child, @tag_class.find_by_path(%w[grandparent parent child]) + assert_equal child, parent.find_by_path(%w[child]) + assert_equal child, grandparent.find_by_path(%w[parent child]) + assert_nil parent.find_by_path(%w[child larvae]) + end + + define_method 'test_respects attribute hashes with both selection and creation' do + grandparent = @tag_class.find_or_create_by_path([ + { name: 'grandparent', title: 'Nonnie' } + ]) + + expected_title = 'something else' + attrs = { title: expected_title } + existing_title = grandparent.title + new_grandparent = @tag_class.find_or_create_by_path(%w[grandparent], attrs) + refute_equal grandparent, new_grandparent + assert_equal expected_title, new_grandparent.title + assert_equal existing_title, grandparent.reload.title + end + + define_method 'test_creates hierarchy with given attribute' do + expected_title = 'unicorn rainbows' + attrs = { title: expected_title } + child = @tag_class.find_or_create_by_path(%w[grandparent parent child], attrs) + + [child, child.parent, child.parent.parent].each do |ea| + assert_equal expected_title, ea.title end + end - describe 'hash_tree' do - before do - @d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] - @c1 = @d1.parent - @b = @c1.parent - @a = @b.parent - @a2 = @tag_class.create(name: 'a2') - @b2 = @tag_class.find_or_create_by_path %w[a b2] - @c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] - @b3 = @c3.parent - @a3 = @b3.parent - - @tree2 = { - @a => { @b => {}, @b2 => {} }, @a2 => {}, @a3 => { @b3 => {} } - } - - @one_tree = { - @a => {}, - @a2 => {}, - @a3 => {} - } - - @two_tree = { - @a => { - @b => {}, - @b2 => {} - }, - @a2 => {}, - @a3 => { - @b3 => {} - } - } - - @three_tree = { - @a => { - @b => { - @c1 => {} - }, - @b2 => {} - }, - @a2 => {}, - @a3 => { - @b3 => { - @c3 => {} - } - } - } - - @full_tree = { - @a => { - @b => { - @c1 => { - @d1 => {} - } - }, - @b2 => {} - }, - @a2 => {}, - @a3 => { - @b3 => { - @c3 => {} - } - } - } - end + define_method 'test_finds correctly rooted paths' do + _decoy = @tag_class.find_or_create_by_path %w[a b c d] + b_d = @tag_class.find_or_create_by_path %w[b c d] + assert_equal b_d, @tag_class.find_by_path(%w[b c d]) + assert_nil @tag_class.find_by_path(%w[c d]) + end + + define_method 'test_find_by_path for 1 node' do + b = @tag_class.find_or_create_by_path %w[a b] + b2 = b.root.find_by_path(%w[b]) + assert_equal b, b2 + end - describe '#hash_tree' do - it 'returns {} for depth 0' do - assert_equal({}, @tag_class.hash_tree(limit_depth: 0)) - end + define_method 'test_find_by_path for 2 nodes' do + path = %w[a b c] + c = @tag_class.find_or_create_by_path path + permutations = path.permutation.to_a + correct = %w[b c] + assert_equal c, c.root.find_by_path(correct) + (permutations - correct).each do |bad_path| + assert_nil c.root.find_by_path(bad_path) + end + end - it 'limit_depth 1' do - assert_equal @one_tree, @tag_class.hash_tree(limit_depth: 1) - end + define_method 'test_find_by_path for 3 nodes' do + d = @tag_class.find_or_create_by_path %w[a b c d] + assert_equal d, d.root.find_by_path(%w[b c d]) + assert_equal d, @tag_class.find_by_path(%w[a b c d]) + assert_nil @tag_class.find_by_path(%w[d]) + end - it 'limit_depth 2' do - assert_equal @two_tree, @tag_class.hash_tree(limit_depth: 2) - end + define_method 'test_returns nil for missing nodes' do + assert_nil @tag_class.find_by_path(%w[missing]) + assert_nil @tag_class.find_by_path(%w[grandparent missing]) + assert_nil @tag_class.find_by_path(%w[grandparent parent missing]) + assert_nil @tag_class.find_by_path(%w[grandparent parent missing child]) + end - it 'limit_depth 3' do - assert_equal @three_tree, @tag_class.hash_tree(limit_depth: 3) - end + define_method 'test_find_or_create_by_path uses existing records' do + grandparent = @tag_class.find_or_create_by_path(%w[grandparent]) + assert_equal grandparent, grandparent + child = @tag_class.find_or_create_by_path(%w[grandparent parent child]) + assert_equal child, child + end - it 'limit_depth 4' do - assert_equal @full_tree, @tag_class.hash_tree(limit_depth: 4) - end + define_method 'test_find_or_create_by_path creates 2-deep trees with strings' do + subject = @tag_class.find_or_create_by_path(%w[events anniversary]) + assert_equal %w[events anniversary], subject.ancestry_path + end - it 'no limit' do - assert_equal @full_tree, @tag_class.hash_tree - end - end + define_method 'test_find_or_create_by_path creates 2-deep trees with hashes' do + subject = @tag_class.find_or_create_by_path([ + { name: 'test1', title: 'TEST1' }, + { name: 'test2', title: 'TEST2' } + ]) + assert_equal %w[test1 test2], subject.ancestry_path + assert_equal %w[TEST2 TEST1], subject.self_and_ancestors.map(&:title) + end - describe '.hash_tree' do - it 'returns {} for depth 0' do - assert_equal({}, @b.hash_tree(limit_depth: 0)) - end + define_method 'test_hash_tree returns {} for depth 0' do + @tag_class.find_or_create_by_path %w[a b c1 d1] + assert_equal({}, @tag_class.hash_tree(limit_depth: 0)) + end - it 'limit_depth 1' do - assert_equal @two_tree[@a].slice(@b), @b.hash_tree(limit_depth: 1) - end + define_method 'test_hash_tree limit_depth 1' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + a = d1.root + a2 = @tag_class.create(name: 'a2') + a3 = @tag_class.find_or_create_by_path(%w[a3 b3 c3]).root - it 'limit_depth 2' do - assert_equal @three_tree[@a].slice(@b), @b.hash_tree(limit_depth: 2) - end + one_tree = { a => {}, a2 => {}, a3 => {} } + assert_equal one_tree, @tag_class.hash_tree(limit_depth: 1) + end - it 'limit_depth 3' do - assert_equal @full_tree[@a].slice(@b), @b.hash_tree(limit_depth: 3) - end + define_method 'test_hash_tree limit_depth 2' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + a3 = b3.parent + + two_tree = { + a => { b => {}, b2 => {} }, + a2 => {}, + a3 => { b3 => {} } + } + assert_equal two_tree, @tag_class.hash_tree(limit_depth: 2) + end - it 'no limit from subsubroot' do - assert_equal @full_tree[@a][@b].slice(@c1), @c1.hash_tree - end + define_method 'test_hash_tree limit_depth 3' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + a3 = b3.parent + + three_tree = { + a => { b => { c1 => {} }, b2 => {} }, + a2 => {}, + a3 => { b3 => { c3 => {} } } + } + assert_equal three_tree, @tag_class.hash_tree(limit_depth: 3) + end - it 'no limit from subroot' do - assert_equal @full_tree[@a].slice(@b), @b.hash_tree - end + define_method 'test_hash_tree limit_depth 4' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + a3 = b3.parent + + full_tree = { + a => { b => { c1 => { d1 => {} } }, b2 => {} }, + a2 => {}, + a3 => { b3 => { c3 => {} } } + } + assert_equal full_tree, @tag_class.hash_tree(limit_depth: 4) + end - it 'no limit from root' do - assert_equal @full_tree.slice(@a, @a2), @a.hash_tree.merge(@a2.hash_tree) - end - end + define_method 'test_hash_tree no limit' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + a3 = b3.parent + + full_tree = { + a => { b => { c1 => { d1 => {} } }, b2 => {} }, + a2 => {}, + a3 => { b3 => { c3 => {} } } + } + assert_equal full_tree, @tag_class.hash_tree + end - describe '.hash_tree from relations' do - it 'limit_depth 2 from chained activerecord association subroots' do - assert_equal @three_tree[@a], @a.children.hash_tree(limit_depth: 2) - end + define_method 'test_instance hash_tree returns {} for depth 0' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + b = d1.parent.parent + assert_equal({}, b.hash_tree(limit_depth: 0)) + end - it 'no limit from chained activerecord association subroots' do - assert_equal @full_tree[@a], @a.children.hash_tree - end + define_method 'test_instance hash_tree limit_depth 1' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + b2 = @tag_class.find_or_create_by_path %w[a b2] - it 'limit_depth 3 from b.parent' do - assert_equal @three_tree.slice(@a), @b.parent.hash_tree(limit_depth: 3) - end + two_tree = { a => { b => {}, b2 => {} } } + assert_equal two_tree[a].slice(b), b.hash_tree(limit_depth: 1) + end - it 'no limit_depth from b.parent' do - assert_equal @full_tree.slice(@a), @b.parent.hash_tree - end + define_method 'test_instance hash_tree no limit from subroot' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + b2 = @tag_class.find_or_create_by_path %w[a b2] - it 'no limit_depth from c.parent' do - assert_equal @full_tree[@a].slice(@b), @c1.parent.hash_tree - end - end - end + full_tree = { a => { b => { c1 => { d1 => {} } }, b2 => {} } } + assert_equal full_tree[a].slice(b), b.hash_tree + end - it 'finds_by_path for very deep trees' do - path = (1..20).to_a.map(&:to_s) - subject = @tag_class.find_or_create_by_path(path) - assert_equal path, subject.ancestry_path - assert_equal subject, @tag_class.find_by_path(path) - root = subject.root - assert_equal subject, root.find_by_path(path[1..]) - end + define_method 'test_hash_tree from chained associations' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + b2 = @tag_class.find_or_create_by_path %w[a b2] - describe 'DOT rendering' do - it 'should render for an empty scope' do - assert_equal "digraph G {\n}\n", @tag_class.to_dot_digraph(@tag_class.where('0=1')) - end + full_tree = { a => { b => { c1 => { d1 => {} } }, b2 => {} } } + assert_equal full_tree[a], a.children.hash_tree + end - it 'should render for an empty scope' do - @tag_class.find_or_create_by_path(%w[a b1 c1]) - @tag_class.find_or_create_by_path(%w[a b2 c2]) - @tag_class.find_or_create_by_path(%w[a b2 c3]) - a, b1, b2, c1, c2, c3 = %w[a b1 b2 c1 c2 c3].map { |ea| @tag_class.where(name: ea).first.id } - dot = @tag_class.roots.first.to_dot_digraph - - graph = <<~DOT - digraph G { - "#{a}" [label="a"] - "#{a}" -> "#{b1}" - "#{b1}" [label="b1"] - "#{a}" -> "#{b2}" - "#{b2}" [label="b2"] - "#{b1}" -> "#{c1}" - "#{c1}" [label="c1"] - "#{b2}" -> "#{c2}" - "#{c2}" [label="c2"] - "#{b2}" -> "#{c3}" - "#{c3}" [label="c3"] - } - DOT - - assert_equal(graph, dot) - end - end + define_method 'test_finds_by_path for very deep trees' do + path = (1..20).to_a.map(&:to_s) + subject = @tag_class.find_or_create_by_path(path) + assert_equal path, subject.ancestry_path + assert_equal subject, @tag_class.find_by_path(path) + root = subject.root + assert_equal subject, root.find_by_path(path[1..]) + end - describe '.depth' do - it 'should render for an empty scope' do - @tag_class.find_or_create_by_path(%w[a b1 c1]) - @tag_class.find_or_create_by_path(%w[a b2 c2]) - @tag_class.find_or_create_by_path(%w[a b2 c3]) - a, b1, b2, c1, c2, c3 = %w[a b1 b2 c1 c2 c3].map { |ea| @tag_class.where(name: ea).first.id } - dot = @tag_class.roots.first.to_dot_digraph - - graph = <<~DOT - digraph G { - "#{a}" [label="a"] - "#{a}" -> "#{b1}" - "#{b1}" [label="b1"] - "#{a}" -> "#{b2}" - "#{b2}" [label="b2"] - "#{b1}" -> "#{c1}" - "#{c1}" [label="c1"] - "#{b2}" -> "#{c2}" - "#{c2}" [label="c2"] - "#{b2}" -> "#{c3}" - "#{c3}" [label="c3"] - } - DOT - - assert_equal(graph, dot) - end - end + define_method 'test_DOT rendering for empty scope' do + assert_equal "digraph G {\n}\n", @tag_class.to_dot_digraph(@tag_class.where('0=1')) + end - describe '.depth' do - before do - @d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] - @c1 = @d1.parent - @b = @c1.parent - @a = @b.parent - @a2 = @tag_class.create(name: 'a2') - @b2 = @tag_class.find_or_create_by_path %w[a b2] - @c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] - @b3 = @c3.parent - @a3 = @b3.parent - - - end + define_method 'test_DOT rendering for tree' do + @tag_class.find_or_create_by_path(%w[a b1 c1]) + @tag_class.find_or_create_by_path(%w[a b2 c2]) + @tag_class.find_or_create_by_path(%w[a b2 c3]) + a, b1, b2, c1, c2, c3 = %w[a b1 b2 c1 c2 c3].map { |ea| @tag_class.where(name: ea).first.id } + dot = @tag_class.roots.first.to_dot_digraph + + graph = <<~DOT + digraph G { + "#{a}" [label="a"] + "#{a}" -> "#{b1}" + "#{b1}" [label="b1"] + "#{a}" -> "#{b2}" + "#{b2}" [label="b2"] + "#{b1}" -> "#{c1}" + "#{c1}" [label="c1"] + "#{b2}" -> "#{c2}" + "#{c2}" [label="c2"] + "#{b2}" -> "#{c3}" + "#{c3}" [label="c3"] + } + DOT + + assert_equal(graph, dot) + end - it 'should return 0 for root' do - assert_equal 0, @a.depth - assert_equal 0, @a2.depth - assert_equal 0, @a3.depth - end + define_method 'test_depth returns 0 for root' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + a3 = c3.parent.parent + + assert_equal 0, a.depth + assert_equal 0, a2.depth + assert_equal 0, a3.depth + end - it 'should return correct depth for nodes' do - assert_equal 1, @b.depth - assert_equal 2, @c1.depth - assert_equal 3, @d1.depth - assert_equal 1, @b2.depth - assert_equal 1, @b3.depth - assert_equal 2, @c3.depth - end - end + define_method 'test_depth returns correct depth for nodes' do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + + assert_equal 1, b.depth + assert_equal 2, c1.depth + assert_equal 3, d1.depth + assert_equal 1, b2.depth + assert_equal 1, b3.depth + assert_equal 2, c3.depth end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index f3ae3fc1..0b0d022f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,150 +1,56 @@ # frozen_string_literal: true -require 'logger' -require 'erb' -require 'active_record' -require 'with_advisory_lock' -require 'tmpdir' require 'securerandom' -require 'minitest' +ENV['RAILS_ENV'] = 'test' +ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex + +require 'dotenv' +Dotenv.load + +require_relative 'dummy/config/environment' +require 'rails/test_help' +require 'with_advisory_lock' + require 'minitest/autorun' require 'database_cleaner' +require 'database_cleaner/active_record' require 'support/query_counter' require 'parallel' require 'timecop' -# JRuby has issues with Timecop and ActiveRecord datetime casting -# Skip Timecop-dependent tests on JRuby -if defined?(JRUBY_VERSION) - puts "Warning: Timecop tests may fail on JRuby due to Time class incompatibilities" -end - -# Configure the database based on environment -database_url = ENV['DB_ADAPTER'] || ENV['DATABASE_URL'] || "sqlite3:///:memory:" - -ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex - -# Parse database URL and establish connection -connection_config = if database_url.start_with?('sqlite3://') - # SQLite needs special handling - if database_url == 'sqlite3:///:memory:' - { adapter: 'sqlite3', database: ':memory:' } - else - # Create a temporary database file - db_file = File.join(Dir.tmpdir, "closure_tree_test_#{SecureRandom.hex}.sqlite3") - { adapter: 'sqlite3', database: db_file } - end -elsif database_url.start_with?('mysql2://') - # Parse MySQL URL: mysql2://root:root@0/closure_tree_test - # The @0 means localhost in GitHub Actions - database_url.gsub('@0/', '@127.0.0.1/') -elsif database_url.start_with?('postgres://') - # Parse PostgreSQL URL: postgres://closure_tree:closure_tree@0/closure_tree_test - # The @0 means localhost in GitHub Actions - fixed_url = database_url.gsub('@0/', '@127.0.0.1/') - # PostgreSQL adapter expects 'postgresql://' not 'postgres://' - fixed_url.gsub('postgres://', 'postgresql://') -else - # For other database URLs, use directly - database_url -end - -# Set connection pool size for parallel tests -if connection_config.is_a?(Hash) - connection_config[:pool] = 50 - connection_config[:checkout_timeout] = 10 - # Add JRuby-specific properties if needed - if defined?(JRUBY_VERSION) - connection_config[:properties] ||= {} - connection_config[:properties][:allowPublicKeyRetrieval] = true - end - ActiveRecord::Base.establish_connection(connection_config) -else - # For URL-based configs, append pool parameters - separator = connection_config.include?('?') ? '&' : '?' - ActiveRecord::Base.establish_connection("#{connection_config}#{separator}pool=50&checkout_timeout=10") -end - -def env_db - @env_db ||= ActiveRecord::Base.connection_db_config.adapter.to_sym -end - -ActiveRecord::Migration.verbose = false -ActiveRecord::Base.table_name_prefix = ENV['DB_PREFIX'].to_s -ActiveRecord::Base.table_name_suffix = ENV['DB_SUFFIX'].to_s - -# Use in specs to skip some tests -def sqlite? - env_db == :sqlite3 -end - -# For PostgreSQL and MySQL, we need to create/reset the database structure -unless sqlite? - begin - if ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql') - # PostgreSQL requires disconnecting before dropping the database - ActiveRecord::Base.connection.disconnect! - # Connect to postgres database to drop/create closure_tree_test - if connection_config.is_a?(String) - # Parse the DATABASE_URL and change database to postgres - postgres_url = connection_config.gsub(/\/closure_tree_test/, '/postgres') - ActiveRecord::Base.establish_connection(postgres_url) - else - ActiveRecord::Base.establish_connection(connection_config.merge(database: 'postgres')) - end - ActiveRecord::Base.connection.drop_database('closure_tree_test') rescue nil - ActiveRecord::Base.connection.create_database('closure_tree_test') - ActiveRecord::Base.connection.disconnect! - ActiveRecord::Base.establish_connection(connection_config) - else - # MySQL can recreate directly - ActiveRecord::Base.connection.recreate_database('closure_tree_test') - end - rescue => e - puts "Warning: Could not recreate database: #{e.message}" - end -end -puts "Testing with #{env_db} database, ActiveRecord #{ActiveRecord.gem_version} and #{RUBY_ENGINE} #{RUBY_ENGINE_VERSION} as #{RUBY_VERSION}" - -DatabaseCleaner.strategy = :truncation -# Allow DatabaseCleaner to work with our test database -DatabaseCleaner.allow_remote_database_url = true - -module Minitest - class Spec - include QueryCounter - - before :each do - ENV['FLOCK_DIR'] = Dir.mktmpdir - DatabaseCleaner.start - end +# Configure parallel tests +Thread.abort_on_exception = true - after :each do - FileUtils.remove_entry_secure ENV['FLOCK_DIR'] - DatabaseCleaner.clean +class ActiveSupport::TestCase + # Configure DatabaseCleaner + self.use_transactional_tests = false + parallelize(workers: 1) + + def self.startup + # Validate environment variables when tests actually start running + %w[DATABASE_URL_PG DATABASE_URL_MYSQL].each do |var| + warn "Warning: Missing environment variable: #{var}" if ENV[var].nil? || ENV[var].empty? end end -end -class ActiveSupport::TestCase setup do + # Configure DatabaseCleaner for each database connection + DatabaseCleaner[:active_record, db: ApplicationRecord].strategy = :truncation + DatabaseCleaner[:active_record, db: MysqlRecord].strategy = :truncation + DatabaseCleaner[:active_record, db: SqliteRecord].strategy = :truncation + DatabaseCleaner.start end - + teardown do DatabaseCleaner.clean end - def exceed_query_limit(num, &block) + def exceed_query_limit(num, &) counter = QueryCounter.new - ActiveSupport::Notifications.subscribed(counter.to_proc, 'sql.active_record', &block) + ActiveSupport::Notifications.subscribed(counter.to_proc, 'sql.active_record', &) assert counter.query_count <= num, "Expected to run maximum #{num} queries, but ran #{counter.query_count}" end - - # Helper method to skip tests on JRuby - def skip_on_jruby(message = "Skipping on JRuby") - skip message if defined?(JRUBY_VERSION) - end class QueryCounter attr_reader :query_count @@ -154,22 +60,37 @@ def initialize end def to_proc - lambda(&method(:callback)) + method(:callback) end - def callback(name, start, finish, message_id, values) - @query_count += 1 unless %w(CACHE SCHEMA).include?(values[:name]) + def callback(_name, _start, _finish, _message_id, values) + @query_count += 1 unless %w[CACHE SCHEMA].include?(values[:name]) end end end -# Configure parallel tests -Thread.abort_on_exception = true +# Helper methods available globally +def env_db(connection = ActiveRecord::Base.connection) + connection.adapter_name.downcase.to_sym +end -# Configure advisory_lock -# See: https://github.com/ClosureTree/with_advisory_lock -ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex +def sqlite?(connection = ActiveRecord::Base.connection) + env_db(connection) == :sqlite3 +end + +def postgresql?(connection = ActiveRecord::Base.connection) + env_db(connection) == :postgresql +end + +def mysql?(connection = ActiveRecord::Base.connection) + %i[mysql2 trilogy].include?(env_db(connection)) +end + +# Load support files +require_relative 'support/query_counter' + +# Include QueryCounter in Minitest +Minitest::Test.include QueryCounter -require 'closure_tree' -require_relative 'support/schema' -require_relative 'support/models' +puts "Testing ActiveRecord #{ActiveRecord.gem_version} and Ruby #{RUBY_VERSION}" +puts "Connection Pool size: #{ActiveRecord::Base.connection_pool.size}"