Skip to content
Merged
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
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ jobs:
strategy:
fail-fast: false
matrix:
rails: ['7.2', '8.0', '8.1']
rails: ['8.0', '8.1', 'edge']

# Edge (rails/rails main, future 8.2) is informational, not blocking
continue-on-error: ${{ matrix.rails == 'edge' }}

services:
postgres:
Expand Down Expand Up @@ -102,9 +105,11 @@ jobs:
echo 'Waiting for MySQL...'
sleep 2
done
# Create and migrate all databases in multi-database setup
# Create all databases and load the per-database schema dumps.
# There are no migration files, so db:migrate is a no-op; only
# Rails >= 8.1 test_help loads the schemas implicitly.
bundle exec rails db:create:all RAILS_ENV=test
bundle exec rails db:migrate RAILS_ENV=test
bundle exec rails db:schema:load RAILS_ENV=test

- name: Run tests
run: bundle exec rake test
Expand Down
17 changes: 12 additions & 5 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,25 @@ gem 'rake', '~> 13.0'
gem 'minitest', '~> 5.17'
gem 'minitest-reporters', '~> 1.6'

# Support testing against different Rails versions
if ENV['RAILS_VERSION']
# Support testing against different Rails versions ('edge' = rails/rails main)
# rubocop:disable Bundler/DuplicatedGem -- branches are mutually exclusive
if ENV['RAILS_VERSION'] == 'edge'
git 'https://github.com/rails/rails.git', branch: 'main' do
gem 'actionmailer'
gem 'activerecord'
gem 'railties'
end
elsif ENV['RAILS_VERSION']
rails_version = ENV['RAILS_VERSION']
gem 'actionmailer', "~> #{rails_version}.0"
gem 'activerecord', "~> #{rails_version}.0"
gem 'railties', "~> #{rails_version}.0"
else
gem 'actionmailer', '>= 7.2.0'
gem 'actionmailer', '>= 8.0.0'
end
# rubocop:enable Bundler/DuplicatedGem

# PostGIS adapter only supports Rails 8+
gem 'activerecord-postgis' if !ENV['RAILS_VERSION'] || ENV['RAILS_VERSION'].to_i >= 8
gem 'activerecord-postgis'
gem 'closure_tree'
gem 'dotenv', '~> 3.0'
gem 'rubocop', '~> 1.66'
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ erDiagram
## Requirements

- Ruby >= 3.4.0
- Rails >= 7.2.0
- Rails >= 8.0.0

## Installation

Expand Down
13 changes: 8 additions & 5 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ require 'bundler/gem_tasks'

desc 'Run all tests'
task :test do # rubocop:disable Rails/RakeEnvironment
test_files = Dir['test/**/*_test.rb'].reject { |f| f.include?('test/dummy/') }
# One process per file: `ruby file1 file2` only executes file1 (the rest
# become ARGV), and the multi-database tests assume a fresh boot anyway.
test_files = Dir['test/**/*_test.rb'].reject { |f| f.include?('test/dummy/') }.sort

# Run minitest directly to avoid rake test runner issues
cmd = "ruby -Ilib:test #{test_files.join(' ')}"
failed = test_files.reject do |file|
puts "== #{file}"
system(Gem.ruby, '-Ilib:test', file)
end

puts "Running tests with: #{cmd}"
system(cmd) || exit(1)
abort "\n#{failed.length} test file(s) failed:\n#{failed.join("\n")}" if failed.any?
end

task default: :test
4 changes: 4 additions & 0 deletions lib/rails_lens.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ module RailsLens
extend Configuration

class << self
# Path of the loaded config file, nil when running on defaults.
attr_reader :config_file

def logger
@logger ||= config.logger || default_logger
end
Expand All @@ -52,6 +55,7 @@ def default_logger
def load_config_file(path = '.rails-lens.yml')
return unless File.exist?(path)

@config_file = path
yaml = YAML.load_file(path)

yaml.each do |section, settings|
Expand Down
40 changes: 3 additions & 37 deletions lib/rails_lens/analyzers/composite_keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,10 @@ module RailsLens
module Analyzers
class CompositeKeys < Base
def analyze
# First try Rails native support
if model_class.respond_to?(:primary_keys) && model_class.primary_keys.is_a?(Array)
keys = model_class.primary_keys
return format_composite_keys(keys) if keys.length > 1
end
pk = model_class.primary_key
return nil unless pk.is_a?(Array)

