diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 219bc0b..0689d97 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,2 +1 @@ - {".":"8.0.0"} diff --git a/README.md b/README.md index d53345d..16a86ba 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ for a description of different tree storage algorithms. - [Polymorphic hierarchies with STI](#polymorphic-hierarchies-with-sti) - [Deterministic ordering](#deterministic-ordering) - [Concurrency](#concurrency) +- [Multi-Database Support](#multi-database-support) - [FAQ](#faq) - [Testing](#testing) - [Change log](#change-log) @@ -61,11 +62,11 @@ Note that closure_tree only supports ActiveRecord 7.2 and later, and has test co 3. Add `has_closure_tree` (or `acts_as_tree`, which is an alias of the same method) to your hierarchical model: ```ruby - class Tag < ActiveRecord::Base + class Tag < ApplicationRecord has_closure_tree end - class AnotherTag < ActiveRecord::Base + class AnotherTag < ApplicationRecord acts_as_tree end ``` @@ -82,7 +83,7 @@ Note that closure_tree only supports ActiveRecord 7.2 and later, and has test co You may want to also [add a column for deterministic ordering of children](#deterministic-ordering), but that's optional. ```ruby - class AddParentIdToTag < ActiveRecord::Migration + class AddParentIdToTag < ActiveRecord::Migration[7.2] def change add_column :tags, :parent_id, :integer end @@ -384,7 +385,7 @@ Polymorphic models using single table inheritance (STI) are supported: 2. Subclass the model class. You only need to add ```has_closure_tree``` to your base class: ```ruby -class Tag < ActiveRecord::Base +class Tag < ApplicationRecord has_closure_tree end class WhenTag < Tag ; end @@ -411,7 +412,7 @@ By default, children will be ordered by your database engine, which may not be w If you want to order children alphabetically, and your model has a ```name``` column, you'd do this: ```ruby -class Tag < ActiveRecord::Base +class Tag < ApplicationRecord has_closure_tree order: 'name' end ``` @@ -425,7 +426,7 @@ t.integer :sort_order and in your model: ```ruby -class OrderedTag < ActiveRecord::Base +class OrderedTag < ApplicationRecord has_closure_tree order: 'sort_order', numeric_order: true end ``` @@ -525,7 +526,7 @@ If you are already managing concurrency elsewhere in your application, and want of with_advisory_lock, pass ```with_advisory_lock: false``` in the options hash: ```ruby -class Tag +class Tag < ApplicationRecord has_closure_tree with_advisory_lock: false end ``` @@ -533,6 +534,98 @@ end Note that you *will eventually have data corruption* if you disable advisory locks, write to your database with multiple threads, and don't provide an alternative mutex. +### Customizing Advisory Lock Names + +By default, closure_tree generates advisory lock names based on the model class name. You can customize +this behavior in several ways: + +```ruby +# Static string +class Tag < ApplicationRecord + has_closure_tree advisory_lock_name: 'custom_tag_lock' +end + +# Dynamic via Proc +class Tag < ApplicationRecord + has_closure_tree advisory_lock_name: ->(model_class) { "#{Rails.env}_#{model_class.name.underscore}" } +end + +# Delegate to model method +class Tag < ApplicationRecord + has_closure_tree advisory_lock_name: :custom_lock_name + + def self.custom_lock_name + "tag_lock_#{current_tenant_id}" + end +end +``` + +This is particularly useful when: +* You need environment-specific lock names +* You're using multi-tenancy and need tenant-specific locks +* You want to avoid lock name collisions between similar model names + +## Multi-Database Support + +Closure Tree fully supports running with multiple databases simultaneously, including mixing different database engines (PostgreSQL, MySQL, SQLite) in the same application. This is particularly useful for: + +* Applications with read replicas +* Sharding strategies +* Testing with different database engines +* Gradual database migrations + +### Database-Specific Behaviors + +#### PostgreSQL +* Full support for advisory locks via `with_advisory_lock` +* Excellent concurrency support with row-level locking +* Best overall performance for tree operations + +#### MySQL +* Advisory locks supported via `with_advisory_lock` +* Note: MySQL's row-level locking may incorrectly report deadlocks in some cases +* Requires MySQL 5.7.12+ to avoid hierarchy maintenance errors + +#### SQLite +* **No advisory lock support** - always returns false from `with_advisory_lock` +* Falls back to file-based locking for tests +* Suitable for development and testing, but not recommended for production with concurrent writes + +### Configuration + +When using multiple databases, closure_tree automatically detects the correct adapter for each connection: + +```ruby +class Tag < ApplicationRecord + connects_to database: { writing: :primary, reading: :replica } + has_closure_tree +end + +class Category < ApplicationRecord + connects_to database: { writing: :sqlite_db } + has_closure_tree +end +``` + +Each model will use the appropriate database-specific SQL syntax and features based on its connection adapter. + +### Testing with Multiple Databases + +You can run the test suite against different databases: + +```bash +# Run with PostgreSQL +DATABASE_URL=postgres://localhost/closure_tree_test rake test + +# Run with MySQL +DATABASE_URL=mysql2://localhost/closure_tree_test rake test + +# Run with SQLite (default) +rake test +``` + +For simultaneous multi-database testing, the test suite automatically sets up connections to all three database types when available. + ## I18n You can customize error messages using [I18n](http://guides.rubyonrails.org/i18n.html): diff --git a/lib/closure_tree/has_closure_tree.rb b/lib/closure_tree/has_closure_tree.rb index 21b10f4..508274f 100644 --- a/lib/closure_tree/has_closure_tree.rb +++ b/lib/closure_tree/has_closure_tree.rb @@ -13,7 +13,8 @@ def has_closure_tree(options = {}) :dont_order_roots, :numeric_order, :touch, - :with_advisory_lock + :with_advisory_lock, + :advisory_lock_name ) class_attribute :_ct diff --git a/lib/closure_tree/support_attributes.rb b/lib/closure_tree/support_attributes.rb index 2d63c6f..fd30493 100644 --- a/lib/closure_tree/support_attributes.rb +++ b/lib/closure_tree/support_attributes.rb @@ -9,10 +9,29 @@ module SupportAttributes def_delegators :model_class, :connection, :transaction, :table_name, :base_class, :inheritance_column, :column_names def advisory_lock_name - # 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)}" + # Allow customization via options or instance method + if options[:advisory_lock_name] + case options[:advisory_lock_name] + when Proc + # Allow dynamic generation via proc + options[:advisory_lock_name].call(base_class) + when Symbol + # Allow delegation to a model method + if model_class.respond_to?(options[:advisory_lock_name]) + model_class.send(options[:advisory_lock_name]) + else + raise ArgumentError, "Model #{model_class} does not respond to #{options[:advisory_lock_name]}" + end + else + # Use static string value + options[:advisory_lock_name].to_s + end + else + # Default: 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 end def quoted_table_name diff --git a/test/closure_tree/advisory_lock_test.rb b/test/closure_tree/advisory_lock_test.rb new file mode 100644 index 0000000..553faaf --- /dev/null +++ b/test/closure_tree/advisory_lock_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Test for advisory lock name customization +class AdvisoryLockTest < ActiveSupport::TestCase + def setup + Tag.delete_all + Tag.hierarchy_class.delete_all + end + + def test_default_advisory_lock_name + tag = Tag.new + expected_name = "ct_#{Zlib.crc32(Tag.base_class.name.to_s).to_s(16)}" + assert_equal expected_name, tag._ct.advisory_lock_name + end + + def test_static_string_advisory_lock_name + with_temporary_model do + has_closure_tree advisory_lock_name: 'custom_lock_name' + end + + instance = @model_class.new + assert_equal 'custom_lock_name', instance._ct.advisory_lock_name + end + + def test_proc_advisory_lock_name + with_temporary_model do + has_closure_tree advisory_lock_name: ->(model) { "lock_for_#{model.name.underscore}" } + end + + instance = @model_class.new + assert_equal "lock_for_#{@model_class.name.underscore}", instance._ct.advisory_lock_name + end + + def test_symbol_advisory_lock_name + with_temporary_model do + has_closure_tree advisory_lock_name: :custom_lock_method + + def self.custom_lock_method + 'method_generated_lock' + end + end + + instance = @model_class.new + assert_equal 'method_generated_lock', instance._ct.advisory_lock_name + end + + def test_symbol_advisory_lock_name_raises_on_missing_method + with_temporary_model do + has_closure_tree advisory_lock_name: :non_existent_method + end + + instance = @model_class.new + assert_raises(ArgumentError) do + instance._ct.advisory_lock_name + end + end + + private + + def with_temporary_model(&block) + # Create a named temporary class + model_name = "TempModel#{Time.now.to_i}#{rand(1000)}" + + @model_class = Class.new(ApplicationRecord) do + self.table_name = 'tags' + end + + # Set the constant before calling has_closure_tree + Object.const_set(model_name, @model_class) + + # Create hierarchy class before calling has_closure_tree + hierarchy_class = Class.new(ApplicationRecord) do + self.table_name = 'tag_hierarchies' + end + Object.const_set("#{model_name}Hierarchy", hierarchy_class) + + # Now call has_closure_tree with the block + @model_class.instance_eval(&block) + + # Clean up constants after test + ObjectSpace.define_finalizer(self, proc { + Object.send(:remove_const, model_name) if Object.const_defined?(model_name) + Object.send(:remove_const, "#{model_name}Hierarchy") if Object.const_defined?("#{model_name}Hierarchy") + }) + end +end \ No newline at end of file