Skip to content

Commit 97705f1

Browse files
kbrockclaude
andcommitted
Allow ancestry to define a primary ancestry key
Introduce primary_ancestry_key class variable and ancestry_id instance method as the indirection point for which column values are stored in ancestry paths. Defaults to :id. On Rails 7.2+, auto-detects from primary_key. On older Rails, must be passed explicitly via has_ancestry primary_key: :code. Replaces bare id/node.id with ancestry_id/node.ancestry_id throughout static modules (instance_methods.rb, class_methods.rb) and builder. Replaces scope.primary_key with scope.primary_ancestry_key for record lookups by ancestry values. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 86ebfbf commit 97705f1

5 files changed

Lines changed: 160 additions & 38 deletions

File tree

lib/ancestry/class_methods.rb

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def to_node(object)
77
if object.is_a?(ancestry_base_class)
88
object
99
else
10-
unscoped_where { |scope| scope.find(object.try(primary_key) || object) }
10+
unscoped_where { |scope| scope.find_by!(primary_ancestry_key => object.try(primary_ancestry_key) || object) }
1111
end
1212
end
1313

@@ -52,17 +52,17 @@ def arrange(options = {})
5252
# @returns Hash{Node => {Node => {}, Node => {}}}
5353
# If a node's parent is not included, the node will be included as if it is a top level node
5454
def arrange_nodes(nodes, orphan_strategy: :rootify)
55-
node_ids = Set.new(nodes.map(&:id))
55+
node_ids = Set.new(nodes.map(&:ancestry_id))
5656
index = Hash.new { |h, k| h[k] = {} }
5757

5858
if orphan_strategy == :rootify
5959
nodes.each_with_object({}) do |node, arranged|
60-
index[node.parent_id][node] = children = index[node.id]
60+
index[node.parent_id][node] = children = index[node.ancestry_id]
6161
arranged[node] = children unless node_ids.include?(node.parent_id)
6262
end
6363
else
6464
nodes.each_with_object({}) do |node, arranged|
65-
index[node.parent_id][node] = children = index[node.id]
65+
index[node.parent_id][node] = children = index[node.ancestry_id]
6666
if node.parent_id.nil?
6767
arranged[node] = children
6868
elsif !node_ids.include?(node.parent_id)
@@ -150,14 +150,14 @@ def self._check_ancestry_integrity!(klass, column, options = {})
150150
# ... check validity of ancestry column
151151
if !node.sane_ancestor_ids?
152152
raise Ancestry::AncestryIntegrityException, I18n.t("ancestry.invalid_ancestry_column",
153-
:node_id => node.id,
153+
:node_id => node.ancestry_id,
154154
:ancestry_column => node.read_attribute(column))
155155
end
156156
# ... check that all ancestors exist
157157
node.ancestor_ids.each do |ancestor_id|
158-
unless klass.exists?(ancestor_id)
158+
unless klass.exists?(klass.primary_ancestry_key => ancestor_id)
159159
raise Ancestry::AncestryIntegrityException, I18n.t("ancestry.reference_nonexistent_node",
160-
:node_id => node.id,
160+
:node_id => node.ancestry_id,
161161
:ancestor_id => ancestor_id)
162162
end
163163
end
@@ -197,20 +197,20 @@ def restore_ancestry_integrity!
197197
end
198198
end
199199
# ... save parent id of this node in parent_ids array if it exists
200-
parent_ids[node.id] = node.parent_id if exists? node.parent_id
200+
parent_ids[node.ancestry_id] = node.parent_id if exists?(primary_ancestry_key => node.parent_id)
201201

202202
# Reset parent id in array to nil if it introduces a cycle
203-
parent_id = parent_ids[node.id]
204-
until parent_id.nil? || parent_id == node.id
203+
parent_id = parent_ids[node.ancestry_id]
204+
until parent_id.nil? || parent_id == node.ancestry_id
205205
parent_id = parent_ids[parent_id]
206206
end
207-
parent_ids[node.id] = nil if parent_id == node.id
207+
parent_ids[node.ancestry_id] = nil if parent_id == node.ancestry_id
208208
end
209209

