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 # Remove descendants (they would create a cycle) entity_id == editing_entity_id || 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