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.
Summary
Functions
Finds all circular foreign key references (directed cycles) in the ERD.
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.
Validates a schema and returns a list of issues.
Functions
@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 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 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