class Quantity < ApplicationRecord ATTRIBUTES = [:name, :description, :parent_id] attr_cached :depth, :pathname belongs_to :user, optional: true belongs_to :parent, optional: true, class_name: "Quantity" has_many :subquantities, ->{ order(:name) }, class_name: "Quantity", inverse_of: :parent, dependent: :restrict_with_error validate if: ->{ parent.present? } do errors.add(:parent, :user_mismatch) unless user_id == parent.user_id errors.add(:parent, :self_reference) if id == parent_id end validate if: ->{ parent.present? }, on: :update do errors.add(:parent, :descendant_reference) if ancestor_of?(parent) end validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]}, length: {maximum: type_for_attribute(:name).limit} validates :description, length: {maximum: type_for_attribute(:description).limit} if type_for_attribute(:description).limit # Update :depths of progenies after parent change before_save if: :parent_changed? do self[:depth] = parent&.depth&.succ || 0 end after_update if: :depth_previously_changed? do quantities = Quantity.arel_table selected = Arel::Table.new('selected') Quantity.with_recursive(selected: [ quantities.project(quantities[:id].as('quantity_id'), quantities[:depth]) .where(quantities[:id].eq(id)), quantities.project(quantities[:id], selected[:depth] + 1) .join(selected).on(selected[:quantity_id].eq(quantities[:parent_id])) ]).joins(:selected).update_all(depth: selected[:depth]) end # Update :pathnames of progenies after parent/name change PATHNAME_DELIMITER = ' → ' before_save if: -> { parent_changed? || name_changed? } do self[:pathname] = (parent ? parent.pathname + PATHNAME_DELIMITER : '') + self[:name] end after_update if: :pathname_previously_changed? do quantities = Quantity.arel_table selected = Arel::Table.new('selected') Quantity.with_recursive(selected: [ quantities.project(quantities[:id].as('quantity_id'), quantities[:pathname]) .where(quantities[:id].eq(id)), quantities.project( quantities[:id], selected[:pathname].concat(Arel::Nodes.build_quoted(PATHNAME_DELIMITER)) .concat(quantities[:name]) ).join(selected).on(selected[:quantity_id].eq(quantities[:parent_id])) ]).joins(:selected).update_all(pathname: selected[:pathname]) end scope :defaults, ->{ where(user: nil) } # Return: ordered [sub]hierarchy scope :ordered, ->(root: nil, include_root: true) { q_ordered = Arel::Table.new('q_ordered') cast_type = connection.adapter_name == 'Mysql2' ? 'BINARY' : 'BLOB' self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(q_ordered: [ Quantity.from(Arel::Table.new('numbered').as(arel_table.name)) .select( arel_table[Arel.star], arel_table.cast(arel_table[:child_number], cast_type).as('path') ).where(arel_table[root && include_root ? :id : :parent_id].eq(root)), Quantity.from(Arel::Table.new('numbered').as(arel_table.name)) .joins("INNER JOIN q_ordered ON quantities.parent_id = q_ordered.id") .select( arel_table[Arel.star], q_ordered[:path].concat(arel_table[:child_number]) ) ]).from(q_ordered.as(arel_table.name)).order(arel_table[:path]) } # TODO: extract named functions to custom Arel extension # NOTE: once recursive queries allow use of window functions, this scope can # be merged with :ordered # https://gist.github.com/ProGM/c6df08da14708dcc28b5ca325df37ceb#extending-arel scope :numbered, ->(parent_column, order_column) { row_number = Arel::Nodes::NamedFunction.new('ROW_NUMBER', []) .over(Arel::Nodes::Window.new.partition(parent_column).order(order_column)) width = Arel::SelectManager.new.project( Arel::Nodes::NamedFunction.new('LENGTH', [Arel.star.count]) ).from(arel_table) pad_expr = if connection.adapter_name == 'Mysql2' Arel::Nodes::NamedFunction.new('LPAD', [row_number, width, Arel::Nodes.build_quoted('0')]) else # SQLite: printf('%0' || width || 'd', row_number) fmt = Arel::Nodes::InfixOperation.new('||', Arel::Nodes::InfixOperation.new('||', Arel::Nodes.build_quoted('%0'), width), Arel::Nodes.build_quoted('d') ) Arel::Nodes::NamedFunction.new('printf', [fmt, row_number]) end select(arel_table[Arel.star], pad_expr.as('child_number')) } def to_s name end def to_s_with_depth # em space, U+2003 ' ' * depth + name end def destroyable? subquantities.empty? end def default? parent_id.nil? end # Common ancestors, assuming node is a descendant of itself scope :common_ancestors, ->(of) { q_common = Arel::Table.new('q_common') # Take unique IDs, so self can be called with parent nodes of collection to # get common ancestors of collection _excluding_ nodes in collection. uniq_of = of.uniq model.with(selected: self).with_recursive(q_common: [ Quantity.from(Arel::Table.new('selected').as(arel_table.name)) .where(arel_table[:id].in(uniq_of)), Quantity.from(Arel::Table.new('selected').as(arel_table.name)) .joins("INNER JOIN q_common ON selected.id = q_common.parent_id") ]).select(arel_table[Arel.star]) .from(q_common.as(arel_table.name)) .group(column_names) .having(arel_table[:id].count.eq(uniq_of.size)) .order(arel_table[:depth].desc) } # Return: successive record in order of appearance; used for partial view reload def successive ordered = user.quantities.ordered.to_a idx = ordered.index { |q| q.id == id } ordered[idx + 1] if idx end def with_progenies user.quantities.ordered(root: id).to_a end def progenies user.quantities.ordered(root: id, include_root: false).to_a end # Return: record with ID `of` with its ancestors, sorted by :depth scope :with_ancestors, ->(of) { q_ancestors = Arel::Table.new('q_ancestors') model.with(selected: self).with_recursive(q_ancestors: [ Quantity.from(Arel::Table.new('selected').as(arel_table.name)) .where(arel_table[:id].eq(of)), Quantity.from(Arel::Table.new('selected').as(arel_table.name)) .joins("INNER JOIN q_ancestors ON selected.id = q_ancestors.parent_id") ]).from(q_ancestors.as(arel_table.name)) } # Return: ancestors of (possibly destroyed) self def ancestors user.quantities.with_ancestors(parent_id).order(:depth).to_a end def ancestor_of?(progeny) user.quantities.with_ancestors(progeny.id).exists?(id) end def relative_pathname(ancestor) pathname.delete_prefix(ancestor ? ancestor.pathname + PATHNAME_DELIMITER : '') end end