|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
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 |
139 | 4 | let!(:people) { Person.factory(3) } |
140 | 5 | let(:a) { people[0] } |
141 | 6 | let(:b) { people[1] } |
@@ -214,50 +79,49 @@ def path_ids |
214 | 79 | describe "ORDER with virtual attributes" do |
215 | 80 | it "orders by parent_id with nulls (symbol)" do |
216 | 81 | # 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) |
218 | 83 | expect(results.map(&:id)).to eq([a.id, b.id, c.id]) |
219 | 84 | end |
220 | 85 |
|
221 | 86 | it "orders by parent_id with explicit arel and nulls_last" do |
222 | 87 | 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) |
224 | 89 | expect(results.map(&:id)).to eq([b.id, c.id, a.id]) |
225 | 90 | end |
226 | 91 |
|
227 | 92 | 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) |
229 | 94 | expect(results.map(&:id)).to eq([a.id, b.id, c.id]) |
230 | 95 | end |
231 | 96 | end |
232 | 97 |
|
233 | 98 | describe "SELECT with virtual attributes" do |
234 | 99 | 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 |
236 | 101 | expect(results.map(&:root_id)).to eq([a.id, a.id, a.id]) |
237 | 102 | end |
238 | 103 |
|
239 | 104 | 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 |
241 | 106 | expect(results[0].parent_id).to be_nil |
242 | 107 | expect(results[1].parent_id).to eq(a.id) |
243 | 108 | expect(results[2].parent_id).to eq(b.id) |
244 | 109 | end |
245 | 110 |
|
246 | 111 | 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 |
248 | 113 | expect(results.map(&:child_path)).to eq([a.child_path, b.child_path, c.child_path]) |
249 | 114 | end |
250 | 115 |
|
251 | 116 | 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 |
254 | 118 | expect(result.has_attribute?("root_id")).to be true |
255 | 119 | expect(result.root_id).to eq(a.id) |
256 | 120 | end |
257 | 121 |
|
258 | 122 | it "works with explicit arel and alias" do |
259 | 123 | 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 |
261 | 125 | expect(results.map(&:root_id)).to eq([a.id, a.id, a.id]) |
262 | 126 | end |
263 | 127 | end |
@@ -309,27 +173,27 @@ def path_ids |
309 | 173 | describe "preloading" do |
310 | 174 | it "preloads root association" do |
311 | 175 | 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 |
313 | 177 | expect(results.map { |p| p.root&.id }).to eq([a.id, a.id, a.id]) |
314 | 178 | end |
315 | 179 |
|
316 | 180 | it "preloads parent association" do |
317 | 181 | 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 |
319 | 183 | expect(results.map { |p| p.parent&.id }).to eq([nil, a.id, b.id]) |
320 | 184 | end |
321 | 185 | end |
322 | 186 |
|
323 | 187 | describe "includes (eager load)" do |
324 | 188 | it "includes root" do |
325 | 189 | 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 |
327 | 191 | expect(results.map { |p| p.root&.id }).to eq([a.id, a.id, a.id]) |
328 | 192 | end |
329 | 193 |
|
330 | 194 | it "includes parent" do |
331 | 195 | 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 |
333 | 197 | expect(results.map { |p| p.parent&.id }).to eq([nil, a.id, b.id]) |
334 | 198 | end |
335 | 199 | end |
|
0 commit comments