Files
component-system/lib/components_elixir/inventory/hierarchical.ex
Schuwi 264adbfb98 refactor(elixir): hierarchical refactor
to extract common code patterns from
category/storage location systems
2025-09-17 23:56:56 +02:00

253 lines
8.5 KiB
Elixir

defmodule ComponentsElixir.Inventory.Hierarchical do
@moduledoc """
Shared hierarchical behavior for entities with parent-child relationships.
This module provides common functionality for:
- Path computation (e.g., "Parent > Child > Grandchild")
- Cycle detection and prevention
- Parent/child filtering for UI dropdowns
- Tree traversal utilities
Based on the elegant category implementation approach.
"""
@doc """
Computes full hierarchical path for an entity.
Uses recursive traversal of parent chain, loading parents from database if needed.
Optimized to minimize database queries by trying preloaded associations first.
## Examples
iex> category = %Category{name: "Resistors", parent: %Category{name: "Electronics", parent: nil}}
iex> Hierarchical.full_path(category, &(&1.parent))
"Electronics > Resistors"
"""
def full_path(entity, parent_accessor_fn, separator \\ " > ")
def full_path(nil, _parent_accessor_fn, _separator), do: ""
def full_path(entity, parent_accessor_fn, separator) do
case parent_accessor_fn.(entity) do
nil ->
entity.name
%Ecto.Association.NotLoaded{} ->
# Parent not loaded - fall back to database lookup
# This is a fallback and should be rare if preloading is done correctly
build_path_with_db_lookup(entity, separator)
parent ->
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
end
end
# Helper function to build path when parent associations are not loaded
# This is optimized to minimize database queries
defp build_path_with_db_lookup(entity, separator) do
# Build path by walking up the parent chain via database queries
# Collect parent names from root to leaf
path_parts = collect_path_from_root(entity, [])
Enum.join(path_parts, separator)
end
defp collect_path_from_root(entity, path_so_far) do
case entity.parent_id do
nil ->
# This is a root entity, add its name and return the complete path
[entity.name | path_so_far]
parent_id ->
# Load parent from database
case load_parent_entity(entity, parent_id) do
nil ->
# Parent not found (orphaned record), treat this as root
[entity.name | path_so_far]
parent ->
# Recursively get the path from the parent, then add current entity
collect_path_from_root(parent, [entity.name | path_so_far])
end
end
end
defp load_parent_entity(%{__struct__: module} = _entity, parent_id) do
# Note: This function makes individual database queries
# For better performance, consider preloading parent associations properly
# or implementing batch loading if this becomes a bottleneck
ComponentsElixir.Repo.get(module, parent_id)
end
@doc """
Filters entities to remove circular reference options for parent selection.
Prevents an entity from being its own ancestor.
## Examples
iex> categories = [%{id: 1, parent_id: nil}, %{id: 2, parent_id: 1}]
iex> Hierarchical.filter_parent_options(categories, 1, &(&1.id), &(&1.parent_id))
[%{id: 2, parent_id: 1}] # ID 1 filtered out (self-reference)
"""
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn)
def filter_parent_options(entities, nil, _id_accessor_fn, _parent_id_accessor_fn) do
entities
end
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn) do
entities
|> Enum.reject(fn entity ->
entity_id = id_accessor_fn.(entity)
# Remove self-reference
entity_id == editing_entity_id ||
# Remove descendants (they would create a cycle)
is_descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
end)
end
@doc """
Checks if an entity is a descendant of an ancestor entity.
Used for cycle detection in parent selection.
"""
def is_descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
descendant = Enum.find(entities, fn e -> e.id == descendant_id end)
case descendant do
nil -> false
entity -> is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn)
end
end
defp is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn) do
case parent_id_accessor_fn.(entity) do
nil -> false
^ancestor_id -> true
parent_id ->
parent = Enum.find(entities, fn e -> e.id == parent_id end)
case parent do
nil -> false
parent_entity -> is_descendant_recursive(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
end
end
end
@doc """
Gets all root entities (entities with no parent).
"""
def root_entities(entities, parent_id_accessor_fn) do
Enum.filter(entities, fn entity ->
is_nil(parent_id_accessor_fn.(entity))
end)
end
@doc """
Gets all child entities of a specific parent.
"""
def child_entities(entities, parent_id, parent_id_accessor_fn) do
Enum.filter(entities, fn entity ->
parent_id_accessor_fn.(entity) == parent_id
end)
end
@doc """
Generates display name for entity including parent context.
For dropdown displays: "Parent > Child"
"""
def display_name(entity, parent_accessor_fn, separator \\ " > ") do
full_path(entity, parent_accessor_fn, separator)
end
@doc """
Generates options for a parent selection dropdown.
Includes proper filtering to prevent cycles and formatted display names.
Results are sorted hierarchically for intuitive navigation.
"""
def parent_select_options(entities, editing_entity_id, parent_accessor_fn, nil_option_text \\ "No parent") do
available_entities =
filter_parent_options(
entities,
editing_entity_id,
&(&1.id),
&(&1.parent_id)
)
|> sort_hierarchically(&(&1.parent_id))
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
[{nil_option_text, nil}] ++ available_entities
end
@doc """
Generates options for a general selection dropdown (like filters).
Results are sorted hierarchically for intuitive navigation.
"""
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
sorted_entities =
entities
|> sort_hierarchically(&(&1.parent_id))
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
if nil_option_text do
[{nil_option_text, nil}] ++ sorted_entities
else
sorted_entities
end
end
@doc """
Computes the depth/level of an entity in the hierarchy.
Root entities have level 0.
"""
def compute_level(entity, parent_accessor_fn) do
case parent_accessor_fn.(entity) do
nil -> 0
%Ecto.Association.NotLoaded{} -> 0
parent -> 1 + compute_level(parent, parent_accessor_fn)
end
end
@doc """
Returns the separator string used for a specific entity type.
Categories use " > " while storage locations use " / ".
"""
def separator_for(:category), do: " > "
def separator_for(:storage_location), do: " / "
def separator_for(_), do: " > "
@doc """
Sorts entities hierarchically in depth-first order.
Each parent is followed immediately by all its children (recursively).
Within each level, entities are sorted alphabetically by name.
## Examples
iex> entities = [
...> %{id: 1, name: "Resistors", parent_id: nil},
...> %{id: 2, name: "Wire", parent_id: 1},
...> %{id: 3, name: "Capacitors", parent_id: nil},
...> %{id: 4, name: "Ceramic", parent_id: 3}
...> ]
iex> Hierarchical.sort_hierarchically(entities, &(&1.parent_id))
# Returns: [Capacitors, Capacitors>Ceramic, Resistors, Resistors>Wire]
"""
def sort_hierarchically(entities, parent_id_accessor_fn) do
# First, get all root entities sorted alphabetically
root_entities =
entities
|> root_entities(parent_id_accessor_fn)
|> Enum.sort_by(& &1.name)
# Then recursively add children after each parent
Enum.flat_map(root_entities, fn root ->
[root | sort_children_recursively(entities, root.id, parent_id_accessor_fn)]
end)
end
defp sort_children_recursively(entities, parent_id, parent_id_accessor_fn) do
children =
entities
|> child_entities(parent_id, parent_id_accessor_fn)
|> Enum.sort_by(& &1.name)
Enum.flat_map(children, fn child ->
[child | sort_children_recursively(entities, child.id, parent_id_accessor_fn)]
end)
end
end