refactor(elixir): hierarchical refactor

to extract common code patterns from
category/storage location systems
This commit is contained in:
Schuwi
2025-09-17 23:56:56 +02:00
parent 963c9a3770
commit 264adbfb98
12 changed files with 415 additions and 1173 deletions

View File

@@ -5,6 +5,7 @@ defmodule ComponentsElixir.Inventory.Category do
Categories can be hierarchical with parent-child relationships.
"""
use Ecto.Schema
use ComponentsElixir.Inventory.HierarchicalSchema
import Ecto.Changeset
alias ComponentsElixir.Inventory.{Category, Component}
@@ -34,11 +35,20 @@ defmodule ComponentsElixir.Inventory.Category do
@doc """
Returns the full path of the category including parent names.
"""
def full_path(%Category{parent: nil} = category), do: category.name
def full_path(%Category{parent: %Category{} = parent} = category) do
"#{full_path(parent)} > #{category.name}"
end
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
category.name
@impl true
def full_path(%Category{} = category) do
Hierarchical.full_path(category, &(&1.parent), path_separator())
end
@impl true
def parent(%Category{parent: parent}), do: parent
@impl true
def children(%Category{children: children}), do: children
@impl true
def path_separator(), do: " > "
@impl true
def entity_type(), do: :category
end

View File

@@ -0,0 +1,252 @@
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

View File

@@ -0,0 +1,41 @@
defmodule ComponentsElixir.Inventory.HierarchicalSchema do
@moduledoc """
Behaviour for schemas that implement hierarchical relationships.
Provides a contract for entities with parent-child relationships,
ensuring consistent interface across different hierarchical entities.
"""
@doc """
Returns the full hierarchical path as a string.
Example: "Electronics > Components > Resistors"
"""
@callback full_path(struct()) :: String.t()
@doc """
Returns the parent entity or nil if this is a root entity.
"""
@callback parent(struct()) :: struct() | nil
@doc """
Returns the children entities as a list.
"""
@callback children(struct()) :: [struct()]
@doc """
Returns the separator used for path display.
"""
@callback path_separator() :: String.t()
@doc """
Returns the entity type for use with the Hierarchical module.
"""
@callback entity_type() :: atom()
defmacro __using__(_opts) do
quote do
@behaviour ComponentsElixir.Inventory.HierarchicalSchema
alias ComponentsElixir.Inventory.Hierarchical
end
end
end

View File

@@ -3,9 +3,10 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
Schema for storage locations with hierarchical organization.
Storage locations can be nested (shelf -> drawer -> box) and each
has a unique QR code for quick scanning and identification.
has a unique AprilTag for quick scanning and identification.
"""
use Ecto.Schema
use ComponentsElixir.Inventory.HierarchicalSchema
import Ecto.Changeset
import Ecto.Query
@@ -16,10 +17,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
field :description, :string
field :apriltag_id, :integer
# Computed/virtual fields - not stored in database
field :level, :integer, virtual: true
field :path, :string, virtual: true
# Only parent relationship is stored
belongs_to :parent, StorageLocation
has_many :children, StorageLocation, foreign_key: :parent_id
@@ -37,47 +34,26 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|> validate_length(:description, max: 500)
|> validate_apriltag_id()
|> foreign_key_constraint(:parent_id)
|> validate_no_circular_reference()
|> put_apriltag_id()
end
# Prevent circular references (location being its own ancestor)
defp validate_no_circular_reference(changeset) do
case get_change(changeset, :parent_id) do
nil -> changeset
parent_id ->
location_id = changeset.data.id
if location_id && would_create_cycle?(location_id, parent_id) do
add_error(changeset, :parent_id, "cannot be a descendant of this location")
else
changeset
end
end
# HierarchicalSchema implementations
@impl true
def full_path(%StorageLocation{} = storage_location) do
Hierarchical.full_path(storage_location, &(&1.parent), path_separator())
end
defp would_create_cycle?(location_id, parent_id) do
# Check if parent_id is the same as location_id or any of its descendants
location_id == parent_id or
(parent_id && is_descendant_of?(parent_id, location_id))
end
@impl true
def parent(%StorageLocation{parent: parent}), do: parent
defp is_descendant_of?(potential_descendant, ancestor_id) do
case ComponentsElixir.Repo.get(StorageLocation, potential_descendant) do
nil -> false
%{parent_id: nil} -> false
%{parent_id: ^ancestor_id} -> true
%{parent_id: parent_id} -> is_descendant_of?(parent_id, ancestor_id)
end
end
@impl true
def children(%StorageLocation{children: children}), do: children
@doc """
Returns the full hierarchical path as a human-readable string.
"""
def full_path(storage_location) do
storage_location.path
|> String.split("/")
|> Enum.join("")
end
@impl true
def path_separator(), do: " / "
@impl true
def entity_type(), do: :storage_location
@doc """
Returns the AprilTag format for this storage location.
@@ -102,28 +78,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
end
end
# Compute the hierarchy level based on parent chain
def compute_level(%StorageLocation{parent_id: nil}), do: 0
def compute_level(%StorageLocation{parent: %StorageLocation{} = parent}) do
compute_level(parent) + 1
end
def compute_level(%StorageLocation{parent_id: parent_id}) when not is_nil(parent_id) do
# Parent not loaded, fetch it
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
compute_level(parent) + 1
end
# Compute the full path based on parent chain
def compute_path(%StorageLocation{name: name, parent_id: nil}), do: name
def compute_path(%StorageLocation{name: name, parent: %StorageLocation{} = parent}) do
"#{compute_path(parent)}/#{name}"
end
def compute_path(%StorageLocation{name: name, parent_id: parent_id}) when not is_nil(parent_id) do
# Parent not loaded, fetch it
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
"#{compute_path(parent)}/#{name}"
end
defp get_next_available_apriltag_id do
# Get all used AprilTag IDs
used_ids = ComponentsElixir.Repo.all(