Skip to content

Commit 02aeea7

Browse files
committed
Remove special class and just use standard models
1 parent 548e268 commit 02aeea7

5 files changed

Lines changed: 163 additions & 270 deletions

File tree

spec/ancestry_spec.rb

Lines changed: 13 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,6 @@
11
# frozen_string_literal: true
22

3-
RSpec.describe "Ancestry-style arel attributes", :with_test_class do
4-
# Ancestry uses a path column like "/1/2/3/" to represent hierarchy.
5-
# We derive root_id, parent_id, and child_path from the path via SQL.
6-
7-
before do
8-
ActiveRecord::Schema.define do
9-
self.verbose = false
10-
create_table :people, force: true do |t|
11-
t.string :path, null: false, default: "/"
12-
t.string :name
13-
end
14-
end
15-
16-
# rubocop:disable Lint/ConstantDefinitionInBlock
17-
class Person < ActiveRecord::Base
18-
include ArelAttribute::Base
19-
include ArelAttribute::SqlDetection
20-
21-
class << self
22-
private
23-
24-
# Arel helper: SQL function node
25-
def sql_fn(name, *args)
26-
Arel::Nodes::NamedFunction.new(name, args)
27-
end
28-
29-
# Arel helper: CAST(expr AS type)
30-
def sql_cast(expr, type)
31-
Arel::Nodes::NamedFunction.new("CAST", [Arel.sql("#{expr.to_sql} AS #{type}")])
32-
end
33-
34-
def sql_position(str, sub)
35-
if is_pg?
36-
sql_fn("STRPOS", str, sub)
37-
else
38-
sql_fn("INSTR", str, sub)
39-
end
40-
end
41-
# Arel helper: CASE WHEN path = '/' THEN root_val ELSE not_root END
42-
def sql_case_root(path, root_val, not_root)
43-
Arel::Nodes::Case.new(path).when(Arel.sql("'/'")).then(root_val).else(not_root)
44-
end
45-
end
46-
47-
# root_id: first id in the path, e.g. "/1/2/3/" => 1
48-
# For root nodes (path="/"), root_id is the node's own id.
49-
#
50-
# SQL: CASE path WHEN '/' THEN id ELSE CAST(SUBSTR(path, 2, INSTR(LTRIM(path, '/'), '/') - 1) AS INTEGER) END
51-
define_arel_attribute :root_id, :integer do |t|
52-
path = t[:path]
53-
stripped = sql_fn("LTRIM", path, Arel.sql("'/'"))
54-
len = sql_position(stripped, Arel.sql("'/'")) - 1
55-
segment = sql_fn("SUBSTR", path, 2, len)
56-
sql_case_root(path, t[:id], sql_cast(segment, "INTEGER"))
57-
end
58-
59-
# parent_id: last id in the path, e.g. "/1/2/3/" => 3 (i.e. parent of this node)
60-
# For root nodes (path="/"), parent_id is NULL.
61-
#
62-
# SQL: CASE path WHEN '/' THEN NULL ELSE CAST(RTRIM(REPLACE(path, RTRIM(RTRIM(path, '/'), REPLACE(path, '/', '')), ''), '/') AS INTEGER) END
63-
define_arel_attribute :parent_id, :integer do |t|
64-
path = t[:path]
65-
slash = Arel.sql("'/'")
66-
empty = Arel.sql("''")
67-
non_slash_chars = sql_fn("REPLACE", path, slash, empty)
68-
front = sql_fn("RTRIM", sql_fn("RTRIM", path, slash), non_slash_chars)
69-
last_segment = sql_fn("RTRIM", sql_fn("REPLACE", path, front, empty), slash)
70-
sql_case_root(path, Arel.sql("NULL"), sql_cast(last_segment, "INTEGER"))
71-
end
72-
73-
# child_path: path that children of this node would have, e.g. id=2, path="/1/" => "/1/2/"
74-
# SQL: path || id || '/'
75-
define_arel_attribute :child_path, :string do |t|
76-
Arel::Nodes::Concat.new(
77-
Arel::Nodes::Concat.new(t[:path], t[:id]),
78-
Arel.sql("'/'")
79-
)
80-
end
81-
82-
# Ruby getters with has_attribute? pattern
83-
def root_id
84-
if has_attribute?("root_id")
85-
self["root_id"]
86-
else
87-
ids = path_ids
88-
ids.empty? ? id : ids.first
89-
end
90-
end
91-
92-
def parent_id
93-
if has_attribute?("parent_id")
94-
self["parent_id"]
95-
else
96-
ids = path_ids
97-
ids.empty? ? nil : ids.last
98-
end
99-
end
100-
101-
def child_path
102-
if has_attribute?("child_path")
103-
self["child_path"]
104-
else
105-
"#{path}#{id}/"
106-
end
107-
end
108-
109-
# Association definitions using virtual foreign keys
110-
belongs_to :root, foreign_key: :root_id, class_name: "Person", optional: true
111-
belongs_to :parent, foreign_key: :path, primary_key: :child_path, class_name: "Person",
112-
inverse_of: :children, optional: true
113-
has_many :children, foreign_key: :path, primary_key: :child_path, class_name: "Person",
114-
inverse_of: :parent
115-
has_many :siblings, foreign_key: :path, primary_key: :path, class_name: "Person"
116-
117-
scope :roots, -> { where(path: "/") }
118-
119-
def self.factory(count = 3, start: "a".ord)
120-
count.times.inject([]) do |ac, i|
121-
ac << create!(name: (start + i).chr, path: ac.last&.child_path || "/")
122-
end
123-
end
124-
125-
private
126-
127-
def path_ids
128-
return [] if path.blank? || path == "/"
129-
path[1..].split("/").map(&:to_i)
130-
end
131-
end
132-
# rubocop:enable Lint/ConstantDefinitionInBlock
133-
end
134-
135-
after do
136-
Object.send(:remove_const, :Person)
137-
end
138-
3+
RSpec.describe "Ancestry-style arel attributes" do
1394
let!(:people) { Person.factory(3) }
1405
let(:a) { people[0] }
1416
let(:b) { people[1] }
@@ -214,50 +79,49 @@ def path_ids
21479
describe "ORDER with virtual attributes" do
21580
it "orders by parent_id with nulls (symbol)" do
21681
# SQLite puts NULLs first by default
217-
results = Person.order(:parent_id, :id)
82+
results = Person.where(id: [a.id, b.id, c.id]).order(:parent_id, :id)
21883
expect(results.map(&:id)).to eq([a.id, b.id, c.id])
21984
end
22085

