forked from fixin.me/fixin.me
Several fixes to make complex Arel/CTE queries work with both MySQL and SQLite: Unit model: - Add guard for description length validation (text column .limit returns nil in SQLite) - defaults_diff: rename 'units' CTE to 'all_units' to avoid SQLite circular reference (SQLite treats any CTE in WITH RECURSIVE that references a same-named table as circular) - defaults_diff: read from 'all_units' CTE explicitly in UNION parts via AR::Relation instead of relying on CTE name shadowing; use AR::Relation (not SelectManager) for UNION parts to avoid extra parentheses (visit_Arel_SelectManager always wraps in parens) - defaults_diff: qualify GROUP BY and ORDER BY columns to avoid ambiguity when bases_units join adds a second table with same column names - Qualify :symbol in ordering to avoid ambiguous column in joined queries Quantity model: - Add guard for description length validation (text column .limit returns nil in SQLite) - ordered: rename recursive CTE from 'quantities' to 'q_ordered' to avoid circular reference; use AR::Relation for UNION parts; fix column qualifiers; use printf() instead of LPAD() for SQLite (LPAD not supported), BLOB instead of BINARY cast - common_ancestors: rename CTE to 'q_common', use AR::Relation for UNION parts - with_ancestors: rename CTE to 'q_ancestors', use AR::Relation for UNION parts - successive: replace SQL LAG window function approach (which causes nested WITH RECURSIVE) with Ruby array approach for SQLite compatibility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
183 lines
6.6 KiB
Ruby
183 lines
6.6 KiB
Ruby
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
|