# For PostgreSQL, check the actual database constraints
if adapter_name == 'PostgreSQL'
keys = detect_composite_primary_key_from_db
return format_composite_keys(keys) if keys && keys.length > 1
end

nil
rescue NoMethodError => e
RailsLens.logger.debug { "Failed to analyze composite keys for #{model_class.name}: #{e.message}" }
nil
format_composite_keys(pk)
rescue ActiveRecord::ConnectionNotEstablished => e
RailsLens.logger.debug { "No database connection for #{model_class.name}: #{e.message}" }
nil
Expand All @@ -32,28 +20,6 @@ def format_composite_keys(keys)
lines << "keys = #{TomlFormat.quoted_array(keys)}"
lines.join("\n")
end

def detect_composite_primary_key_from_db
# Query PostgreSQL system catalogs to find composite primary keys
sql = <<~SQL.squish
SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = '#{table_name}'::regclass
AND i.indisprimary
ORDER BY array_position(i.indkey, a.attnum)
SQL

result = connection.execute(sql)
keys = result.pluck('attname')
keys.empty? ? nil : keys
rescue ActiveRecord::StatementInvalid => e
RailsLens.logger.debug { "Failed to detect composite keys from database for #{table_name}: #{e.message}" }
nil
rescue PG::Error => e
RailsLens.logger.debug { "PostgreSQL error detecting composite keys: #{e.message}" }
nil
end
end
end
end
6 changes: 3 additions & 3 deletions lib/rails_lens/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ def remove_mailers(options = {})

def generate_erd(options = {})
visualizer = ERD::Visualizer.new(options: options)
filename = visualizer.generate
output.say "Entity Relationship Diagram generated at #{filename}", :green
filename
filenames = Array(visualizer.generate)
filenames.each { |filename| output.say "Entity Relationship Diagram generated at #{filename}", :green }
filenames.length == 1 ? filenames.first : filenames
end

def lint(options = {})
Expand Down
7 changes: 6 additions & 1 deletion lib/rails_lens/erd/column_type_formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ def format
when :binary then 'blob'
when :json, :jsonb then 'json'
else
@column.type.to_s
# column.type is nil for types the adapter can't resolve (e.g. a
# PostGIS geometry column without activerecord-postgis loaded);
# the diagram gem rejects empty attribute types.
fallback = @column.type.to_s
fallback = @column.sql_type.to_s if fallback.empty?
fallback.empty? ? 'unknown' : fallback
end
end
end
Expand Down
80 changes: 41 additions & 39 deletions lib/rails_lens/erd/visualizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ def initialize(options: {})

def generate
models = load_models
generate_mermaid(models)
if config[:group_by_database]
group_models_by_database(models).map do |db_name, group|
generate_mermaid(group, basename: "erd_#{db_name}")
end
else
generate_mermaid(models)
end
end

private
Expand All @@ -29,37 +35,31 @@ def load_models
ModelDetector.detect_models(options)
end

def generate_mermaid(models)
def group_models_by_database(models)
models.group_by { |model| model.connection_pool.db_config.name }
end

def generate_mermaid(models, basename: 'erd')
if models.blank?
# Still need to save the output even if no models found
mermaid_output = "erDiagram\n %% No models found"
return save_output(mermaid_output, 'mmd')
return save_output(mermaid_output, 'mmd', basename: basename)
end

# Create new ERDiagram using mermaid-ruby gem
diagram = Diagrams::ERDiagram.new

# Process models and add them to the diagram
models.each do |model|
# Skip abstract models
next if model.abstract_class?

# Skip models without valid tables/views or columns
is_view = ModelDetector.view_exists?(model)
has_data_source = is_view || (model.table_exists? && model.columns.present?)
next unless has_data_source
next unless renderable?(model)

begin
# Create attributes for the entity
attributes = []
model.columns.each do |column|
type_str = format_column_type(column)
keys = determine_keys(model, column)

