335 lines
12 KiB
Elixir
335 lines
12 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)
|
|
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 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 -> descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn)
|
|
end
|
|
end
|
|
|
|
defp 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 -> 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 """
|
|
Gets all descendant IDs for a given entity ID, including the entity itself.
|
|
This recursively finds all children, grandchildren, etc.
|
|
|
|
## Examples
|
|
iex> categories = [
|
|
...> %{id: 1, parent_id: nil},
|
|
...> %{id: 2, parent_id: 1},
|
|
...> %{id: 3, parent_id: 2},
|
|
...> %{id: 4, parent_id: 1}
|
|
...> ]
|
|
iex> Hierarchical.descendant_ids(categories, 1, &(&1.parent_id))
|
|
[1, 2, 3, 4]
|
|
"""
|
|
def descendant_ids(entities, entity_id, parent_id_accessor_fn) do
|
|
[entity_id | get_descendant_ids_recursive(entities, entity_id, parent_id_accessor_fn)]
|
|
end
|
|
|
|
defp get_descendant_ids_recursive(entities, parent_id, parent_id_accessor_fn) do
|
|
children = child_entities(entities, parent_id, parent_id_accessor_fn)
|
|
|
|
Enum.flat_map(children, fn child ->
|
|
[child.id | get_descendant_ids_recursive(entities, child.id, parent_id_accessor_fn)]
|
|
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
|
|
|
|
@doc """
|
|
Calculates component counts for an entity and all its descendants.
|
|
Returns a tuple of {self_count, children_count, total_count}.
|
|
|
|
## Parameters
|
|
- entity_id: The ID of the entity to count for
|
|
- all_entities: List of all entities in the hierarchy
|
|
- parent_id_accessor_fn: Function to get parent_id from an entity
|
|
- count_fn: Function that takes an entity_id and returns the direct count for that entity
|
|
|
|
## Examples
|
|
iex> count_fn = fn id -> MyRepo.count_components_for(id) end
|
|
iex> Hierarchical.count_with_descendants(1, entities, &(&1.parent_id), count_fn)
|
|
{3, 7, 10} # 3 in self, 7 in children, 10 total
|
|
"""
|
|
def count_with_descendants(entity_id, all_entities, parent_id_accessor_fn, count_fn) do
|
|
# Get direct count for this entity
|
|
self_count = count_fn.(entity_id)
|
|
|
|
# Get all descendant entity IDs (excluding self)
|
|
all_descendant_ids = descendant_ids(all_entities, entity_id, parent_id_accessor_fn)
|
|
descendant_ids_only = List.delete(all_descendant_ids, entity_id)
|
|
|
|
# Sum counts for all descendants
|
|
children_count = Enum.reduce(descendant_ids_only, 0, fn id, acc ->
|
|
acc + count_fn.(id)
|
|
end)
|
|
|
|
{self_count, children_count, self_count + children_count}
|
|
end
|
|
|
|
@doc """
|
|
Formats component count display based on expansion state.
|
|
|
|
When collapsed: Shows total count only: "10 components"
|
|
When expanded: Shows breakdown: "10 components (3 self, 7 children)"
|
|
|
|
## Parameters
|
|
- self_count: Number of components directly in this entity
|
|
- children_count: Number of components in all descendant entities
|
|
- is_expanded: Whether the entity is currently expanded
|
|
- singular_noun: What to call a single item (default: "component")
|
|
- plural_noun: What to call multiple items (default: "components")
|
|
"""
|
|
def format_count_display(self_count, children_count, is_expanded, singular_noun \\ "component", plural_noun \\ "components") do
|
|
total_count = self_count + children_count
|
|
|
|
count_noun = if total_count == 1, do: singular_noun, else: plural_noun
|
|
|
|
if is_expanded and children_count > 0 do
|
|
"#{total_count} #{count_noun} (#{self_count} self, #{children_count} children)"
|
|
else
|
|
"#{total_count} #{count_noun}"
|
|
end
|
|
end
|
|
end
|