22186
it "orders by parent_id with explicit arel and nulls_last" do
22287
sort = Arel::Nodes::Ascending.new(Person.arel_table[:parent_id]).nulls_last
223-
results = Person.order(sort, :id)
88+
results = Person.where(id: [a.id, b.id, c.id]).order(sort, :id)
22489
expect(results.map(&:id)).to eq([b.id, c.id, a.id])
22590
end
22691

22792
it "orders by root_id" do
228-
results = Person.order(:root_id, :id)
93+
results = Person.where(id: [a.id, b.id, c.id]).order(:root_id, :id)
22994
expect(results.map(&:id)).to eq([a.id, b.id, c.id])
23095
end
23196
end
23297

23398
describe "SELECT with virtual attributes" do
23499
it "selects root_id with alias" do
235-
results = Person.select(:id, :path, :root_id).order(:id).load
100+
results = Person.where(id: [a.id, b.id, c.id]).select(:id, :path, :root_id).order(:id).load
236101
expect(results.map(&:root_id)).to eq([a.id, a.id, a.id])
237102
end
238103

239104
it "selects parent_id with alias" do
240-
results = Person.select(:id, :path, :parent_id).order(:id).load
105+
results = Person.where(id: [a.id, b.id, c.id]).select(:id, :path, :parent_id).order(:id).load
241106
expect(results[0].parent_id).to be_nil
242107
expect(results[1].parent_id).to eq(a.id)
243108
expect(results[2].parent_id).to eq(b.id)
244109
end
245110

246111
it "selects child_path with alias" do
247-
results = Person.select(:id, :path, :child_path).order(:id).load
112+
results = Person.where(id: [a.id, b.id, c.id]).select(:id, :path, :child_path).order(:id).load
248113
expect(results.map(&:child_path)).to eq([a.child_path, b.child_path, c.child_path])
249114
end
250115

251116
it "selected values override Ruby getter" do
252-
result = Person.select(:id, :root_id).order(:id).first
253-
# has_attribute? returns true, so the SQL-computed value is used
117+
result = Person.where(id: [a.id, b.id, c.id]).select(:id, :root_id).order(:id).first
254118
expect(result.has_attribute?("root_id")).to be true
255119
expect(result.root_id).to eq(a.id)
256120
end
257121

258122
it "works with explicit arel and alias" do
259123
root_with_alias = Person.arel_table[:root_id].as("root_id")
260-
results = Person.select(:id, :name, root_with_alias).order(:id).load
124+
results = Person.where(id: [a.id, b.id, c.id]).select(:id, :name, root_with_alias).order(:id).load
261125
expect(results.map(&:root_id)).to eq([a.id, a.id, a.id])
262126
end
263127
end
@@ -309,27 +173,27 @@ def path_ids
309173
describe "preloading" do
310174
it "preloads root association" do
311175
people
312-
results = Person.preload(:root).order(:id).load
176+
results = Person.where(id: [a.id, b.id, c.id]).preload(:root).order(:id).load
313177
expect(results.map { |p| p.root&.id }).to eq([a.id, a.id, a.id])
314178
end
315179

316180
it "preloads parent association" do
317181
people
318-
results = Person.preload(:parent).order(:id).load
182+
results = Person.where(id: [a.id, b.id, c.id]).preload(:parent).order(:id).load
319183
expect(results.map { |p| p.parent&.id }).to eq([nil, a.id, b.id])
320184
end
321185
end
322186

323187
describe "includes (eager load)" do
324188
it "includes root" do
325189
people
326-
results = Person.includes(:root).order(:id).load
190+
results = Person.where(id: [a.id, b.id, c.id]).includes(:root).order(:id).load
327191
expect(results.map { |p| p.root&.id }).to eq([a.id, a.id, a.id])
328192
end
329193

330194
it "includes parent" do
331195
people
332-
results = Person.includes(:parent).order(:id).load
196+
results = Person.where(id: [a.id, b.id, c.id]).includes(:parent).order(:id).load
333197
expect(results.map { |p| p.parent&.id }).to eq([nil, a.id, b.id])
334198
end
335199
end

0 commit comments

Comments
 (0)