rails_lens 0.5.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e504fa26391d500a945947795688a4a52a23922e0ea6a760762415c698d91fc
4
- data.tar.gz: 800c91d7df514dc2c745235cf9f5423a7a68d6499cee7221d5cb89adaa95157b
3
+ metadata.gz: 0f010a15d0d3afdf64754e866cac571fcb1c91e4bd3981c8aa9bdece8d30f85a
4
+ data.tar.gz: 11e8b6ad7da6461bdc846952151537d3495d67cd812cd9f7e4b3ca40dbc37fbb
5
5
  SHA512:
6
- metadata.gz: 0c17d650e862adcf76e32d7a4a5398cd8c4827b8dd0e0424f01e307a76285e18001c5431561807a049695fbe3c4eaf68960add7d139427b1fcabb36ef2692e06
7
- data.tar.gz: 31bf7b191ec9f5dfedabf351798d38b217a71b85c9ceba7e458ed7b45d61662b367989a40b3e311f9fc1e21f270eacbf55e6998e5b28ff0114fa0b5b37b9ab80
6
+ metadata.gz: 22963fce37978c1a4157d9c1ffe236a3ca41c0a97d8b492a832729b467c91923810cc70126c4717c17cbbac9596dfbcd620a34cdb482092b34a8692a37351c23
7
+ data.tar.gz: 4c5bd10154b4d44b4dfda08ec21ebcd3632b6a740e7a9d28ddd7d5a35289fec248fcf76b61bc19db8a53f548691a5597df326bda48f25ad0cc200c94468f94bf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0](https://github.com/seuros/rails_lens/compare/rails_lens/v0.5.4...rails_lens/v0.10.0) (2026-08-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * composite primary keys ([#45](https://github.com/seuros/rails_lens/issues/45)) ([8d934b5](https://github.com/seuros/rails_lens/commit/8d934b51b310252ce42f3db9aa85894161d562f3))
9
+
3
10
  ## [0.5.4](https://github.com/seuros/rails_lens/compare/rails_lens/v0.5.3...rails_lens/v0.5.4) (2026-06-26)
4
11
 
5
12
 
data/README.md CHANGED
@@ -120,7 +120,7 @@ erDiagram
120
120
  ## Requirements
121
121
 
122
122
  - Ruby >= 3.4.0
123
- - Rails >= 7.2.0
123
+ - Rails >= 8.0.0
124
124
 
125
125
  ## Installation
126
126
 
@@ -4,22 +4,10 @@ module RailsLens
4
4
  module Analyzers
5
5
  class CompositeKeys < Base
6
6
  def analyze
7
- # First try Rails native support
8
- if model_class.respond_to?(:primary_keys) && model_class.primary_keys.is_a?(Array)
9
- keys = model_class.primary_keys
10
- return format_composite_keys(keys) if keys.length > 1
11
- end
7
+ pk = model_class.primary_key
8
+ return nil unless pk.is_a?(Array)
12
9
 
13
- # For PostgreSQL, check the actual database constraints
14
- if adapter_name == 'PostgreSQL'
15
- keys = detect_composite_primary_key_from_db
16
- return format_composite_keys(keys) if keys && keys.length > 1
17
- end
18
-
19
- nil
20
- rescue NoMethodError => e
21
- RailsLens.logger.debug { "Failed to analyze composite keys for #{model_class.name}: #{e.message}" }
22
- nil
10
+ format_composite_keys(pk)
23
11
  rescue ActiveRecord::ConnectionNotEstablished => e
24
12
  RailsLens.logger.debug { "No database connection for #{model_class.name}: #{e.message}" }
25
13
  nil
@@ -32,28 +20,6 @@ module RailsLens
32
20
  lines << "keys = #{TomlFormat.quoted_array(keys)}"
33
21
  lines.join("\n")
34
22
  end
35
-
36
- def detect_composite_primary_key_from_db
37
- # Query PostgreSQL system catalogs to find composite primary keys
38
- sql = <<~SQL.squish
39
- SELECT a.attname
40
- FROM pg_index i
41
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
42
- WHERE i.indrelid = '#{table_name}'::regclass
43
- AND i.indisprimary
44
- ORDER BY array_position(i.indkey, a.attnum)
45
- SQL
46
-
47
- result = connection.execute(sql)
48
- keys = result.pluck('attname')
49
- keys.empty? ? nil : keys
50
- rescue ActiveRecord::StatementInvalid => e
51
- RailsLens.logger.debug { "Failed to detect composite keys from database for #{table_name}: #{e.message}" }
52
- nil
53
- rescue PG::Error => e
54
- RailsLens.logger.debug { "PostgreSQL error detecting composite keys: #{e.message}" }
55
- nil
56
- end
57
23
  end
58
24
  end
59
25
  end
@@ -59,9 +59,9 @@ module RailsLens
59
59
 
60
60
  def generate_erd(options = {})
61
61
  visualizer = ERD::Visualizer.new(options: options)
62
- filename = visualizer.generate
63
- output.say "Entity Relationship Diagram generated at #{filename}", :green
64
- filename
62
+ filenames = Array(visualizer.generate)
63
+ filenames.each { |filename| output.say "Entity Relationship Diagram generated at #{filename}", :green }
64
+ filenames.length == 1 ? filenames.first : filenames
65
65
  end
66
66
 
67
67
  def lint(options = {})
@@ -24,7 +24,12 @@ module RailsLens
24
24
  when :binary then 'blob'
25
25
  when :json, :jsonb then 'json'
26
26
  else
27
- @column.type.to_s
27
+ # column.type is nil for types the adapter can't resolve (e.g. a
28
+ # PostGIS geometry column without activerecord-postgis loaded);
29
+ # the diagram gem rejects empty attribute types.
30
+ fallback = @column.type.to_s
31
+ fallback = @column.sql_type.to_s if fallback.empty?
32
+ fallback.empty? ? 'unknown' : fallback
28
33
  end
29
34
  end
30
35
  end
@@ -20,7 +20,13 @@ module RailsLens
20
20
 
21
21
  def generate
22
22
  models = load_models
23
- generate_mermaid(models)
23
+ if config[:group_by_database]
24
+ group_models_by_database(models).map do |db_name, group|
25
+ generate_mermaid(group, basename: "erd_#{db_name}")
26
+ end
27
+ else
28
+ generate_mermaid(models)
29
+ end
24
30
  end
25
31
 
26
32
  private
@@ -29,11 +35,15 @@ module RailsLens
29
35
  ModelDetector.detect_models(options)
30
36
  end
31
37
 
32
- def generate_mermaid(models)
38
+ def group_models_by_database(models)
39
+ models.group_by { |model| model.connection_pool.db_config.name }
40
+ end
41
+
42
+ def generate_mermaid(models, basename: 'erd')
33
43
  if models.blank?
34
44
  # Still need to save the output even if no models found
35
45
  mermaid_output = "erDiagram\n %% No models found"
36
- return save_output(mermaid_output, 'mmd')
46
+ return save_output(mermaid_output, 'mmd', basename: basename)
37
47
  end
38
48
 
39
49
  # Create new ERDiagram using mermaid-ruby gem
@@ -41,25 +51,15 @@ module RailsLens
41
51
 
42
52
  # Process models and add them to the diagram
43
53
  models.each do |model|
44
- # Skip abstract models
45
- next if model.abstract_class?
46
-
47
- # Skip models without valid tables/views or columns
48
- is_view = ModelDetector.view_exists?(model)
49
- has_data_source = is_view || (model.table_exists? && model.columns.present?)
50
- next unless has_data_source
54
+ next unless renderable?(model)
51
55
 
52
56
  begin
53
57
  # Create attributes for the entity
54
- attributes = []
55
- model.columns.each do |column|
56
- type_str = format_column_type(column)
57
- keys = determine_keys(model, column)
58
-
59
- attributes << {
60
- type: type_str,
58
+ attributes = model.columns.map do |column|
59
+ {
60
+ type: format_column_type(column),
61
61
  name: column.name,
62
- keys: keys
62
+ keys: determine_keys(model, column)
63
63
  }
64
64
  end
65
65
 
@@ -74,26 +74,31 @@ module RailsLens
74
74
  RailsLens.logger.debug { "Warning: Could not add entity #{model.name}: #{e.message}" }
75
75
  end
76
76
 
77
- # Add relationships
78
- next if model.abstract_class?
79
-
80
- is_view = ModelDetector.view_exists?(model)
81
- has_data_source = is_view || (model.table_exists? && model.columns.present?)
82
- next unless has_data_source
83
-
84
- add_model_relationships(diagram, model, models)
77
+ # Relationships require both entities to exist in the diagram
78
+ add_model_relationships(diagram, model, models) if diagram.entities.key?(model.name)
85
79
  end
86
80
 
87
81
  # Generate mermaid syntax using the gem
88
82
  mermaid_output = diagram.to_mermaid
89
83
 
90
84
  # Save output
91
- filename = save_output(mermaid_output, 'mmd')
85
+ filename = save_output(mermaid_output, 'mmd', basename: basename)
92
86
 
93
87
  RailsLens.logger.debug 'ERD generated successfully!'
94
88
  filename # Return the filename instead of content
95
89
  end
96
90
 
91
+ # A model renders when it is concrete and its table/view is reachable;
92
+ # an unavailable connection just drops the model from the diagram.
93
+ def renderable?(model)
94
+ return false if model.abstract_class?
95
+
96
+ ModelDetector.view_exists?(model) || (model.table_exists? && model.columns.present?)
97
+ rescue ActiveRecord::ActiveRecordError => e
98
+ RailsLens.logger.debug { "Skipping #{model.name}: #{e.message}" }
99
+ false
100
+ end
101
+
97
102
  def format_column_type(column)
98
103
  formatter_class = case column.sql_type
99
104
  when /jsonb|uuid|inet|array|tsvector/i
@@ -109,13 +114,10 @@ module RailsLens
109
114
 
110
115
  def determine_keys(model, column)
111
116
  keys = []
112
- keys << :PK if column.name == model.primary_key
113
-
114
- # Check foreign keys
115
- if model.respond_to?(:reflect_on_all_associations)
116
- model.reflect_on_all_associations(:belongs_to).each do |assoc|
117
- keys << :FK if assoc.foreign_key.to_s == column.name
118
- end
117
+ # primary_key and foreign_key are arrays for composite keys
118
+ keys << :PK if Array(model.primary_key).include?(column.name)
119
+ keys << :FK if model.reflect_on_all_associations(:belongs_to).any? do |assoc|
120
+ Array(assoc.foreign_key).map(&:to_s).include?(column.name)
119
121
  end
120
122
 
121
123
  # Check unique indexes - use UK which will be automatically quoted as comment
@@ -143,9 +145,9 @@ module RailsLens
143
145
 
144
146
  next unless target_model && models.include?(target_model)
145
147
 
146
- # Skip relationships to abstract models
147
- next if target_model.abstract_class?
148
- next unless target_model.table_exists? && target_model.columns.present?
148
+ # Skip targets whose entity was not added to the diagram
149
+ # (abstract, unreachable connection, or add_entity failed)
150
+ next unless diagram.entities.key?(target_model.name)
149
151
 
150
152
  add_association_relationship(diagram, model, association, target_model)
151
153
  end
@@ -181,11 +183,11 @@ module RailsLens
181
183
  end
182
184
  end
183
185
 
184
- def save_output(content, extension)
186
+ def save_output(content, extension, basename: 'erd')
185
187
  output_dir = config[:output_dir] || 'doc/erd'
186
188
  FileUtils.mkdir_p(output_dir)
187
189
 
188
- filename = File.join(output_dir, "erd.#{extension}")
190
+ filename = File.join(output_dir, "#{basename}.#{extension}")
189
191
  File.write(filename, content)
190
192
 
191
193
  RailsLens.logger.debug { "ERD saved to: #{filename}" }
@@ -42,7 +42,7 @@ module RailsLens
42
42
  class ErrorReporter
43
43
  class << self
44
44
  def report(error, context = {})
45
- return unless RailsLens.verbose || RailsLens.debug
45
+ return unless RailsLens.config.verbose || RailsLens.config.debug
46
46
 
47
47
  message = build_error_message(error, context)
48
48
 
@@ -50,7 +50,7 @@ module RailsLens
50
50
  RailsLens.logger.error message
51
51
 
52
52
  # Use kernel output for debug mode to ensure visibility
53
- return unless RailsLens.debug
53
+ return unless RailsLens.config.debug
54
54
 
55
55
  RailsLens.logger.debug message
56
56
  end
@@ -59,7 +59,7 @@ module RailsLens
59
59
  yield
60
60
  rescue StandardError => e
61
61
  report(e, context)
62
- raise if RailsLens.raise_on_error
62
+ raise if RailsLens.config.raise_on_error
63
63
 
64
64
  nil
65
65
  end
@@ -201,6 +201,7 @@ module RailsLens
201
201
  # @return [void]
202
202
  def write_content_to_file(path:, content:)
203
203
  return if @dry_run
204
+ return if File.exist?(path) && File.read(path) == content
204
205
 
205
206
  File.write(path, content)
206
207
  @changed_files << path
@@ -243,6 +243,7 @@ module RailsLens
243
243
  # @return [void]
244
244
  def write_content_to_file(path:, content:)
245
245
  return if @dry_run
246
+ return if File.exist?(path) && File.read(path) == content
246
247
 
247
248
  File.write(path, content)
248
249
  @changed_files << path
@@ -164,11 +164,12 @@ module RailsLens
164
164
  end
165
165
 
166
166
  def primary_key?(column)
167
- column.name == primary_key_name
167
+ primary_key_names.include?(column.name)
168
168
  end
169
169
 
170
- def primary_key_name
171
- @primary_key_name ||= connection.primary_key(unqualified_table_name)
170
+ # The singular connection.primary_key returns nil for composite keys.
171
+ def primary_key_names
172
+ @primary_key_names ||= connection.primary_keys(unqualified_table_name)
172
173
  end
173
174
 
174
175
  def column_name_width
@@ -112,9 +112,9 @@ module RailsLens
112
112
  end
113
113
  end
114
114
 
115
- def primary_key_name
116
- @primary_key_name ||= with_schema_in_search_path do
117
- connection.primary_key(unqualified_table_name)
115
+ def primary_key_names
116
+ @primary_key_names ||= with_schema_in_search_path do
117
+ connection.primary_keys(unqualified_table_name)
118
118
  end
119
119
  end
120
120
 
@@ -104,19 +104,33 @@ module RailsLens
104
104
  results = { annotated: [], skipped: [], failed: [] }
105
105
 
106
106
  begin
107
- models = source.models(options)
107
+ models = filter_models_by_names(source.models(options), options[:models])
108
108
  puts " Found #{models.size} #{source.source_name} models" if options[:verbose]
109
109
 
110
110
  models.each do |model|
111
111
  record_result(results, source.annotate_model(model, options))
112
112
  end
113
113
  rescue StandardError => e
114
- puts " Error processing #{source.source_name} source: #{e.message}" if options[:verbose]
114
+ results[:failed] << { model: "#{source.source_name} (source)", error: e.message }
115
115
  end
116
116
 
117
117
  results
118
118
  end
119
119
 
120
+ # Enforce the models filter for every source, including sources whose
121
+ # own #models implementation ignores options[:models].
122
+ def self.filter_models_by_names(models, names)
123
+ names = Array(names)
124
+ return models if names.empty?
125
+
126
+ models.select do |model|
127
+ names.any? do |name|
128
+ model.name == name ||
129
+ (model.respond_to?(:table_name) && model.table_name == name)
130
+ end
131
+ end
132
+ end
133
+
120
134
  # Merge source results into main results. +primary_key+ is the
121
135
  # source-specific success bucket (:annotated or :removed).
122
136
  def self.merge_results(main, source, primary_key)
@@ -13,7 +13,7 @@ namespace :rails_lens do
13
13
 
14
14
  if results[:by_source]&.any?
15
15
  results[:by_source].each do |source_name, count|
16
- puts "Annotated #{count} #{source_name} models" if count.positive?
16
+ puts "Annotated #{count} #{source_name} models"
17
17
  end
18
18
  else
19
19
  puts "Annotated #{results[:annotated].length} models"
@@ -25,6 +25,11 @@ namespace :rails_lens do
25
25
  puts " - #{failure[:model]}: #{failure[:error]}"
26
26
  end
27
27
  end
28
+ if options[:models] && results.values_at(:annotated, :skipped, :failed).all?(&:empty?)
29
+ warn "No models matched '#{options[:models].join(', ')}'. The task argument is a " \
30
+ 'comma-separated list of model class names, e.g. rails_lens:annotate[User,Admin::Account]. ' \
31
+ 'Run rails_lens:annotate without arguments to annotate everything.'
32
+ end
28
33
  end
29
34
 
30
35
  desc 'Remove all annotations from models'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsLens
4
- VERSION = '0.5.4'
4
+ VERSION = '0.10.0'
5
5
  end
data/lib/rails_lens.rb CHANGED
@@ -31,6 +31,9 @@ module RailsLens
31
31
  extend Configuration
32
32
 
33
33
  class << self
34
+ # Path of the loaded config file, nil when running on defaults.
35
+ attr_reader :config_file
36
+
34
37
  def logger
35
38
  @logger ||= config.logger || default_logger
36
39
  end
@@ -52,6 +55,7 @@ module RailsLens
52
55
  def load_config_file(path = '.rails-lens.yml')
53
56
  return unless File.exist?(path)
54
57
 
58
+ @config_file = path
55
59
  yaml = YAML.load_file(path)
56
60
 
57
61
  yaml.each do |section, settings|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_lens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 7.2.0
18
+ version: 8.0.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 7.2.0
25
+ version: 8.0.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: railties
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 7.2.0
32
+ version: 8.0.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 7.2.0
39
+ version: 8.0.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: ostruct
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -85,14 +85,14 @@ dependencies:
85
85
  requirements:
86
86
  - - ">="
87
87
  - !ruby/object:Gem::Version
88
- version: 7.2.0
88
+ version: 8.0.0
89
89
  type: :development
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - ">="
94
94
  - !ruby/object:Gem::Version
95
- version: 7.2.0
95
+ version: 8.0.0
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: dotenv
98
98
  requirement: !ruby/object:Gem::Requirement
@@ -149,7 +149,7 @@ dependencies:
149
149
  - - "~>"
150
150
  - !ruby/object:Gem::Version
151
151
  version: '2.0'
152
- description: Rails Lens provides unified visualization and annotation for Rails 7.2+
152
+ description: Rails Lens provides unified visualization and annotation for Rails 8.0+
153
153
  applications, integrating ERD generation and model annotations.
154
154
  email:
155
155
  - terminale@gmail.com
@@ -273,7 +273,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
273
273
  - !ruby/object:Gem::Version
274
274
  version: '0'
275
275
  requirements: []
276
- rubygems_version: 4.0.6
276
+ rubygems_version: 4.0.10
277
277
  specification_version: 4
278
278
  summary: Comprehensive Rails application visualization and annotation
279
279
  test_files: []