diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-07 02:46:37 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-07 02:46:37 +0200 |
| commit | 7b6af89509e8439fe2474e623ee97e4db67ab011 (patch) | |
| tree | 47aa227de58d566727e8afdc44d7abc0934f22a6 | |
| parent | 527376039c527eb8f559c5e6da76429bc3f3ee4f (diff) | |
Replace awesome_nested_set with a plain parent_id-based NestedTree concern
Root-caused this session: appending a child to any node never widened
that parent's own rgt boundary, on the pinned revision (Gemfile tracked
main directly, chasing a too-conservative gemspec constraint - not, as
first assumed, a deliberate pin to avoid a known bug). Reproduced
cleanly on a single ordinary create with no concurrency and no bulk
operation involved, confirmed via the gem's own SetValidator, then
confirmed as the root cause of nodes_controller_test.rb's 3 long-standing
"pre-existing" failures - not three separate mysteries, one bug.
admin_controller's sitemap needed its own real conversion, not just a
drop-in: awesome_nested_set's lft column implicitly provided correct
depth-first tree order for free, which the old code combined with a
separate class-level each_with_level iterator. Both replaced by one
method, self_and_descendants_ordered_with_level, computing an ordered
[node, level] list in a single query-then-walk pass - checked against
the actual view template first (admin/index.html.erb) rather than
assumed, since it relies on list order alone to render correct visual
nesting.
lft/rgt/depth columns intentionally left in schema, unused - dropping
them is a separate, deliberately deferred migration once this is proven
running for a while, not bundled with the behavior change.
| -rw-r--r-- | Gemfile | 3 | ||||
| -rw-r--r-- | Gemfile.lock | 10 | ||||
| -rw-r--r-- | app/controllers/admin_controller.rb | 8 | ||||
| -rw-r--r-- | app/models/concerns/nested_tree.rb | 86 | ||||
| -rw-r--r-- | app/models/node.rb | 25 | ||||
| -rw-r--r-- | db/seeds/chapters.rb | 3 |
6 files changed, 95 insertions, 40 deletions
| @@ -44,9 +44,6 @@ gem 'will_paginate', '~> 3.0' | |||
| 44 | gem 'acts-as-taggable-on', | 44 | gem 'acts-as-taggable-on', |
| 45 | git: 'https://github.com/mbleigh/acts-as-taggable-on.git', | 45 | git: 'https://github.com/mbleigh/acts-as-taggable-on.git', |
| 46 | branch: 'master' | 46 | branch: 'master' |
| 47 | gem 'awesome_nested_set', | ||
| 48 | git: 'https://github.com/collectiveidea/awesome_nested_set.git', | ||
| 49 | branch: 'main' | ||
| 50 | 47 | ||
| 51 | # ── XML / parsing ───────────────────────────────────────────────────────────── | 48 | # ── XML / parsing ───────────────────────────────────────────────────────────── |
| 52 | 49 | ||
diff --git a/Gemfile.lock b/Gemfile.lock index 2e9a1ce..02c71ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock | |||
| @@ -1,12 +1,4 @@ | |||
| 1 | GIT | 1 | GIT |
| 2 | remote: https://github.com/collectiveidea/awesome_nested_set.git | ||
| 3 | revision: 7a642749af74519b4bf3642d939061beb36835e1 | ||
| 4 | branch: main | ||
| 5 | specs: | ||
| 6 | awesome_nested_set (3.9.0) | ||
| 7 | activerecord (>= 4.0.0, < 8.2) | ||
| 8 | |||
| 9 | GIT | ||
| 10 | remote: https://github.com/erdgeist/chaoscalendar.git | 2 | remote: https://github.com/erdgeist/chaoscalendar.git |
| 11 | revision: 48fe5c517ae3895cc78d9d2fefbdd35b90d52ba7 | 3 | revision: 48fe5c517ae3895cc78d9d2fefbdd35b90d52ba7 |
| 12 | branch: erdgeist-ruby1.9 | 4 | branch: erdgeist-ruby1.9 |
| @@ -335,7 +327,6 @@ PLATFORMS | |||
| 335 | DEPENDENCIES | 327 | DEPENDENCIES |
| 336 | acts-as-taggable-on! | 328 | acts-as-taggable-on! |
| 337 | acts_as_list | 329 | acts_as_list |
| 338 | awesome_nested_set! | ||
| 339 | chaos_calendar! | 330 | chaos_calendar! |
| 340 | coffee-rails (~> 4.0) | 331 | coffee-rails (~> 4.0) |
| 341 | concurrent-ruby (~> 1.3) | 332 | concurrent-ruby (~> 1.3) |
| @@ -374,7 +365,6 @@ CHECKSUMS | |||
| 374 | activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e | 365 | activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e |
| 375 | acts-as-taggable-on (13.0.0) | 366 | acts-as-taggable-on (13.0.0) |
| 376 | acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8 | 367 | acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8 |
| 377 | awesome_nested_set (3.9.0) | ||
| 378 | base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b | 368 | base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b |
| 379 | bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd | 369 | bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd |
| 380 | builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f | 370 | builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f |
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index bfc6cd8..3fa0519 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb | |||
| @@ -15,12 +15,10 @@ class AdminController < ApplicationController | |||
| 15 | Time.now, Time.now - 14.days | 15 | Time.now, Time.now - 14.days |
| 16 | ).limit(50).order("updated_at desc") | 16 | ).limit(50).order("updated_at desc") |
| 17 | 17 | ||
| 18 | all_nodes = Node.root.self_and_descendants | 18 | ordered_with_level = Node.root.self_and_descendants_ordered_with_level |
| 19 | @sitemap_depth = {} | 19 | @sitemap_depth = {} |
| 20 | Node.each_with_level(all_nodes) do |node, level| | 20 | ordered_with_level.each { |node, level| @sitemap_depth[node.id] = level } |
| 21 | @sitemap_depth[node.id] = level | 21 | @sitemap = ordered_with_level.map(&:first).reject(&:update?) |
| 22 | end | ||
| 23 | @sitemap = all_nodes.to_a.sort! { |node1,node2| node1.lft <=> node2.lft }.delete_if { |node| node.update? } | ||
| 24 | 22 | ||
| 25 | @mypages = Page.where("user_id = ? or editor_id = ?", @current_user, @current_user) | 23 | @mypages = Page.where("user_id = ? or editor_id = ?", @current_user, @current_user) |
| 26 | 24 | ||
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb new file mode 100644 index 0000000..9a2eb0e --- /dev/null +++ b/app/models/concerns/nested_tree.rb | |||
| @@ -0,0 +1,86 @@ | |||
| 1 | # app/models/concerns/nested_tree.rb | ||
| 2 | # | ||
| 3 | # Minimal parent_id-based replacement for the tree-traversal subset of | ||
| 4 | # awesome_nested_set actually used by this app (descendants, ancestors, | ||
| 5 | # level, root, move_to_child_of) | ||
| 6 | module NestedTree | ||
| 7 | extend ActiveSupport::Concern | ||
| 8 | |||
| 9 | included do | ||
| 10 | belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children | ||
| 11 | has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent | ||
| 12 | |||
| 13 | before_destroy :delete_descendants | ||
| 14 | end | ||
| 15 | |||
| 16 | class_methods do | ||
| 17 | def root | ||
| 18 | roots.first | ||
| 19 | end | ||
| 20 | |||
| 21 | def roots | ||
| 22 | where(parent_id: nil) | ||
| 23 | end | ||
| 24 | |||
| 25 | def valid? | ||
| 26 | # Every non-root row's parent_id must point at a real, existing row. | ||
| 27 | where.not(parent_id: nil).where.missing(:parent).none? | ||
| 28 | end | ||
| 29 | end | ||
| 30 | |||
| 31 | def root? | ||
| 32 | parent_id.nil? | ||
| 33 | end | ||
| 34 | |||
| 35 | def ancestors | ||
| 36 | result = [] | ||
| 37 | current = parent | ||
| 38 | while current | ||
| 39 | result << current | ||
| 40 | current = current.parent | ||
| 41 | end | ||
| 42 | result | ||
| 43 | end | ||
| 44 | |||
| 45 | def level | ||
| 46 | ancestors.size | ||
| 47 | end | ||
| 48 | |||
| 49 | def descendants | ||
| 50 | ids = [] | ||
| 51 | queue = self.class.where(parent_id: id).pluck(:id) | ||
| 52 | until queue.empty? | ||
| 53 | ids.concat(queue) | ||
| 54 | queue = self.class.where(parent_id: queue).pluck(:id) | ||
| 55 | end | ||
| 56 | self.class.where(id: ids) | ||
| 57 | end | ||
| 58 | |||
| 59 | def self_and_descendants | ||
| 60 | self.class.where(id: [id] + descendants.pluck(:id)) | ||
| 61 | end | ||
| 62 | |||
| 63 | def self_and_descendants_ordered_with_level | ||
| 64 | nodes = [self] + descendants.to_a | ||
| 65 | children_by_parent = nodes.group_by(&:parent_id) | ||
| 66 | children_by_parent.each_value { |list| list.sort_by!(&:id) } | ||
| 67 | |||
| 68 | result = [] | ||
| 69 | visit = ->(node, level) do | ||
| 70 | result << [node, level] | ||
| 71 | (children_by_parent[node.id] || []).each { |child| visit.call(child, level + 1) } | ||
| 72 | end | ||
| 73 | visit.call(self, 0) | ||
| 74 | result | ||
| 75 | end | ||
| 76 | |||
| 77 | def move_to_child_of(new_parent) | ||
| 78 | update!(parent_id: new_parent.id) | ||
| 79 | end | ||
| 80 | |||
| 81 | private | ||
| 82 | |||
| 83 | def delete_descendants | ||
| 84 | self.class.where(id: descendants.pluck(:id)).delete_all | ||
| 85 | end | ||
| 86 | end | ||
diff --git a/app/models/node.rb b/app/models/node.rb index 2177f15..fc23dc1 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | class Node < ApplicationRecord | 1 | class Node < ApplicationRecord |
| 2 | # Mixins and Plugins | 2 | # Mixins and Plugins |
| 3 | acts_as_nested_set | 3 | include NestedTree |
| 4 | 4 | ||
| 5 | # Associations | 5 | # Associations |
| 6 | has_many :pages, -> { order("revision ASC") }, :dependent => :destroy | 6 | has_many :pages, -> { order("revision ASC") }, :dependent => :destroy |
| @@ -21,16 +21,6 @@ class Node < ApplicationRecord | |||
| 21 | validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } | 21 | validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } |
| 22 | validates_presence_of :parent_id, :unless => -> { Node.root.nil? } | 22 | validates_presence_of :parent_id, :unless => -> { Node.root.nil? } |
| 23 | 23 | ||
| 24 | validate :borders # This should never ever happen. | ||
| 25 | |||
| 26 | # Index for Fulltext Search | ||
| 27 | # define_index do | ||
| 28 | # indexes head.translations.title | ||
| 29 | # indexes slug | ||
| 30 | # indexes unique_name | ||
| 31 | # indexes head.translations.body | ||
| 32 | # end | ||
| 33 | |||
| 34 | # Class methods | 24 | # Class methods |
| 35 | 25 | ||
| 36 | # Returns a page for a given node. If no revision is supplied, it returns | 26 | # Returns a page for a given node. If no revision is supplied, it returns |
| @@ -254,10 +244,13 @@ class Node < ApplicationRecord | |||
| 254 | # Watch out recursion ahead! update_unique_name itself triggers this | 244 | # Watch out recursion ahead! update_unique_name itself triggers this |
| 255 | # after_save callback which invokes update_unique_name on its children. | 245 | # after_save callback which invokes update_unique_name on its children. |
| 256 | # Hopefully until no childrens occur | 246 | # Hopefully until no childrens occur |
| 247 | # | ||
| 248 | # Queries parent_id directly rather than the NestedTree#children | ||
| 249 | # association out of habit from the old awesome_nested_set-avoidance | ||
| 250 | # workaround - no longer strictly necessary now that children is | ||
| 251 | # equally safe, but left as-is since it already works correctly. | ||
| 257 | def update_unique_names_of_children | 252 | def update_unique_names_of_children |
| 258 | unless root? | 253 | unless root? |
| 259 | # Use parent_id-based traversal instead of lft/rgt descendants | ||
| 260 | # due to awesome_nested_set not refreshing parent lft/rgt in memory | ||
| 261 | Node.where(:parent_id => self.id).each do |child| | 254 | Node.where(:parent_id => self.id).each do |child| |
| 262 | child.reload | 255 | child.reload |
| 263 | child.update_unique_name | 256 | child.update_unique_name |
| @@ -265,10 +258,4 @@ class Node < ApplicationRecord | |||
| 265 | end | 258 | end |
| 266 | end | 259 | end |
| 267 | end | 260 | end |
| 268 | |||
| 269 | def borders | ||
| 270 | if lft && rgt && (lft > rgt) | ||
| 271 | errors.add("Fuck!. lft should never be smaller than rgt") | ||
| 272 | end | ||
| 273 | end | ||
| 274 | end | 261 | end |
diff --git a/db/seeds/chapters.rb b/db/seeds/chapters.rb index bebc8da..4d81e3c 100644 --- a/db/seeds/chapters.rb +++ b/db/seeds/chapters.rb | |||
| @@ -1409,6 +1409,3 @@ puts "Nodes flagged for review:" | |||
| 1409 | (erfas + chaostreffs).select { |e| e[:review] }.each do |e| | 1409 | (erfas + chaostreffs).select { |e| e[:review] }.each do |e| |
| 1410 | puts " #{e[:slug]}" | 1410 | puts " #{e[:slug]}" |
| 1411 | end | 1411 | end |
| 1412 | |||
| 1413 | Node.rebuild!(false) | ||
| 1414 | |||
