Analysis and topological query functions for Choreo.ERD.
Provides graph algorithms optimized for database schema design, including:
orphans/1— finds disconnected tables.cycles/1— identifies circular foreign key references.table_degrees/1— measures coupling (incoming/outgoing links).shortest_join_path/3— calculates the optimal join sequence between two tables.normalization_score/2— scores schema health and reports normalization smells.validate/1— returns structural and normalization issues as{severity, message}tuples.affected_by/2— returns tables that transitively reference a target table.depends_on/2— returns tables that the target table transitively relates to.transitive_reduction/1— finds redundant relationships implied by longer paths.longest_dependency_chain/1— finds the longest cascade of relationships.
Summary
Functions
Returns all tables that transitively depend on the given table.
Finds all circular foreign key references (directed cycles) in the ERD.
Returns all tables that the given table transitively depends on.
Finds the longest chain of relationships in the schema.
Calculates a database normalization and schema quality score.
Finds all "orphan" tables that have no incoming or outgoing relationships.
Finds the shortest sequence of tables required to perform a join between
start and dest.
Calculates incoming, outgoing, and total degrees of all tables.
Identifies redundant relationships that are implied by a longer path.
Validates a schema and returns a list of issues.
Functions
@spec affected_by(Choreo.ERD.t(), Choreo.ERD.table_id()) :: [Choreo.ERD.table_id()]
Returns all tables that transitively depend on the given table.
If you change target, these are the tables that could break.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:posts, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:comments, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:users, :posts, cardinality: :one_to_many)
...> |> Choreo.ERD.add_relationship(:posts, :comments, cardinality: :one_to_many)
iex> Enum.sort(Choreo.ERD.Analysis.affected_by(erd, :comments))
[:posts, :users]
iex> Choreo.ERD.Analysis.affected_by(erd, :users)
[]This analysis answers the question: "What breaks if I change this table?"
@spec cycles(Choreo.ERD.t()) :: [[Choreo.ERD.table_id()]]
Finds all circular foreign key references (directed cycles) in the ERD.
Circular references can prevent clean database teardowns, complicate cascade triggers, and indicate suboptimal database normalization.
Returns a list of cycles, where each cycle is a list of node IDs starting at the canonical smallest node and listing each member once.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:a, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:b, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:a, :b, cardinality: :one_to_one)
...> |> Choreo.ERD.add_relationship(:b, :a, cardinality: :one_to_one)
iex> Choreo.ERD.Analysis.cycles(erd)
[[:a, :b]]
@spec depends_on(Choreo.ERD.t(), Choreo.ERD.table_id()) :: [Choreo.ERD.table_id()]
Returns all tables that the given table transitively depends on.
These are the tables target cannot function without, following the
directed relationships in the diagram.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:posts, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:comments, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:users, :posts, cardinality: :one_to_many)
...> |> Choreo.ERD.add_relationship(:posts, :comments, cardinality: :one_to_many)
iex> Enum.sort(Choreo.ERD.Analysis.depends_on(erd, :users))
[:comments, :posts]
iex> Choreo.ERD.Analysis.depends_on(erd, :comments)
[]This analysis answers the question: "What does this table depend on?"
@spec longest_dependency_chain(Choreo.ERD.t()) :: {:ok, [Choreo.ERD.table_id()], number()} | :error
Finds the longest chain of relationships in the schema.
This measures the maximum depth of foreign-key cascades and is useful for estimating migration, deletion, or query-plan complexity.
Returns {:ok, [table_id], total_edges} or :error if the schema
contains a cycle.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:posts, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:comments, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:users, :posts, cardinality: :one_to_many)
...> |> Choreo.ERD.add_relationship(:posts, :comments, cardinality: :one_to_many)
iex> Choreo.ERD.Analysis.longest_dependency_chain(erd)
{:ok, [:users, :posts, :comments], 2}This analysis answers the question: "What is the deepest relationship chain?"
@spec normalization_score( Choreo.ERD.t(), keyword() ) :: %{score: number(), smells: [String.t()]}
Calculates a database normalization and schema quality score.
The score starts at 100 and is reduced by:
:large_column— Tables with more than 15 columns (default: -15):many_to_many— Direct:many_to_manyrelationships without a junction table (default: -10):one_to_one—:one_to_onerelationships indicating potential split entities (default: 0):orphan— Tables with no relationships (default: -10)
Opinionated defaults
The
:one_to_onepenalty defaults to 0 because legitimate 1-1 splits (e.g., PII isolation) are common. Passweights: [one_to_one: 5]to opt-in to penalizing them.
The score is capped at a minimum of 0.
Options
:weights— Keyword list of custom penalties (e.g.,[large_column: 20, orphan: 5]):column_threshold— Threshold for column count (default: 15)
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:posts, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:users, :posts, cardinality: :many_to_many)
iex> Choreo.ERD.Analysis.normalization_score(erd)
%{
score: 90,
smells: ["Direct many-to-many relationship between :users and :posts."]
}
@spec orphans(Choreo.ERD.t()) :: [Choreo.ERD.table_id()]
Finds all "orphan" tables that have no incoming or outgoing relationships.
Orphan tables are structurally isolated from the rest of the database schema.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:a, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:b, columns: [%{name: :id, type: :integer}])
iex> Choreo.ERD.Analysis.orphans(erd)
[:a, :b]
@spec shortest_join_path(Choreo.ERD.t(), Choreo.ERD.table_id(), Choreo.ERD.table_id()) :: {:ok, [Choreo.ERD.table_id()]} | :error
Finds the shortest sequence of tables required to perform a join between
start and dest.
This treats the schema as an undirected graph, as joins can be traversed in either direction regardless of which table holds the foreign key.
Returns {:ok, path} where path is a list of table IDs, or :error if
the tables are not connected.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:posts, columns: [%{name: :id, type: :integer}, %{name: :user_id, type: :integer}])
...> |> Choreo.ERD.add_table(:comments, columns: [%{name: :id, type: :integer}, %{name: :post_id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:users, :posts, cardinality: :one_to_many)
...> |> Choreo.ERD.add_relationship(:posts, :comments, cardinality: :one_to_many)
iex> Choreo.ERD.Analysis.shortest_join_path(erd, :users, :comments)
{:ok, [:users, :posts, :comments]}
@spec table_degrees(Choreo.ERD.t()) :: %{ required(Choreo.ERD.table_id()) => %{ in: integer(), out: integer(), total: integer() } }
Calculates incoming, outgoing, and total degrees of all tables.
Highly coupled tables with high degree counts (e.g. users) serve as central hubs,
while tables with low degree counts represent leaves.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:posts, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:users, :posts, cardinality: :one_to_many)
iex> Choreo.ERD.Analysis.table_degrees(erd)
%{
users: %{in: 0, out: 1, total: 1},
posts: %{in: 1, out: 0, total: 1}
}
@spec transitive_reduction(Choreo.ERD.t()) :: [ {Choreo.ERD.table_id(), Choreo.ERD.table_id()} ]
Identifies redundant relationships that are implied by a longer path.
If users -> posts, posts -> comments, and users -> comments all
exist, the direct users -> comments edge is redundant because it is
implied by the transitive path.
Returns a list of {from, to} tuples. On cyclic schemas returns an
empty list because every edge in a cycle is structurally required.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:posts, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:comments, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:users, :posts, cardinality: :one_to_many)
...> |> Choreo.ERD.add_relationship(:posts, :comments, cardinality: :one_to_many)
...> |> Choreo.ERD.add_relationship(:users, :comments, cardinality: :one_to_many)
iex> Choreo.ERD.Analysis.transitive_reduction(erd)
[{:users, :comments}]This analysis answers the question: "Which explicit relationships are redundant?"
@spec validate(Choreo.ERD.t()) :: [{:error | :warning, String.t()}]
Validates a schema and returns a list of issues.
Checks for:
- unclassified orphan tables
- direct many-to-many relationships without a junction table
Returns a list of {severity, message} tuples.
Note on validation idiom
ERD uses
normalization_score/2for quality scoring rather than a binary pass/fail check.validate/1wraps the most critical smells into the standard tuple format for composability with other modules.
Examples
iex> erd =
...> Choreo.ERD.new()
...> |> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_table(:posts, columns: [%{name: :id, type: :integer}])
...> |> Choreo.ERD.add_relationship(:users, :posts, cardinality: :many_to_many)
iex> issues = Choreo.ERD.Analysis.validate(erd)
iex> Enum.any?(issues, fn {sev, _} -> sev == :error end)
true