Choreo.ERD.Analysis (Choreo v0.10.0)

Copy Markdown View Source

Analysis and topological query functions for Choreo.ERD.

Provides graph algorithms optimized for database schema design, including:

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

affected_by(erd, target)

@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?"

cycles(erd)

@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]]

depends_on(erd, target)

@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?"

longest_dependency_chain(erd)

@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?"

normalization_score(erd, opts \\ [])

@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_many relationships without a junction table (default: -10)
  • :one_to_one:one_to_one relationships indicating potential split entities (default: 0)
  • :orphan — Tables with no relationships (default: -10)

Opinionated defaults

The :one_to_one penalty defaults to 0 because legitimate 1-1 splits (e.g., PII isolation) are common. Pass weights: [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."]
}

orphans(erd)

@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]

shortest_join_path(erd, start, dest)

@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]}

table_degrees(erd)

@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}
}

transitive_reduction(erd)

@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?"

validate(erd)

@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/2 for quality scoring rather than a binary pass/fail check. validate/1 wraps 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