refactor(elixir): hierarchical refactor
to extract common code patterns from category/storage location systems
This commit is contained in:
@@ -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
|
||||
|
||||
252
lib/components_elixir/inventory/hierarchical.ex
Normal file
252
lib/components_elixir/inventory/hierarchical.ex
Normal 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
|
||||
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal file
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal 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
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user