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

[POC] rails attribute for ancestor_ids #481

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
71 changes: 71 additions & 0 deletions lib/ancestry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,75 @@ def self.default_update_strategy
def self.default_update_strategy=(value)
@@default_update_strategy = value
end

# used for materialized path
class MaterializedPathString < ActiveRecord::Type::Value
def initialize(casting: :to_i, delimiter: '/')
@casting = casting&.to_proc
@delimiter = delimiter
end

def type
:materialized_path_string
end

# convert to database type
def serialize(value)
if value.kind_of?(Array)
value.map(&:to_s).join(@delimiter).presence
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if we want an empty ancestry to have [] or nil

technically null means that the value is undetermined. But in our case, we want to say "none" which is "" and not nil.

elsif value.kind_of?(Integer)
value.to_s
elsif value.nil? || value.kind_of?(String)
value
else
byebug
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we want to handle this case a little differently

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol

puts "curious type: #{value.class}"
end
end

def cast(value)
cast_value(value) #unless value.nil? (want to get rid of this - fix default value)
end

# called by cast (form or setter) or deserialize (database)
def cast_value(value)
if value.kind_of?(Array)
super
elsif value.nil?
# would prefer to use default here
# but with default, it kept thinking the field had changed when it hadn't
super([])
else
#TODO: test ancestry=1
super(value.to_s.split(@delimiter).map(&@casting))
end
end
end
end
ActiveRecord::Type.register(:materialized_path_string, Ancestry::MaterializedPathString)

class ArrayPatternValidator < ActiveModel::EachValidator
def initialize(options)
raise ArgumentError, "Pattern unspecified, Specify using :pattern" unless options[:pattern]

options[:pattern] = /\A#{options[:pattern].to_s}\Z/ unless options[:pattern].to_s.include?('\A')
options[:id] = true unless options.key?(:id)
options[:integer] = true unless options.key?(:integer)

super
end

def validate_each(record, attribute, value)
if options[:id] && value.include?(record.id)
record.errors.add(attribute, I18n.t("ancestry.exclude_self", {:class_name => self.class.name.humanize}))
end

if value.any? { |v| v.to_s !~ options[:pattern] }
record.errors.add(attribute, "illegal characters")
end

if options[:integer] && value.any? { |v| v < 1 }
record.errors.add(attribute, "non positive ancestor id")
end
end
end
15 changes: 4 additions & 11 deletions lib/ancestry/class_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,14 @@ def arrange_serializable options={}, nodes=nil, &block
end

# Pseudo-preordered array of nodes. Children will always follow parents,
# for ordering nodes within a rank provide block, eg. Node.sort_by_ancestry(Node.all) {|a, b| a.rank <=> b.rank}.
EMPTY_ANCESTRY=[0].freeze
def sort_by_ancestry(nodes, &block)
arranged = nodes if nodes.is_a?(Hash)

unless arranged
presorted_nodes = nodes.sort do |a, b|
a_cestry, b_cestry = a.ancestry || '0', b.ancestry || '0'
a_cestry, b_cestry = a.ancestor_ids || EMPTY_ANCESTRY, b.ancestor_ids || EMPTY_ANCESTRY