attributes << {
type: type_str,
attributes = model.columns.map do |column|
{
type: format_column_type(column),
name: column.name,
keys: keys
keys: determine_keys(model, column)
}
end

Expand All @@ -74,26 +74,31 @@ def generate_mermaid(models)
RailsLens.logger.debug { "Warning: Could not add entity #{model.name}: #{e.message}" }
end

# Add relationships
next if model.abstract_class?

is_view = ModelDetector.view_exists?(model)
has_data_source = is_view || (model.table_exists? && model.columns.present?)
next unless has_data_source

add_model_relationships(diagram, model, models)
# Relationships require both entities to exist in the diagram
add_model_relationships(diagram, model, models) if diagram.entities.key?(model.name)
end

# Generate mermaid syntax using the gem
mermaid_output = diagram.to_mermaid

# Save output
filename = save_output(mermaid_output, 'mmd')
filename = save_output(mermaid_output, 'mmd', basename: basename)

RailsLens.logger.debug 'ERD generated successfully!'
filename # Return the filename instead of content
end

# A model renders when it is concrete and its table/view is reachable;
# an unavailable connection just drops the model from the diagram.
def renderable?(model)
return false if model.abstract_class?

ModelDetector.view_exists?(model) || (model.table_exists? && model.columns.present?)
rescue ActiveRecord::ActiveRecordError => e
RailsLens.logger.debug { "Skipping #{model.name}: #{e.message}" }
false
end

def format_column_type(column)
formatter_class = case column.sql_type
when /jsonb|uuid|inet|array|tsvector/i
Expand All @@ -109,13 +114,10 @@ def format_column_type(column)

def determine_keys(model, column)
keys = []
keys << :PK if column.name == model.primary_key

# Check foreign keys
if model.respond_to?(:reflect_on_all_associations)
model.reflect_on_all_associations(:belongs_to).each do |assoc|
keys << :FK if assoc.foreign_key.to_s == column.name
end
# primary_key and foreign_key are arrays for composite keys
keys << :PK if Array(model.primary_key).include?(column.name)
keys << :FK if model.reflect_on_all_associations(:belongs_to).any? do |assoc|
Array(assoc.foreign_key).map(&:to_s).include?(column.name)
end

# Check unique indexes - use UK which will be automatically quoted as comment
Expand Down Expand Up @@ -143,9 +145,9 @@ def add_model_relationships(diagram, model, models)

next unless target_model && models.include?(target_model)

# Skip relationships to abstract models
next if target_model.abstract_class?
next unless target_model.table_exists? && target_model.columns.present?
# Skip targets whose entity was not added to the diagram
# (abstract, unreachable connection, or add_entity failed)
next unless diagram.entities.key?(target_model.name)

add_association_relationship(diagram, model, association, target_model)
end
Expand Down Expand Up @@ -181,11 +183,11 @@ def add_association_relationship(diagram, model, association, target_model)
end
end

def save_output(content, extension)
def save_output(content, extension, basename: 'erd')
output_dir = config[:output_dir] || 'doc/erd'
FileUtils.mkdir_p(output_dir)

filename = File.join(output_dir, "erd.#{extension}")
filename = File.join(output_dir, "#{basename}.#{extension}")
File.write(filename, content)

RailsLens.logger.debug { "ERD saved to: #{filename}" }
Expand Down
6 changes: 3 additions & 3 deletions lib/rails_lens/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ class VisualizationError < ERDError; end
class ErrorReporter
class << self
def report(error, context = {})
return unless RailsLens.verbose || RailsLens.debug
return unless RailsLens.config.verbose || RailsLens.config.debug

message = build_error_message(error, context)

# Use Rails logger for verbose mode
RailsLens.logger.error message

# Use kernel output for debug mode to ensure visibility
return unless RailsLens.debug
return unless RailsLens.config.debug

RailsLens.logger.debug message
end
Expand All @@ -59,7 +59,7 @@ def handle(context = {})
yield
rescue StandardError => e
report(e, context)
raise if RailsLens.raise_on_error
raise if RailsLens.config.raise_on_error

nil
end
Expand Down
1 change: 1 addition & 0 deletions lib/rails_lens/mailer/annotator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ def extract_formats_from_templates(templates)
# @return [void]
def write_content_to_file(path:, content:)
return if @dry_run
return if File.exist?(path) && File.read(path) == content

File.write(path, content)
@changed_files << path
Expand Down
1 change: 1 addition & 0 deletions lib/rails_lens/route/annotator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ def write_to_file(path:, parsed_file:)
# @return [void]
def write_content_to_file(path:, content:)
return if @dry_run
return if File.exist?(path) && File.read(path) == content

File.write(path, content)
@changed_files << path
Expand Down
7 changes: 4 additions & 3 deletions lib/rails_lens/schema/adapters/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,12 @@ def format_default(default)
end

def primary_key?(column)
column.name == primary_key_name
primary_key_names.include?(column.name)
end

def primary_key_name
@primary_key_name ||= connection.primary_key(unqualified_table_name)
# The singular connection.primary_key returns nil for composite keys.
def primary_key_names
@primary_key_names ||= connection.primary_keys(unqualified_table_name)
end

def column_name_width
Expand Down
Loading