Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add virtual depth column support #670

Merged
merged 4 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions lib/ancestry/has_ancestry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,7 @@ def has_ancestry options = {}

# Include dynamic class methods
extend Ancestry::ClassMethods

if ancestry_format == :materialized_path2
extend Ancestry::MaterializedPath2
else
extend Ancestry::MaterializedPath
end
extend Ancestry::HasAncestry.ancestry_format_module(ancestry_format)

attribute self.ancestry_column, default: self.ancestry_root

Expand All @@ -69,7 +64,11 @@ def has_ancestry options = {}
end

# Create ancestry column accessor and set to option or default
if options[:cache_depth]

if options[:cache_depth] == :virtual
# NOTE: not setting self.depth_cache_column so the code does not try to update the column
depth_cache_sql = options[:depth_cache_column]&.to_s || 'ancestry_depth'
elsif options[:cache_depth]
# Create accessor for column name and set to option or default
self.cattr_accessor :depth_cache_column
self.depth_cache_column =
Expand All @@ -89,20 +88,18 @@ def has_ancestry options = {}
# Validate depth column
validates_numericality_of depth_cache_column, :greater_than_or_equal_to => 0, :only_integer => true, :allow_nil => false

scope :before_depth, lambda { |depth| where("#{depth_cache_column} < ?", depth) }
scope :to_depth, lambda { |depth| where("#{depth_cache_column} <= ?", depth) }
scope :at_depth, lambda { |depth| where("#{depth_cache_column} = ?", depth) }
scope :from_depth, lambda { |depth| where("#{depth_cache_column} >= ?", depth) }
scope :after_depth, lambda { |depth| where("#{depth_cache_column} > ?", depth) }
depth_cache_sql = depth_cache_column
else
# this is not efficient, but it works
scope :before_depth, lambda { |depth| where("#{ancestry_depth_sql} < ?", depth) }
scope :to_depth, lambda { |depth| where("#{ancestry_depth_sql} <= ?", depth) }
scope :at_depth, lambda { |depth| where("#{ancestry_depth_sql} = ?", depth) }
scope :from_depth, lambda { |depth| where("#{ancestry_depth_sql} >= ?", depth) }
scope :after_depth, lambda { |depth| where("#{ancestry_depth_sql} > ?", depth) }
depth_cache_sql = ancestry_depth_sql
end

scope :before_depth, lambda { |depth| where("#{depth_cache_sql} < ?", depth) }
scope :to_depth, lambda { |depth| where("#{depth_cache_sql} <= ?", depth) }
scope :at_depth, lambda { |depth| where("#{depth_cache_sql} = ?", depth) }
scope :from_depth, lambda { |depth| where("#{depth_cache_sql} >= ?", depth) }
scope :after_depth, lambda { |depth| where("#{depth_cache_sql} > ?", depth) }

# Create counter cache column accessor and set to option or default
if options[:counter_cache]
cattr_accessor :counter_cache_column
Expand All @@ -124,6 +121,15 @@ def acts_as_tree(*args)
return super if defined?(super)
has_ancestry(*args)
end

def self.ancestry_format_module(ancestry_format)
ancestry_format ||= Ancestry.default_ancestry_format
if ancestry_format == :materialized_path2
Ancestry::MaterializedPath2
else
Ancestry::MaterializedPath
end
end
end
end

Expand Down
13 changes: 7 additions & 6 deletions lib/ancestry/materialized_path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,7 @@ def child_ancestry_sql
end

