Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,25 @@ erDiagram
Organization ||--o{ User : ""
```

tbls JSON output
----------------

Rails ERD can emit a JSON description of your domain in the
[tbls](https://github.com/k1LoW/tbls) schema format. Unlike a `schema.rb`
dump, this output captures the relationships Rails ERD derives from
ActiveRecord — so `belongs_to` associations show up as foreign keys even
when the database has no FK constraint.

Any tbls-compatible tool can consume the resulting file. For example, to
render an interactive ERD with [Liam ERD](https://liambx.com):

```bash
bundle exec erd --generator=tbls
# writes erd.json

npx @liam-hq/cli erd build --input erd.json --format tbls
```

Graphviz output
---------------

Expand Down
18 changes: 9 additions & 9 deletions lib/rails_erd/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

option :generator do
long "--generator=Generator"
desc "Generator to use (mermaid or graphviz). Defaults to mermaid."
desc "Generator to use (mermaid, graphviz, or tbls). Defaults to mermaid."
end

option :mermaid_style do
Expand Down Expand Up @@ -193,10 +193,10 @@ def start
def initialize(path, options)
@path, @options = path, options
generator = options[:generator] || RailsERD.options[:generator]
if generator == :mermaid
require "rails_erd/diagram/mermaid"
else
require "rails_erd/diagram/graphviz"
case generator
when :mermaid then require "rails_erd/diagram/mermaid"
when :tbls then require "rails_erd/diagram/tbls"
else require "rails_erd/diagram/graphviz"
end
end

Expand Down Expand Up @@ -236,10 +236,10 @@ def load_application

def generator
generator_type = options[:generator] || RailsERD.options[:generator]
if generator_type == :mermaid
RailsERD::Diagram::Mermaid
else
RailsERD::Diagram::Graphviz
case generator_type
when :mermaid then RailsERD::Diagram::Mermaid
when :tbls then RailsERD::Diagram::Tbls
else RailsERD::Diagram::Graphviz
end
end

Expand Down
168 changes: 168 additions & 0 deletions lib/rails_erd/diagram/tbls.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# frozen_string_literal: true

require "rails_erd/diagram"
require "json"

module RailsERD
class Diagram
# Emits a JSON description of the domain in the tbls schema format
# (https://github.com/k1LoW/tbls). Consumable by any tbls-compatible tool —
# for example Liam ERD: `liam erd build --input erd.json --format tbls`.
#
# Unlike a schema.rb dump, this reflects the relationships rails-erd derives
# from ActiveRecord — including FKs that exist only as `belongs_to`
# associations and not as DB-level constraints.
class Tbls < Diagram
attr_accessor :tables_by_name

setup do
self.tables_by_name = {}
end

each_entity do |entity, _attributes|
next if entity.generalized?
next unless entity.model

table_name = entity.model.table_name
next if tables_by_name.key?(table_name)

tables_by_name[table_name] = build_table(entity)
end

each_relationship do |relationship|
next if relationship.indirect?
next unless relationship.source && relationship.destination
next if relationship.source.generalized? || relationship.destination.generalized?

add_foreign_keys(relationship)
end

save do
dir = File.dirname(filename)
raise "Saving diagram failed!\nOutput directory '#{dir}' does not exist." unless File.directory?(dir)

File.write(filename, JSON.pretty_generate(schema_payload))
filename
end

def filename
"#{options.filename}.json"
end

private

def schema_payload
{
name: domain.name || "rails-erd",
tables: tables_by_name.values,
}
end

def build_table(entity)
model = entity.model
comment = model.respond_to?(:table_comment) ? model.table_comment : nil
payload = {
name: model.table_name,
type: "BASE TABLE",
columns: entity.attributes.map { |attr| column_payload(attr) },
indexes: indexes_payload(model),
constraints: primary_key_constraints(model),
}
payload[:comment] = comment if comment && !comment.empty?
payload
end

def column_payload(attr)
column = attr.column
payload = {
name: column.name,
type: column.sql_type.to_s,
nullable: column.null,
}
payload[:default] = column.default.to_s unless column.default.nil?
comment = column.comment if column.respond_to?(:comment)
payload[:comment] = comment if comment && !comment.empty?
payload
end

def indexes_payload(model)
return [] unless model.connection.respond_to?(:indexes)

model.connection.indexes(model.table_name).map do |idx|
columns = Array(idx.columns).map(&:to_s)
{
name: idx.name,
def: index_def(model.table_name, idx, columns),
table: model.table_name,
columns: columns,
}
end
rescue StandardError
[]
end

def index_def(table_name, idx, columns)
unique = idx.unique ? "UNIQUE " : ""
"CREATE #{unique}INDEX #{idx.name} ON #{table_name} (#{columns.join(", ")})"
end

def primary_key_constraints(model)
pk = Array(model.primary_key).compact.map(&:to_s)
return [] if pk.empty?

[{
name: "#{model.table_name}_pkey",
type: "PRIMARY KEY",
def: "PRIMARY KEY (#{pk.join(", ")})",
table: model.table_name,
referenced_table: "",
columns: pk,
}]
end

def add_foreign_keys(relationship)
relationship.associations.each do |assoc|
next if assoc.options[:polymorphic]
next unless assoc.belongs_to?

fk_columns = Array(assoc.send(Domain.foreign_key_method_name)).map(&:to_s).reject(&:empty?)
next if fk_columns.empty?

target_model = safe_klass(assoc)
next unless target_model

target_pk = Array(target_model.primary_key).compact.map(&:to_s)
next if target_pk.empty?

owning_table = assoc.active_record.table_name
target_table = target_model.table_name
name = "fk_#{owning_table}_#{fk_columns.join("_")}"

add_constraint(owning_table, {
name: name,
type: "FOREIGN KEY",
def: "FOREIGN KEY (#{fk_columns.join(", ")}) REFERENCES #{target_table}(#{target_pk.join(", ")})",
table: owning_table,
referenced_table: target_table,
columns: fk_columns,
referenced_columns: target_pk,
})
end
end

def safe_klass(association)
association.klass
rescue NameError
nil
end

def add_constraint(table_name, constraint)
table = tables_by_name[table_name]
return unless table
return if table[:constraints].any? { |c| c[:name] == constraint[:name] }

table[:constraints] << constraint
end
end
end
end
150 changes: 150 additions & 0 deletions test/unit/tbls_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
require File.expand_path("../test_helper", File.dirname(__FILE__))
require "rails_erd/diagram/tbls"
require "json"

class TblsTest < ActiveSupport::TestCase
def setup
RailsERD.options.warn = false
end

def diagram(options = {})
Diagram::Tbls.new(Domain.generate(options), options).tap(&:generate)
end

def payload(options = {})
diagram(options).send(:schema_payload)
end

def table(payload, name)
payload[:tables].find { |t| t[:name] == name }
end

def constraint(table, type)
table[:constraints].find { |c| c[:type] == type }
end

# File output ==============================================================
test "filename has json extension" do
create_simple_domain
result = Diagram::Tbls.create
assert result.end_with?(".json"), "Expected .json, got #{result}"
end

test "written file is valid JSON parseable by tbls schema shape" do
create_simple_domain
file = Diagram::Tbls.create
parsed = JSON.parse(File.read(file))

assert parsed.key?("tables")
assert parsed["tables"].is_a?(Array)
assert parsed["tables"].all? { |t| t.key?("name") && t.key?("columns") }
end

# Tables ===================================================================
test "emits one table per concrete entity using db table name" do
create_simple_domain
p = payload

names = p[:tables].map { |t| t[:name] }.sort
assert_equal %w[bars beers], names
end

test "columns carry name, type, nullable; default and comment only when set" do
create_model "Widget", name: :string, qty: :integer do
belongs_to :gizmo, optional: true
end
create_model "Gizmo"

widgets = table(payload, "widgets")
name_col = widgets[:columns].find { |c| c[:name] == "name" }

assert_equal "varchar", name_col[:type]
assert_equal true, name_col[:nullable]
# tbls schema rejects null comments/defaults — they must be omitted, not nulled.
refute_includes name_col.keys, :default
refute_includes name_col.keys, :comment
end

# Primary keys =============================================================
test "every table gets a PRIMARY KEY constraint" do
create_simple_domain

table(payload, "beers")[:constraints].tap do |constraints|
pk = constraints.find { |c| c[:type] == "PRIMARY KEY" }
assert pk, "expected a PRIMARY KEY constraint"
assert_equal ["id"], pk[:columns]
assert_equal "beers_pkey", pk[:name]
end
end

# Foreign keys =============================================================
test "belongs_to becomes a FOREIGN KEY constraint on the owning table" do
create_simple_domain # Beer belongs_to :bar

beers = table(payload, "beers")
fk = constraint(beers, "FOREIGN KEY")

assert fk, "expected a FOREIGN KEY constraint on beers"
assert_equal ["bar_id"], fk[:columns]
assert_equal "bars", fk[:referenced_table]
assert_equal ["id"], fk[:referenced_columns]
end

test "FK def string is parseable by tbls parser" do
create_simple_domain
fk = constraint(table(payload, "beers"), "FOREIGN KEY")

assert_equal "FOREIGN KEY (bar_id) REFERENCES bars(id)", fk[:def]
end

test "has_many side does not get an FK; only the belongs_to side does" do
create_one_to_many_assoc_domain # One has_many :many; Many belongs_to :one

ones = table(payload, "ones")
manies = table(payload, "manies")

assert_nil constraint(ones, "FOREIGN KEY"), "owner side must not have an FK"
assert constraint(manies, "FOREIGN KEY"), "belongs_to side must have an FK"
end

test "polymorphic associations are skipped (no synthetic FK)" do
create_polymorphic_generalization # Cannon belongs_to :defensible, polymorphic; Galleon has_many :cannons, as: :defensible

cannons = table(payload(polymorphism: true), "cannons")
assert_nil constraint(cannons, "FOREIGN KEY"),
"polymorphic belongs_to has no concrete target — must not emit FK"
end

test "indirect (has_many :through) relationships do not produce extra constraints" do
create_model "Author" do
has_many :authorships
has_many :books, through: :authorships
end
create_model "Book" do
has_many :authorships
has_many :authors, through: :authorships
end
create_model "Authorship", author: :references, book: :references do
belongs_to :author
belongs_to :book
end

authorships = table(payload, "authorships")
fk_targets = authorships[:constraints]
.select { |c| c[:type] == "FOREIGN KEY" }
.map { |c| c[:referenced_table] }
.sort

# Exactly two FKs — author + book — not duplicated by the indirect relationship.
assert_equal %w[authors books], fk_targets
end

# Schema-level payload =====================================================
test "top-level payload has a name and a tables array" do
create_simple_domain
p = payload

assert p[:name].is_a?(String) && !p[:name].empty?
assert p[:tables].is_a?(Array)
end
end