diff --git a/README.md b/README.md index 8c8b02d..9e81016 100644 --- a/README.md +++ b/README.md @@ -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 --------------- diff --git a/lib/rails_erd/cli.rb b/lib/rails_erd/cli.rb index 1153c55..6ab279e 100644 --- a/lib/rails_erd/cli.rb +++ b/lib/rails_erd/cli.rb @@ -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 @@ -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 @@ -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 diff --git a/lib/rails_erd/diagram/tbls.rb b/lib/rails_erd/diagram/tbls.rb new file mode 100644 index 0000000..f2f13c5 --- /dev/null +++ b/lib/rails_erd/diagram/tbls.rb @@ -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 diff --git a/test/unit/tbls_test.rb b/test/unit/tbls_test.rb new file mode 100644 index 0000000..f1918ce --- /dev/null +++ b/test/unit/tbls_test.rb @@ -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