def ancestry_depth_sql
@ancestry_depth_sql ||=
begin
tmp = %{(LENGTH(#{table_name}.#{ancestry_column}) - LENGTH(REPLACE(#{table_name}.#{ancestry_column},'#{ancestry_delimiter}','')))}
tmp = tmp + "/#{ancestry_delimiter.size}" if ancestry_delimiter.size > 1
"(CASE WHEN #{table_name}.#{ancestry_column} IS NULL THEN 0 ELSE 1 + #{tmp} END)"
end
@ancestry_depth_sql ||= MaterializedPath.construct_depth_sql(table_name, ancestry_column, ancestry_delimiter)
end

def generate_ancestry(ancestor_ids)
Expand Down Expand Up @@ -135,6 +130,12 @@ def concat(*args)
end
end

def self.construct_depth_sql(table_name, ancestry_column, ancestry_delimiter)
tmp = %{(LENGTH(#{table_name}.#{ancestry_column}) - LENGTH(REPLACE(#{table_name}.#{ancestry_column},'#{ancestry_delimiter}','')))}
tmp = tmp + "/#{ancestry_delimiter.size}" if ancestry_delimiter.size > 1
"(CASE WHEN #{table_name}.#{ancestry_column} IS NULL THEN 0 ELSE 1 + #{tmp} END)"
end

private

def ancestry_validation_options(ancestry_primary_key_format)
Expand Down
14 changes: 8 additions & 6 deletions lib/ancestry/materialized_path2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,7 @@ def child_ancestry_sql
end

def ancestry_depth_sql
@ancestry_depth_sql ||=
begin
tmp = %{(LENGTH(#{table_name}.#{ancestry_column}) - LENGTH(REPLACE(#{table_name}.#{ancestry_column},'#{ancestry_delimiter}','')))}
tmp = tmp + "/#{ancestry_delimiter.size}" if ancestry_delimiter.size > 1
"(#{tmp} -1)"
end
@ancestry_depth_sql ||= MaterializedPath2.construct_depth_sql(table_name, ancestry_column, ancestry_delimiter)
end

def generate_ancestry(ancestor_ids)
Expand All @@ -49,6 +44,13 @@ def generate_ancestry(ancestor_ids)
end
end

# module method
def self.construct_depth_sql(table_name, ancestry_column, ancestry_delimiter)
tmp = %{(LENGTH(#{table_name}.#{ancestry_column}) - LENGTH(REPLACE(#{table_name}.#{ancestry_column},'#{ancestry_delimiter}','')))}
tmp = tmp + "/#{ancestry_delimiter.size}" if ancestry_delimiter.size > 1
"(#{tmp} -1)"
end

private

def ancestry_nil_allowed?
Expand Down
2 changes: 2 additions & 0 deletions test/concerns/db_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
class DbTest < ActiveSupport::TestCase
def test_does_not_load_database
c = Class.new(ActiveRecord::Base) do
self.table_name = "table"

def self.connection
raise "Oh No - tried to connect to database"
end
Expand Down
104 changes: 104 additions & 0 deletions test/concerns/depth_virtual_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
require_relative '../environment'

# These are only valid for postgres
class DepthVirtualTest < ActiveSupport::TestCase
def test_depth_caching
return unless test_virtual_column?

AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => :virtual do |_model, roots|
roots.each do |lvl0_node, lvl0_children|
assert_equal 0, lvl0_node.depth
lvl0_children.each do |lvl1_node, lvl1_children|
assert_equal 1, lvl1_node.depth
lvl1_children.each do |lvl2_node, _lvl2_children|
assert_equal 2, lvl2_node.depth
end
end
end
end
end

def test_depth_caching_after_subtree_movement
return unless test_virtual_column?

AncestryTestDatabase.with_model :depth => 6, :width => 1, :cache_depth => :virtual do |model, _roots|
node = model.at_depth(3).first
node.update(:parent => model.roots.first)
assert_equal(1, node.depth)
node.children.each do |child|
assert_equal(2, child.depth)
child.children.each do |gchild|
assert_equal(3, gchild.depth)
end
end
end
end

def test_depth_scopes
return unless test_virtual_column?

AncestryTestDatabase.with_model :depth => 4, :width => 2, :cache_depth => true do |model, _roots|
model.before_depth(2).all? { |node| assert node.depth < 2 }
model.to_depth(2).all? { |node| assert node.depth <= 2 }
model.at_depth(2).all? { |node| assert node.depth == 2 }
model.from_depth(2).all? { |node| assert node.depth >= 2 }
model.after_depth(2).all? { |node| assert node.depth > 2 }
end
end

def test_depth_scopes_without_depth_cache
return unless test_virtual_column?

AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, _roots|
model.before_depth(2).all? { |node| assert node.depth < 2 }
model.to_depth(2).all? { |node| assert node.depth <= 2 }
model.at_depth(2).all? { |node| assert node.depth == 2 }
model.from_depth(2).all? { |node| assert node.depth >= 2 }
model.after_depth(2).all? { |node| assert node.depth > 2 }
end
end

def test_exception_when_rebuilding_depth_cache_for_model_without_depth_caching
return unless test_virtual_column?

AncestryTestDatabase.with_model do |model|
assert_raise Ancestry::AncestryException do
model.rebuild_depth_cache!
end
end
end

def test_exception_on_unknown_depth_column
return unless test_virtual_column?

AncestryTestDatabase.with_model :cache_depth => true do |model|
assert_raise Ancestry::AncestryException do
model.create!.subtree(:this_is_not_a_valid_depth_option => 42)
end
end
end

# we are already testing generate and parse against static values
# this assumes those are methods are tested and working
def test_ancestry_depth_change
return unless test_virtual_column?

AncestryTestDatabase.with_model do |model|
{
[[], [1]] => +1,
[[1], []] => -1,
[[1], [2]] => 0,
[[1], [1, 2, 3]] => +2,
[[1, 2, 3], [1]] => -2
}.each do |(before, after), diff|
a_before = model.generate_ancestry(before)
a_after = model.generate_ancestry(after)
assert_equal(diff, model.ancestry_depth_change(a_before, a_after))
end
end
end

def test_virtual_column?
AncestryTestDatabase.postgres? && ActiveRecord.version.to_s >= "7.0"
end
end
15 changes: 14 additions & 1 deletion test/environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,20 @@ def self.with_model options = {}

ActiveRecord::Base.connection.create_table 'test_nodes', **table_options do |table|
table.send(column_type, options[:ancestry_column], **column_options(force_allow_nil: skip_ancestry))
table.integer options[:cache_depth] == true ? :ancestry_depth : options[:cache_depth] if options[:cache_depth]
case options[:cache_depth]
when true
table.integer :ancestry_depth
when :virtual
# sorry, this duplicates has_ancestry a little
path_module = Ancestry::HasAncestry.ancestry_format_module(options[:ancestry_format])
ancestry_depth_sql = path_module.construct_depth_sql("test_nodes", options[:ancestry_column], '/')

table.virtual :ancestry_depth, type: :integer, as: ancestry_depth_sql, stored: true
when nil, false
# no column
else
table.integer options[:cache_depth]
end
if options[:counter_cache]
counter_cache_column = options[:counter_cache] == true ? :children_count : options[:counter_cache]
table.integer counter_cache_column, default: 0, null: false
Expand Down