if block_given? && a_cestry == b_cestry
yield a, b
Expand Down Expand Up @@ -130,7 +132,7 @@ def check_ancestry_integrity! options = {}
end
# ... check that all node parents are consistent with values observed earlier
node.path_ids.zip([nil] + node.path_ids).each do |node_id, parent_id|
parents[node_id] = parent_id unless parents.has_key? node_id
parents[node_id] = parent_id unless parents.key? node_id
unless parents[node_id] == parent_id
raise Ancestry::AncestryIntegrityException.new(I18n.t("ancestry.conflicting_parent_id",
:node_id => node_id,
Expand Down Expand Up @@ -219,14 +221,5 @@ def rebuild_depth_cache!
def unscoped_where
yield self.ancestry_base_class.unscope(:where)
end

ANCESTRY_UNCAST_TYPES = [:string, :uuid, :text].freeze
def primary_key_is_an_integer?
if defined?(@primary_key_is_an_integer)
@primary_key_is_an_integer
else
@primary_key_is_an_integer = !ANCESTRY_UNCAST_TYPES.include?(type_for_attribute(primary_key).type)
end
end
end
end
37 changes: 19 additions & 18 deletions lib/ancestry/has_ancestry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,23 @@ def has_ancestry options = {}
# Include dynamic class methods
extend Ancestry::ClassMethods

validates_format_of self.ancestry_column, :with => derive_ancestry_pattern(options[:primary_key_format]), :allow_nil => true
pattern = options[:primary_key_format] || /\A[0-9]+\Z/ #(pi ? /\A[0-9]+\Z/ : /\A[^\/]\Z/) # ANCESTRY_DELIMITER

pi = "a" !~ pattern # want to know primary_key_is_an_integer? without accessing the database


attribute ancestry_column, :materialized_path_string, :casting => pi ? :to_i : :to_s, :delimiter => '/'
validates ancestry_column, :array_pattern => {:id => true, :pattern => pattern, :integer => pi}
alias_attribute :ancestor_ids, ancestry_column
if ActiveRecord::VERSION::STRING < '5.1.0'
alias_method :ancestor_ids_before_last_save, :ancestor_ids_was
alias_method :ancestor_ids_in_database, :ancestor_ids_was
# usable in after save hook
# monkey patching will_save_change to fix rails
alias_method :saved_change_to_ancestor_ids?, :will_save_change_to_ancestor_ids?
alias_method :will_save_change_to_ancestor_ids?, :will_save_change_to_ancestor_ids?
end

extend Ancestry::MaterializedPath

update_strategy = options[:update_strategy] || Ancestry.default_update_strategy
Expand All @@ -37,9 +53,6 @@ def has_ancestry options = {}
cattr_reader :orphan_strategy
self.orphan_strategy = options[:orphan_strategy] || :destroy

# Validate that the ancestor ids don't include own id
validate :ancestry_exclude_self

# Update descendants with new ancestry before save
before_save :update_descendants_with_new_ancestry

Expand Down Expand Up @@ -70,8 +83,8 @@ def has_ancestry options = {}
self.counter_cache_column = options[:counter_cache]
end

after_create :increase_parent_counter_cache, if: :has_parent?
after_destroy :decrease_parent_counter_cache, if: :has_parent?
after_create :increase_parent_counter_cache, if: :ancestor_ids?
after_destroy :decrease_parent_counter_cache, if: :ancestor_ids?
after_update :update_parent_counter_cache
end

Expand Down Expand Up @@ -99,18 +112,6 @@ def acts_as_tree(*args)
return super if defined?(super)
has_ancestry(*args)
end

private

def derive_ancestry_pattern(primary_key_format, delimiter = '/')
primary_key_format ||= '[0-9]+'

if primary_key_format.to_s.include?('\A')
primary_key_format
else
/\A#{primary_key_format}(#{delimiter}#{primary_key_format})*\Z/
end
end
end
end

Expand Down
36 changes: 16 additions & 20 deletions lib/ancestry/instance_methods.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
module Ancestry
module InstanceMethods
# Validate that the ancestors don't include itself
def ancestry_exclude_self
errors.add(:base, I18n.t("ancestry.exclude_self", {:class_name => self.class.name.humanize})) if ancestor_ids.include? self.id
end

# Update descendants with new ancestry (before save)
def update_descendants_with_new_ancestry
# If enabled and node is existing and ancestry was updated and the new ancestry is sane ...
if !ancestry_callbacks_disabled? && !new_record? && ancestry_changed? && sane_ancestry?
# If enabled and node is existing and ancestry was updated
if !ancestry_callbacks_disabled? && !new_record? && will_save_change_to_ancestor_ids? && sane_ancestor_ids?
# ... for each descendant ...
unscoped_descendants.each do |descendant|
# ... replace old ancestry with new ancestry
Expand Down Expand Up @@ -77,15 +72,12 @@ def decrease_parent_counter_cache
self.class.decrement_counter _counter_cache_column, parent_id
end

def update_parent_counter_cache
changed =
if ActiveRecord::VERSION::STRING >= '5.1.0'
saved_change_to_attribute?(self.ancestry_base_class.ancestry_column)
else
ancestry_changed?
end
def parent_id_before_last_save
ancestor_ids_before_last_save.last
end

return unless changed
def update_parent_counter_cache
return unless saved_change_to_ancestor_ids?

if parent_id_was = parent_id_before_last_save
self.class.decrement_counter _counter_cache_column, parent_id_was
Expand All @@ -100,14 +92,19 @@ def _counter_cache_column

# Ancestors

def ancestors?
# when field is removed, this will end up back at ancestors
def ancestor_ids?
ancestor_ids.present?
end
alias :has_parent? :ancestors?
alias :ancestors? :ancestor_ids?
alias :has_parent? :ancestor_ids?
alias :parent_id? :ancestor_ids?

def ancestry_changed?
def will_save_change_to_ancestor_ids?
column = self.ancestry_base_class.ancestry_column.to_s
if ActiveRecord::VERSION::STRING >= '5.1.0'
if ActiveRecord::VERSION::STRING >= '6.1.0'
# implementation is fine
elsif ActiveRecord::VERSION::STRING >= '5.1.0'
# These methods return nil if there are no changes.
# This was fixed in a refactoring in rails 6.0: https://github.com/rails/rails/pull/35933
!!(will_save_change_to_attribute?(column) || saved_change_to_attribute?(column))
Expand Down Expand Up @@ -164,7 +161,6 @@ def parent_id= new_parent_id
def parent_id
ancestor_ids.last if ancestors?
end
alias :parent_id? :ancestors?

def parent
unscoped_find(parent_id) if ancestors?
Expand Down
65 changes: 8 additions & 57 deletions lib/ancestry/materialized_path.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
module Ancestry
module MaterializedPath
BEFORE_LAST_SAVE_SUFFIX = ActiveRecord::VERSION::STRING >= '5.1.0' ? '_before_last_save'.freeze : '_was'.freeze
IN_DATABASE_SUFFIX = ActiveRecord::VERSION::STRING >= '5.1.0' ? '_in_database'.freeze : '_was'.freeze
ANCESTRY_DELIMITER='/'.freeze

def self.extended(base)
Expand Down Expand Up @@ -31,14 +29,14 @@ def inpath_of(object)
def children_of(object)
t = arel_table
node = to_node(object)
where(t[ancestry_column].eq(node.child_ancestry))
where(t[ancestry_column].eq(node.child_ancestor_ids))
end

# indirect = anyone who is a descendant, but not a child
def indirects_of(object)
t = arel_table
node = to_node(object)
where(t[ancestry_column].matches("#{node.child_ancestry}/%", nil, true))
where(t[ancestry_column].matches(node.child_ancestor_id_widcard, nil, true))
end

def descendants_of(object)
Expand All @@ -49,7 +47,7 @@ def descendants_of(object)
def descendant_conditions(object)
t = arel_table
node = to_node(object)
t[ancestry_column].matches("#{node.child_ancestry}/%", nil, true).or(t[ancestry_column].eq(node.child_ancestry))
t[ancestry_column].matches(node.child_ancestor_id_widcard, nil, true).or(t[ancestry_column].eq(node.child_ancestor_ids))
end

def subtree_of(object)
Expand Down Expand Up @@ -82,64 +80,17 @@ def ordered_by_ancestry_and(order)
end

module InstanceMethods

# Validates the ancestry, but can also be applied if validation is bypassed to determine if children should be affected
def sane_ancestry?
ancestry_value = read_attribute(self.ancestry_base_class.ancestry_column)
(ancestry_value.nil? || !ancestor_ids.include?(self.id)) && valid?
end

# optimization - better to go directly to column and avoid parsing
def ancestors?
read_attribute(self.ancestry_base_class.ancestry_column).present?
end
alias :has_parent? :ancestors?

def ancestor_ids=(value)
col = self.ancestry_base_class.ancestry_column
value.present? ? write_attribute(col, value.join(ANCESTRY_DELIMITER)) : write_attribute(col, nil)
end

def ancestor_ids
parse_ancestry_column(read_attribute(self.ancestry_base_class.ancestry_column))
end

def ancestor_ids_in_database
parse_ancestry_column(send("#{self.ancestry_base_class.ancestry_column}#{IN_DATABASE_SUFFIX}"))
end

def ancestor_ids_before_last_save
parse_ancestry_column(send("#{self.ancestry_base_class.ancestry_column}#{BEFORE_LAST_SAVE_SUFFIX}"))
end

def parent_id_before_last_save
ancestry_was = send("#{self.ancestry_base_class.ancestry_column}#{BEFORE_LAST_SAVE_SUFFIX}")
return unless ancestry_was.present?

parse_ancestry_column(ancestry_was).last
end

# optimization - better to go directly to column and avoid parsing
def sibling_of?(node)
self.read_attribute(self.ancestry_base_class.ancestry_column) == node.read_attribute(self.ancestry_base_class.ancestry_column)
end

# private (public so class methods can find it)
# The ancestry value for this record's children (before save)
# This is technically child_ancestry_was
def child_ancestry
# This is technically child_ancestor_ids_in_database
def child_ancestor_ids
# New records cannot have children
raise Ancestry::AncestryException.new(I18n.t("ancestry.no_child_for_new_record")) if new_record?
path_was = self.send("#{self.ancestry_base_class.ancestry_column}#{IN_DATABASE_SUFFIX}")
path_was.blank? ? id.to_s : "#{path_was}/#{id}"
ancestor_ids_in_database + [id]
end

private

def parse_ancestry_column obj
return [] unless obj
obj_ids = obj.split(ANCESTRY_DELIMITER)
self.class.primary_key_is_an_integer? ? obj_ids.map!(&:to_i) : obj_ids
def child_ancestor_id_widcard
(child_ancestor_ids + ['%']).join(ANCESTRY_DELIMITER)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/ancestry/materialized_path_pg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module MaterializedPathPg
# Update descendants with new ancestry (before save)
def update_descendants_with_new_ancestry
# If enabled and node is existing and ancestry was updated and the new ancestry is sane ...
if !ancestry_callbacks_disabled? && !new_record? && ancestry_changed? && sane_ancestry?
if !ancestry_callbacks_disabled? && !new_record? && will_save_change_to_ancestor_ids? && sane_ancestor_ids?
ancestry_column = ancestry_base_class.ancestry_column
old_ancestry = path_ids_in_database.join(Ancestry::MaterializedPath::ANCESTRY_DELIMITER)
new_ancestry = path_ids.join(Ancestry::MaterializedPath::ANCESTRY_DELIMITER)
Expand Down
12 changes: 6 additions & 6 deletions test/concerns/arrangement_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,16 @@ def test_ancestors_arrange_leaf_node

def test_arrange_serializable
AncestryTestDatabase.with_model :depth => 2, :width => 2 do |model, roots|
result = [{"ancestry"=>nil,
result = [{"ancestry"=>[],
"id"=>4,
"children"=>
[{"ancestry"=>"4", "id"=>6, "children"=>[]},
{"ancestry"=>"4", "id"=>5, "children"=>[]}]},
{"ancestry"=>nil,
[{"ancestry"=>[4], "id"=>6, "children"=>[]},
{"ancestry"=>[4], "id"=>5, "children"=>[]}]},
{"ancestry"=>[],
"id"=>1,
"children"=>
[{"ancestry"=>"1", "id"=>3, "children"=>[]},
{"ancestry"=>"1", "id"=>2, "children"=>[]}]}]
[{"ancestry"=>[1], "id"=>3, "children"=>[]},
{"ancestry"=>[1], "id"=>2, "children"=>[]}]}]

assert_equal model.arrange_serializable(order: "id desc"), result
end
Expand Down
Loading