210210
# For each node ...
211211
scope.find_each do |node|
212212
# ... rebuild ancestry from parent_ids array
213-
ancestor_ids, parent_id = [], parent_ids[node.id]
213+
ancestor_ids, parent_id = [], parent_ids[node.ancestry_id]
214214
until parent_id.nil?
215215
ancestor_ids, parent_id = [parent_id] + ancestor_ids, parent_ids[parent_id]
216216
end
@@ -229,7 +229,7 @@ def build_ancestry_from_parent_ids!(column = :parent_id, parent_id = nil, ancest
229229
node.without_ancestry_callbacks do
230230
node.update_attribute :ancestor_ids, ancestor_ids
231231
end
232-
build_ancestry_from_parent_ids! column, node.id, ancestor_ids + [node.id]
232+
build_ancestry_from_parent_ids! column, node.ancestry_id, ancestor_ids + [node.ancestry_id]
233233
end
234234
end
235235
end
@@ -274,7 +274,7 @@ def self._rebuild_parent_id_cache!(klass, parent_cache_column)
274274
def self._rebuild_counter_cache!(klass, column, counter_col, verbose: false)
275275
child_sql = klass.child_ancestry_sql
276276
tbl = klass.table_name
277-
pk = klass.primary_key
277+
pk = klass.primary_ancestry_key
278278

279279
fixed =
280280
if verbose
@@ -311,7 +311,7 @@ def self._rebuild_counter_cache!(klass, column, counter_col, verbose: false)
311311
# Builder generates thin wrappers that delegate here with baked-in column.
312312

313313
def self._ancestry_exclude_self(record)
314-
record.errors.add(:base, I18n.t("ancestry.exclude_self", class_name: record.class.model_name.human)) if record.ancestor_ids.include?(record.id)
314+
record.errors.add(:base, I18n.t("ancestry.exclude_self", class_name: record.class.model_name.human)) if record.ancestor_ids.include?(record.ancestry_id)
315315
end
316316

317317
def self._update_descendants_with_new_ancestry(record)
@@ -350,7 +350,7 @@ def self._apply_orphan_strategy_adopt(record)
350350

351351
record.class.ancestry_base_class.descendants_of(record).each do |descendant|
352352
descendant.without_ancestry_callbacks do
353-
descendant.update_attribute :ancestor_ids, descendant.ancestor_ids - [record.id]
353+
descendant.update_attribute :ancestor_ids, descendant.ancestor_ids - [record.ancestry_id]
354354
end
355355
end
356356
end

lib/ancestry/has_ancestry.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ def has_ancestry(options = {})
88
raise Ancestry::AncestryException, I18n.t("ancestry.option_must_be_hash")
99
end
1010

11-
extra_keys = options.keys - [:ancestry_column, :orphan_strategy, :cache_depth, :depth_cache_column, :touch, :counter_cache, :primary_key_format, :update_strategy, :ancestry_format, :format, :parent, :root, :associations]
11+
extra_keys = options.keys - [:ancestry_column, :orphan_strategy, :cache_depth, :depth_cache_column, :touch, :counter_cache, :primary_key_format, :primary_key, :update_strategy, :ancestry_format, :format, :parent, :root, :associations]
1212
if (key = extra_keys.first)
1313
raise Ancestry::AncestryException, I18n.t("ancestry.unknown_option", key: key.inspect, value: options[key].inspect)
1414
end
@@ -28,6 +28,18 @@ def has_ancestry(options = {})
2828
class_variable_set('@@ancestry_base_class', self)
2929
cattr_reader :ancestry_base_class, instance_reader: false
3030

31+
# Define the column used to identify nodes in ancestry paths
32+
# ActiveRecord::VERSION::STRING < "7.2" hits DB for primary_key, so fall back to :id
33+
pk = if options[:primary_key]
34+
options[:primary_key]
35+
elsif ActiveRecord::VERSION::STRING >= "7.2"
36+
primary_key
37+
else
38+
:id
39+
end
40+
class_variable_set('@@primary_ancestry_key', pk.to_sym)
41+
cattr_reader :primary_ancestry_key, instance_reader: false
42+
3143
# Include instance methods
3244
include Ancestry::InstanceMethods
3345

@@ -113,6 +125,7 @@ def has_ancestry(options = {})
113125
# This extends ClassMethods (scopes, helpers) and includes instance methods
114126
generated_mod = Ancestry::InstanceMethodsBuilder.build(
115127
format_module, column, root,
128+
primary_key: primary_ancestry_key,
116129
integer_pk: integer_pk,
117130
depth_cache_column: depth_cache_column,
118131
counter_cache_column: counter_cache_column,

lib/ancestry/instance_methods.rb

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
module Ancestry
44
module InstanceMethods
5+
def ancestry_id
6+
read_attribute(self.class.primary_ancestry_key)
7+
end
8+
9+
def ancestry_id_before_last_save
10+
attribute_before_last_save(self.class.primary_ancestry_key)
11+
end
12+
513
# Validate that descendants' depths don't exceed max depth when moving them
614
# Called from generated ancestry_depth_of_descendants with baked column names
715
def validate_depth_of_descendants(depth_cache_column, depth_change)
@@ -29,7 +37,7 @@ def ancestry_sync_parent_cache(parent_cache_column, value, association_name = :p
2937

3038
# Sync root cache column and reset association after ancestry change
3139
def ancestry_sync_root_cache(root_cache_column, value, association_name = :root)
32-
write_attribute(root_cache_column, value.first || id) if root_cache_column
40+
write_attribute(root_cache_column, value.first || ancestry_id) if root_cache_column
3341
association(association_name).reset if association_cached?(association_name)
3442
end
3543

@@ -38,7 +46,7 @@ def ancestry_lookup_parent(association_name = :parent)
3846
if association(association_name).loaded?
3947
association(association_name).target
4048
else
41-
unscoped_where { |scope| scope.find_by(scope.primary_key => parent_id) }
49+
unscoped_where { |scope| scope.find_by(scope.primary_ancestry_key => parent_id) }
4250
end
4351
end
4452

@@ -47,14 +55,14 @@ def ancestry_lookup_root(association_name = :root)
4755
if association(association_name).loaded?
4856
association(association_name).target || self
4957
else
50-
unscoped_where { |scope| scope.find_by(scope.primary_key => root_id) } || self
58+
unscoped_where { |scope| scope.find_by(scope.primary_ancestry_key => root_id) } || self
5159
end
5260
end
5361

5462
# Add root cache update to SQL update clause for descendants
5563
def add_root_cache_to_update_clause(update_clause, root_cache_column)
56-
old_root_id = ancestor_ids_before_last_save.first || id_before_last_save
57-
new_root_id = ancestor_ids.first || id
64+
old_root_id = ancestor_ids_before_last_save.first || ancestry_id_before_last_save
65+
new_root_id = ancestor_ids.first || ancestry_id
5866
if old_root_id != new_root_id
5967
update_clause[root_cache_column] = new_root_id
6068
end
@@ -85,7 +93,7 @@ def ancestry_callbacks_disabled?
8593
# works with after save context (hence before_last_save)
8694
def unscoped_current_and_previous_ancestors
8795
unscoped_where do |scope|
88-
scope.where(scope.primary_key => (ancestor_ids + ancestor_ids_before_last_save).uniq)
96+
scope.where(scope.primary_ancestry_key => (ancestor_ids + ancestor_ids_before_last_save).uniq)
8997
end
9098
end
9199

lib/ancestry/instance_methods_builder.rb

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ module InstanceMethodsBuilder
1313
# @param parent_cache_column [String, nil] column name for parent cache, or nil
1414
# @param root_cache_column [String, nil] column name for root cache, or nil
1515
# @return [Module] a named module with baked-in instance methods
16-
def self.build(format_module, column, root, primary_key: :id, integer_pk: nil, depth_cache_column: nil, counter_cache_column: nil, parent_cache_column: nil, root_cache_column: nil, parent_association: false, root_association: false)
16+
def self.build(format_module, column, root, primary_key:, integer_pk: nil, depth_cache_column: nil, counter_cache_column: nil, parent_cache_column: nil, root_cache_column: nil, parent_association: false, root_association: false)
1717
pk = primary_key
1818
parse_method = integer_pk ? :parse_integer : :parse
1919
format_name = format_module.name.split("::").last
@@ -84,15 +84,15 @@ def sibling_of?(node)
8484
def child_ancestry
8585
raise(Ancestry::AncestryException, I18n.t("ancestry.no_child_for_new_record")) if new_record?
8686

87-
#{format_module}.child_ancestry_value(attribute_in_database(:#{column}), id)
87+
#{format_module}.child_ancestry_value(attribute_in_database(:#{column}), ancestry_id)
8888
end
8989

9090
def child_ancestry_before_last_save
9191
if new_record? || (respond_to?(:previously_new_record?) && previously_new_record?)
9292
raise Ancestry::AncestryException, I18n.t("ancestry.no_child_for_new_record")
9393
end
9494

95-
#{format_module}.child_ancestry_value(attribute_before_last_save(:#{column}), id)
95+
#{format_module}.child_ancestry_value(attribute_before_last_save(:#{column}), ancestry_id)
9696
end
9797

9898
def ancestry_changed?
@@ -123,7 +123,7 @@ def parent_id
123123
alias parent_id? ancestors?
124124

125125
def root_id
126-
has_parent? ? ancestor_ids.first : id
126+
has_parent? ? ancestor_ids.first : ancestry_id
127127
end
128128

129129
def depth
@@ -136,45 +136,45 @@ def is_root?
136136
alias root? is_root?
137137

138138
def path_ids
139-
ancestor_ids + [id]
139+
ancestor_ids + [ancestry_id]
140140
end
141141

142142
def path_ids_before_last_save
143-
ancestor_ids_before_last_save + [id]
143+
ancestor_ids_before_last_save + [ancestry_id]
144144
end
145145

146146
def path_ids_in_database
147-
ancestor_ids_in_database + [id]
147+
ancestor_ids_in_database + [ancestry_id]
148148
end
149149

150150
# Predicates
151151

152152
def ancestor_of?(node)
153-
node.ancestor_ids.include?(id)
153+
node.ancestor_ids.include?(ancestry_id)
154154
end
155155

156156
def parent_of?(node)
157-
id == node.parent_id
157+
ancestry_id == node.parent_id
158158
end
159159

160160
def child_of?(node)
161-
parent_id == node.id
161+
parent_id == node.ancestry_id
162162
end
163163

164164
def root_of?(node)
165-
id == node.root_id
165+
ancestry_id == node.root_id
166166
end
167167

168168
def descendant_of?(node)
169-
ancestor_ids.include?(node.id)
169+
ancestor_ids.include?(node.ancestry_id)
170170
end
171171

172172
def indirect_of?(node)
173-
ancestor_ids[0..-2].include?(node.id)
173+
ancestor_ids[0..-2].include?(node.ancestry_id)
174174
end
175175

176176
def in_subtree_of?(node)
177-
id == node.id || descendant_of?(node)
177+
ancestry_id == node.ancestry_id || descendant_of?(node)
178178
end
179179

180180
# Scope-delegating navigation methods
@@ -238,7 +238,7 @@ def leaf_ids(depth_options = {})
238238
end
239239

240240
def siblings
241-
self.class.ancestry_base_class.siblings_of(self).where.not(:#{pk} => id)
241+
self.class.ancestry_base_class.siblings_of(self).where.not(:#{pk} => ancestry_id)
242242
end
243243

244244
def sibling_ids

0 commit comments

Comments
 (0)