Skip to content

Commit d6ffd73

Browse files
authored
feat: Add runtime advisory lock name customization and multi-database documentation (#454)
release-as: 9.0.0
1 parent 23f8a56 commit d6ffd73

File tree

5 files changed

+213
-13
lines changed

5 files changed

+213
-13
lines changed

.release-please-manifest.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
21
{".":"8.0.0"}

README.md

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ for a description of different tree storage algorithms.
4646
- [Polymorphic hierarchies with STI](#polymorphic-hierarchies-with-sti)
4747
- [Deterministic ordering](#deterministic-ordering)
4848
- [Concurrency](#concurrency)
49+
- [Multi-Database Support](#multi-database-support)
4950
- [FAQ](#faq)
5051
- [Testing](#testing)
5152
- [Change log](#change-log)
@@ -61,11 +62,11 @@ Note that closure_tree only supports ActiveRecord 7.2 and later, and has test co
6162
3. Add `has_closure_tree` (or `acts_as_tree`, which is an alias of the same method) to your hierarchical model:
6263

6364
```ruby
64-
class Tag < ActiveRecord::Base
65+
class Tag < ApplicationRecord
6566
has_closure_tree
6667
end
6768

68-
class AnotherTag < ActiveRecord::Base
69+
class AnotherTag < ApplicationRecord
6970
acts_as_tree
7071
end
7172
```
@@ -82,7 +83,7 @@ Note that closure_tree only supports ActiveRecord 7.2 and later, and has test co
8283
You may want to also [add a column for deterministic ordering of children](#deterministic-ordering), but that's optional.
8384

8485
```ruby
85-
class AddParentIdToTag < ActiveRecord::Migration
86+
class AddParentIdToTag < ActiveRecord::Migration[7.2]
8687
def change
8788
add_column :tags, :parent_id, :integer
8889
end
@@ -384,7 +385,7 @@ Polymorphic models using single table inheritance (STI) are supported:
384385
2. Subclass the model class. You only need to add ```has_closure_tree``` to your base class:
385386
386387
```ruby
387-
class Tag < ActiveRecord::Base
388+
class Tag < ApplicationRecord
388389
has_closure_tree
389390
end
390391
class WhenTag < Tag ; end
@@ -411,7 +412,7 @@ By default, children will be ordered by your database engine, which may not be w
411412
If you want to order children alphabetically, and your model has a ```name``` column, you'd do this:
412413

413414
```ruby
414-
class Tag < ActiveRecord::Base
415+
class Tag < ApplicationRecord
415416
has_closure_tree order: 'name'
416417
end
417418
```
@@ -425,7 +426,7 @@ t.integer :sort_order
425426
and in your model:
426427

427428
```ruby
428-
class OrderedTag < ActiveRecord::Base
429+
class OrderedTag < ApplicationRecord
429430
has_closure_tree order: 'sort_order', numeric_order: true
430431
end
431432
```
@@ -525,14 +526,106 @@ If you are already managing concurrency elsewhere in your application, and want
525526
of with_advisory_lock, pass ```with_advisory_lock: false``` in the options hash:
526527

527528
```ruby
528-
class Tag
529+
class Tag < ApplicationRecord
529530
has_closure_tree with_advisory_lock: false
530531
end
531532
```
532533

533534
Note that you *will eventually have data corruption* if you disable advisory locks, write to your
534535
database with multiple threads, and don't provide an alternative mutex.
535536
537+
### Customizing Advisory Lock Names
538+
539+
By default, closure_tree generates advisory lock names based on the model class name. You can customize
540+
this behavior in several ways:
541+
542+
```ruby
543+
# Static string
544+
class Tag < ApplicationRecord
545+
has_closure_tree advisory_lock_name: 'custom_tag_lock'
546+
end
547+
548+
# Dynamic via Proc
549+
class Tag < ApplicationRecord
550+
has_closure_tree advisory_lock_name: ->(model_class) { "#{Rails.env}_#{model_class.name.underscore}" }
551+
end
552+
553+
# Delegate to model method
554+
class Tag < ApplicationRecord
555+
has_closure_tree advisory_lock_name: :custom_lock_name
556+
557+
def self.custom_lock_name
558+
"tag_lock_#{current_tenant_id}"
559+
end
560+
end
561+
```
562+
563+
This is particularly useful when:
564+
* You need environment-specific lock names
565+
* You're using multi-tenancy and need tenant-specific locks
566+
* You want to avoid lock name collisions between similar model names
567+
568+
## Multi-Database Support
569+
570+
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:
571+
572+
* Applications with read replicas
573+
* Sharding strategies
574+
* Testing with different database engines
575+
* Gradual database migrations
576+
577+
### Database-Specific Behaviors
578+
579+
#### PostgreSQL
580+
* Full support for advisory locks via `with_advisory_lock`
581+
* Excellent concurrency support with row-level locking
582+
* Best overall performance for tree operations
583+
584+
#### MySQL
585+
* Advisory locks supported via `with_advisory_lock`
586+
* Note: MySQL's row-level locking may incorrectly report deadlocks in some cases
587+
* Requires MySQL 5.7.12+ to avoid hierarchy maintenance errors
588+
589+
#### SQLite
590+
* **No advisory lock support** - always returns false from `with_advisory_lock`
591+
* Falls back to file-based locking for tests
592+
* Suitable for development and testing, but not recommended for production with concurrent writes
593+
594+
### Configuration
595+
596+
When using multiple databases, closure_tree automatically detects the correct adapter for each connection:
597+
598+
```ruby
599+
class Tag < ApplicationRecord
600+
connects_to database: { writing: :primary, reading: :replica }
601+
has_closure_tree
602+
end
603+
604+
class Category < ApplicationRecord
605+
connects_to database: { writing: :sqlite_db }
606+
has_closure_tree
607+
end
608+
```
609+
610+
Each model will use the appropriate database-specific SQL syntax and features based on its connection adapter.
611+
612+
### Testing with Multiple Databases
613+
614+
You can run the test suite against different databases:
615+
616+
```bash
617+
# Run with PostgreSQL
618+
DATABASE_URL=postgres://localhost/closure_tree_test rake test
619+
620+
# Run with MySQL
621+
DATABASE_URL=mysql2://localhost/closure_tree_test rake test
622+
623+
# Run with SQLite (default)
624+
rake test
625+
```
626+
627+
For simultaneous multi-database testing, the test suite automatically sets up connections to all three database types when available.
628+
536629
## I18n
537630
538631
You can customize error messages using [I18n](http://guides.rubyonrails.org/i18n.html):

lib/closure_tree/has_closure_tree.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ def has_closure_tree(options = {})
1313
:dont_order_roots,
1414
:numeric_order,
1515
:touch,
16-
:with_advisory_lock
16+
:with_advisory_lock,
17+
:advisory_lock_name
1718
)
1819

1920
class_attribute :_ct

lib/closure_tree/support_attributes.rb

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,29 @@ module SupportAttributes
99
def_delegators :model_class, :connection, :transaction, :table_name, :base_class, :inheritance_column, :column_names
1010

1111
def advisory_lock_name
12-
# Use CRC32 for a shorter, consistent hash
13-
# This gives us 8 hex characters which is plenty for uniqueness
14-
# and leaves room for prefixes
15-
"ct_#{Zlib.crc32(base_class.name.to_s).to_s(16)}"
12+
# Allow customization via options or instance method
13+
if options[:advisory_lock_name]
14+
case options[:advisory_lock_name]
15+
when Proc
16+
# Allow dynamic generation via proc
17+
options[:advisory_lock_name].call(base_class)
18+
when Symbol
19+
# Allow delegation to a model method
20+
if model_class.respond_to?(options[:advisory_lock_name])
21+
model_class.send(options[:advisory_lock_name])
22+
else
23+
raise ArgumentError, "Model #{model_class} does not respond to #{options[:advisory_lock_name]}"
24+
end
25+
else
26+
# Use static string value
27+
options[:advisory_lock_name].to_s
28+
end
29+
else
30+
# Default: Use CRC32 for a shorter, consistent hash
31+
# This gives us 8 hex characters which is plenty for uniqueness
32+
# and leaves room for prefixes
33+
"ct_#{Zlib.crc32(base_class.name.to_s).to_s(16)}"
34+
end
1635
end
1736

1837
def quoted_table_name
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
# Test for advisory lock name customization
6+
class AdvisoryLockTest < ActiveSupport::TestCase
7+
def setup
8+
Tag.delete_all
9+
Tag.hierarchy_class.delete_all
10+
end
11+
12+
def test_default_advisory_lock_name
13+
tag = Tag.new
14+
expected_name = "ct_#{Zlib.crc32(Tag.base_class.name.to_s).to_s(16)}"
15+
assert_equal expected_name, tag._ct.advisory_lock_name
16+
end
17+
18+
def test_static_string_advisory_lock_name
19+
with_temporary_model do
20+
has_closure_tree advisory_lock_name: 'custom_lock_name'
21+
end
22+
23+
instance = @model_class.new
24+
assert_equal 'custom_lock_name', instance._ct.advisory_lock_name
25+
end
26+
27+
def test_proc_advisory_lock_name
28+
with_temporary_model do
29+
has_closure_tree advisory_lock_name: ->(model) { "lock_for_#{model.name.underscore}" }
30+
end
31+
32+
instance = @model_class.new
33+
assert_equal "lock_for_#{@model_class.name.underscore}", instance._ct.advisory_lock_name
34+
end
35+
36+
def test_symbol_advisory_lock_name
37+
with_temporary_model do
38+
has_closure_tree advisory_lock_name: :custom_lock_method
39+
40+
def self.custom_lock_method
41+
'method_generated_lock'
42+
end
43+
end
44+
45+
instance = @model_class.new
46+
assert_equal 'method_generated_lock', instance._ct.advisory_lock_name
47+
end
48+
49+
def test_symbol_advisory_lock_name_raises_on_missing_method
50+
with_temporary_model do
51+
has_closure_tree advisory_lock_name: :non_existent_method
52+
end
53+
54+
instance = @model_class.new
55+
assert_raises(ArgumentError) do
56+
instance._ct.advisory_lock_name
57+
end
58+
end
59+
60+
private
61+
62+
def with_temporary_model(&block)
63+
# Create a named temporary class
64+
model_name = "TempModel#{Time.now.to_i}#{rand(1000)}"
65+
66+
@model_class = Class.new(ApplicationRecord) do
67+
self.table_name = 'tags'
68+
end
69+
70+
# Set the constant before calling has_closure_tree
71+
Object.const_set(model_name, @model_class)
72+
73+
# Create hierarchy class before calling has_closure_tree
74+
hierarchy_class = Class.new(ApplicationRecord) do
75+
self.table_name = 'tag_hierarchies'
76+
end
77+
Object.const_set("#{model_name}Hierarchy", hierarchy_class)
78+
79+
# Now call has_closure_tree with the block
80+
@model_class.instance_eval(&block)
81+
82+
# Clean up constants after test
83+
ObjectSpace.define_finalizer(self, proc {
84+
Object.send(:remove_const, model_name) if Object.const_defined?(model_name)
85+
Object.send(:remove_const, "#{model_name}Hierarchy") if Object.const_defined?("#{model_name}Hierarchy")
86+
})
87+
end
88+
end

0 commit comments

Comments
 (0)