# frozen-string-literal: true module Sequel module Plugins # The tree plugin adds additional associations and methods that allow you to # treat a Model as a tree. # # A column for holding the parent key is required and is :parent_id by default. # This may be overridden by passing column name via :key. # # Optionally, a column to control order of nodes returned can be specified # by passing column name via :order. # # If you pass true for the :single_root option, the class will ensure there is # only ever one root in the tree. # # Examples: # # class Node < Sequel::Model # plugin :tree # end # # class Node < Sequel::Model # plugin :tree, key: :parentid, order: :position # end module Tree # Create parent and children associations. Any options # specified are passed to both associations. You can also # specify options to use for just the parent association # using a :parent option, and options to use for just the # children association using a :children option. def self.apply(model, opts=OPTS) opts = opts.dup opts[:class] = model opts[:key] ||= :parent_id par = opts.merge(opts.fetch(:parent, OPTS)) parent = par.fetch(:name, :parent) chi = opts.merge(opts.fetch(:children, OPTS)) children = chi.fetch(:name, :children) par[:reciprocal] = children chi[:reciprocal] = parent model.instance_exec do @parent_column = opts[:key] @qualified_parent_column = Sequel.deep_qualify(table_name, opts[:key]) @tree_order = opts[:order] @parent_association_name = parent @children_association_name = children many_to_one parent, par one_to_many children, chi plugin SingleRoot if opts[:single_root] end end module ClassMethods # The column symbol or array of column symbols on which to order the tree. attr_accessor :tree_order # The symbol or array of symbols for the column containing the value pointing to the # parent of the node. attr_accessor :parent_column # The qualified identifier or array of qualified identifiers for the column # containing the value pointing to the parent of the node. attr_accessor :qualified_parent_column # The association name for the parent association attr_reader :parent_association_name # The association name for the children association attr_reader :children_association_name Plugins.inherited_instance_variables(self, :@parent_column=>nil, :@qualified_parent_column=>nil, :@tree_order=>nil, :@parent_association_name=>nil, :@children_association_name=>nil) Plugins.def_dataset_methods(self, [:roots, :roots_dataset]) # Should freeze tree order if it is an array when freezing the model class. def freeze @tree_order.freeze if @tree_order.is_a?(Array) super end end module InstanceMethods # Returns list of ancestors, starting from parent until root. # # subchild1.ancestors # => [child1, root] def ancestors node, nodes = self, [] meth = model.parent_association_name while par = node.send(meth) nodes << node = par end nodes end # Returns list of descendants # # node.descendants # => [child1, child2, subchild1_1, subchild1_2, subchild2_1, subchild2_2] def descendants nodes = send(model.children_association_name).dup send(model.children_association_name).each{|child| nodes.concat(child.descendants)} nodes end # Returns the root node of the tree that this node descends from. # This node is returned if it is a root node itself. def root ancestors.last || self end # Returns true if this is a root node, false otherwise. def root? !new? && possible_root? end # Returns all siblings and a reference to the current node. # # subchild1.self_and_siblings # => [subchild1, subchild2] def self_and_siblings if parent = send(model.parent_association_name) parent.send(model.children_association_name) else model.roots end end # Returns all siblings of the current node. # # subchild1.siblings # => [subchild2] def siblings self_and_siblings - [self] end private # True if if all parent columns values are not NULL. def possible_root? !Array(model.parent_column).map{|c| self[c]}.all? end end module DatasetMethods # Returns list of all root nodes (those with no parent nodes). # # TreeClass.roots # => [root1, root2] def roots roots_dataset.all end # Returns the dataset for retrieval of all root nodes # # TreeClass.roots_dataset # => Sequel::Dataset instance def roots_dataset ds = where(Sequel.or(Array(model.qualified_parent_column).zip([]))) ds = ds.order(*model.tree_order) if model.tree_order ds end end # Plugin included when :single_root option is passed. module SingleRoot module ClassMethods # Returns the single root node. def root roots_dataset.first end end module InstanceMethods # Hook that prevents a second root from being created. def before_save if possible_root? && (root = model.root) && pk != root.pk raise TreeMultipleRootError, "there is already a root #{model.name} defined" end super end end end class TreeMultipleRootError < Error; end end end end