rails_lens 0.5.4 → 0.10.1

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: d3be7f4e3942405ae7fcd4d50998f62db251786490ebf01cf71637dd3715783a
4
+ data.tar.gz: 712440cb4e800e14b4c10739228144bcbf8308a79fb3b73593df8ccbb7e52b67
5
5
  SHA512:
6
- metadata.gz: 0c17d650e862adcf76e32d7a4a5398cd8c4827b8dd0e0424f01e307a76285e18001c5431561807a049695fbe3c4eaf68960add7d139427b1fcabb36ef2692e06
7
- data.tar.gz: 31bf7b191ec9f5dfedabf351798d38b217a71b85c9ceba7e458ed7b45d61662b367989a40b3e311f9fc1e21f270eacbf55e6998e5b28ff0114fa0b5b37b9ab80
6
+ metadata.gz: 3bcbf3d18443edeb680ab8fa4fd7a3be4c4d95dcf47566c421fb8d0f57ff63deb7ec42d4bf8298154a77093d1b1385213c476a61d5b1b0c37ff2f33a2e867e9f
7
+ data.tar.gz: 1af1394845cc4c6db4e15b2004379100f959b073abee2efab52d77ce3c88ada3939e61e74f458f1e36e4eedd92bb93ef2b3fbb2b30c2ad972584bcaebb2553d2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.1](https://github.com/seuros/rails_lens/compare/rails_lens/v0.10.0...rails_lens/v0.10.1) (2026-10-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * keep the WHERE clause of partial indexes in annotations ([#50](https://github.com/seuros/rails_lens/issues/50)) ([351476e](https://github.com/seuros/rails_lens/commit/351476e03d98e5fb0c506cf7c63e13680230a804))
9
+ * pluralize model/file counts in CLI and rake output ([#47](https://github.com/seuros/rails_lens/issues/47)) ([d870c6c](https://github.com/seuros/rails_lens/commit/d870c6c5128177a6324bc2db17f32be1538e73da))
10
+
11
+ ## [0.10.0](https://github.com/seuros/rails_lens/compare/rails_lens/v0.5.4...rails_lens/v0.10.0) (2026-08-10)
12
+
13
+
14
+ ### Features
15
+
16
+ * composite primary keys ([#45](https://github.com/seuros/rails_lens/issues/45)) ([8d934b5](https://github.com/seuros/rails_lens/commit/8d934b51b310252ce42f3db9aa85894161d562f3))
17
+
3
18
  ## [0.5.4](https://github.com/seuros/rails_lens/compare/rails_lens/v0.5.3...rails_lens/v0.5.4) (2026-06-26)
4
19
 
5
20
 
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
@@ -162,11 +162,10 @@ module RailsLens
162
162
  else
163
163
  []
164
164
  end
165
- if types.any?
166
- "{ name = \"#{reflection.name}\", type_col = \"#{reflection.foreign_type}\", id_col = \"#{reflection.foreign_key}\", types = #{TomlFormat.quoted_array(types)} }"
167
- else
168
- "{ name = \"#{reflection.name}\", type_col = \"#{reflection.foreign_type}\", id_col = \"#{reflection.foreign_key}\" }"
169
- end
165
+ fields = "name = \"#{reflection.name}\", type_col = \"#{reflection.foreign_type}\", " \
166
+ "id_col = \"#{reflection.foreign_key}\""
167
+ fields += ", types = #{TomlFormat.quoted_array(types)}" if types.any?
168
+ "{ #{fields} }"
170
169
  end
171
170
  lines << "references = [#{refs.join(', ')}]"
172
171
  end
@@ -222,7 +222,8 @@ module RailsLens
222
222
  end
223
223
 
224
224
  # Check for STI without index
225
- if model_class.inheritance_column && column_exists?(model_class.inheritance_column) && !has_index?(model_class.inheritance_column)
225
+ if model_class.inheritance_column && column_exists?(model_class.inheritance_column) &&
226
+ !has_index?(model_class.inheritance_column)
226
227
  notes << NoteCodes.note(model_class.inheritance_column, NoteCodes::STI_INDEX)
227
228
  end
228
229
 
@@ -327,7 +328,8 @@ module RailsLens
327
328
  def associations_needing_counter_cache
328
329
  belongs_to_associations.select do |association|
329
330
  # Check if the inverse association exists and is commonly counted
330
- inverse = association.klass.reflect_on_association(association.inverse_of&.name || model_class.name.underscore.pluralize)
331
+ inverse_name = association.inverse_of&.name || model_class.name.underscore.pluralize
332
+ inverse = association.klass.reflect_on_association(inverse_name)
331
333
  inverse && inverse.macro == :has_many && !association.options[:counter_cache]
332
334
  rescue NameError => e
333
335
  RailsLens.logger.debug { "Failed to check counter cache for association: #{e.message}" }
@@ -21,7 +21,8 @@ module RailsLens
21
21
  desc 'annotate', 'Annotate Rails models with schema information'
22
22
  option :models, type: :array, desc: 'Specific models to annotate'
23
23
  option :include_abstract, type: :boolean, desc: 'Include abstract classes'
24
- option :include_database_objects, type: :boolean, default: true, desc: 'Include database objects (functions, etc.) in abstract classes'
24
+ option :include_database_objects, type: :boolean, default: true,
25
+ desc: 'Include database objects (functions, etc.) in abstract classes'
25
26
  option :position, type: :string, enum: %w[before after top bottom], desc: 'Annotation position'
26
27
  option :routes, type: :boolean, desc: 'Annotate controller routes'
27
28
  option :mailers, type: :boolean, desc: 'Annotate mailer methods'
@@ -14,8 +14,8 @@ module RailsLens
14
14
  def annotate_models(options = {})
15
15
  results = Schema::AnnotationManager.annotate_all(options)
16
16
 
17
- output.say "Annotated #{results[:annotated].length} models", :green
18
- report_skipped_and_failed(results, 'models')
17
+ output.say "Annotated #{count_of(results[:annotated], 'model')}", :green
18
+ report_skipped_and_failed(results, 'model')
19
19
 
20
20
  # Also annotate database-level objects (functions, etc.)
21
21
  if options[:include_database_objects]
@@ -29,39 +29,40 @@ module RailsLens
29
29
  def annotate_database_objects(options = {})
30
30
  results = Schema::DatabaseAnnotator.annotate_all(options)
31
31
 
32
- output.say "Annotated #{results[:annotated].length} abstract base classes with database objects", :green
33
- report_skipped_and_failed(results, 'abstract classes')
32
+ output.say "Annotated #{count_of(results[:annotated], 'abstract base class')} with database objects", :green
33
+ report_skipped_and_failed(results, 'abstract class')
34
34
 
35
35
  results
36
36
  end
37
37
 
38
38
  def annotate_routes(options = {})
39
- annotate_files(Route::Annotator.new(dry_run: options[:dry_run]), 'controller files with routes', options)
39
+ annotate_files(Route::Annotator.new(dry_run: options[:dry_run]), 'controller file', options,
40
+ suffix: 'with routes')
40
41
  end
41
42
 
42
43
  def annotate_mailers(options = {})
43
- annotate_files(Mailer::Annotator.new(dry_run: options[:dry_run]), 'mailer files', options)
44
+ annotate_files(Mailer::Annotator.new(dry_run: options[:dry_run]), 'mailer file', options)
44
45
  end
45
46
 
46
47
  def remove_models(options = {})
47
48
  results = Schema::AnnotationManager.remove_all(options)
48
- output.say "Removed annotations from #{results[:removed].length} models", :green
49
+ output.say "Removed annotations from #{count_of(results[:removed], 'model')}", :green
49
50
  results
50
51
  end
51
52
 
52
53
  def remove_routes(options = {})
53
- remove_files(Route::Annotator.new(dry_run: options[:dry_run]), 'route', 'controller files')
54
+ remove_files(Route::Annotator.new(dry_run: options[:dry_run]), 'route', 'controller file')
54
55
  end
55
56
 
56
57
  def remove_mailers(options = {})
57
- remove_files(Mailer::Annotator.new(dry_run: options[:dry_run]), 'mailer', 'mailer files')
58
+ remove_files(Mailer::Annotator.new(dry_run: options[:dry_run]), 'mailer', 'mailer file')
58
59
  end
59
60
 
60
61
  def generate_erd(options = {})
61
62
  visualizer = ERD::Visualizer.new(options: options)
62
- filename = visualizer.generate
63
- output.say "Entity Relationship Diagram generated at #{filename}", :green
64
- filename
63
+ filenames = Array(visualizer.generate)
64
+ filenames.each { |filename| output.say "Entity Relationship Diagram generated at #{filename}", :green }
65
+ filenames.length == 1 ? filenames.first : filenames
65
66
  end
66
67
 
67
68
  def lint(options = {})
@@ -195,27 +196,33 @@ module RailsLens
195
196
 
196
197
  private
197
198
 
199
+ # "1 model" / "0 models" — count plus the correctly inflected noun.
200
+ def count_of(countable, singular)
201
+ count = countable.respond_to?(:length) ? countable.length : countable
202
+ "#{count} #{singular.pluralize(count)}"
203
+ end
204
+
198
205
  # Report skipped/failed counts for a model-annotation results hash.
199
206
  def report_skipped_and_failed(results, noun)
200
- output.say "Skipped #{results[:skipped].length} #{noun}", :yellow if results[:skipped].any?
207
+ output.say "Skipped #{count_of(results[:skipped], noun)}", :yellow if results[:skipped].any?
201
208
  return unless results[:failed].any?
202
209
 
203
- output.say "Failed to annotate #{results[:failed].length} #{noun}:", :red
210
+ output.say "Failed to annotate #{count_of(results[:failed], noun)}:", :red
204
211
  results[:failed].each do |failure|
205
212
  output.say " - #{failure[:model]}: #{failure[:error]}", :red
206
213
  end
207
214
  end
208
215
 
209
- def annotate_files(annotator, label, options)
216
+ def annotate_files(annotator, singular, options, suffix: nil)
210
217
  changed_files = annotator.annotate_all
211
- output.say "Annotated #{changed_files.length} #{label}", :green
218
+ output.say ["Annotated #{count_of(changed_files, singular)}", suffix].compact.join(' '), :green
212
219
  changed_files.each { |file| output.say " - #{file}", :blue } if options[:verbose] && changed_files.any?
213
220
  { changed_files: changed_files }
214
221
  end
215
222
 
216
- def remove_files(annotator, kind, target)
223
+ def remove_files(annotator, kind, singular)
217
224
  changed_files = annotator.remove_all
218
- output.say "Removed #{kind} annotations from #{changed_files.length} #{target}", :green
225
+ output.say "Removed #{kind} annotations from #{count_of(changed_files, singular)}", :green
219
226
  { changed_files: changed_files }
220
227
  end
221
228
 
@@ -255,8 +262,10 @@ module RailsLens
255
262
  # Use RailsLens directly if available
256
263
  if defined?(RailsLens)
257
264
  results = RailsLens::Schema::AnnotationManager.annotate_all
258
- puts "Rails Lens: Annotated \#{results[:annotated].length} models"
259
- puts "Rails Lens: Skipped \#{results[:skipped].length} models" if results[:skipped].any?
265
+ annotated = results[:annotated].length
266
+ skipped = results[:skipped].length
267
+ puts "Rails Lens: Annotated \#{annotated} \#{'model'.pluralize(annotated)}"
268
+ puts "Rails Lens: Skipped \#{skipped} \#{'model'.pluralize(skipped)}" if skipped.positive?
260
269
  else
261
270
  # Fallback to CLI
262
271
  system('bundle exec rails_lens annotate --quiet')
@@ -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
@@ -32,7 +32,9 @@ module RailsLens
32
32
  end
33
33
 
34
34
  def sti_child_models
35
- concrete_models.select { |model| model.superclass != ActiveRecord::Base && concrete_models.include?(model.superclass) }
35
+ concrete_models.select do |model|
36
+ model.superclass != ActiveRecord::Base && concrete_models.include?(model.superclass)
37
+ end
36
38
  end
37
39
 
38
40
  def view_backed_models
@@ -96,7 +98,6 @@ module RailsLens
96
98
  false # If we can't check, assume it's not a view
97
99
  end
98
100
 
99
- # rubocop:disable Naming/PredicateMethod
100
101
  def check_postgresql_view(connection, table_name)
101
102
  # Handle schema-qualified table names (e.g., 'audit.audit_logs')
102
103
  if table_name.include?('.')
@@ -153,7 +154,6 @@ module RailsLens
153
154
  SQL
154
155
  result.rows.any?
155
156
  end
156
- # rubocop:enable Naming/PredicateMethod
157
157
 
158
158
  def eager_load_models
159
159
  # Zeitwerk is always available in Rails 7+
@@ -9,7 +9,8 @@ module RailsLens
9
9
 
10
10
  def applicable?(model_class)
11
11
  # Only applicable to tables, not views
12
- RailsLens.config.extensions[:enabled] && model_has_table?(model_class) && !ModelDetector.view_exists?(model_class)
12
+ RailsLens.config.extensions[:enabled] && model_has_table?(model_class) &&
13
+ !ModelDetector.view_exists?(model_class)
13
14
  end
14
15
 
15
16
  def process(model_class, connection = nil)
@@ -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
@@ -336,6 +337,7 @@ module RailsLens
336
337
  attrs << "columns = #{TomlFormat.quoted_array(Array(index.columns))}"
337
338
  attrs << 'unique = true' if index.unique
338
339
  attrs << "type = \"#{index.type}\"" if index.respond_to?(:type) && index.type
340
+ attrs << "where = \"#{index.where}\"" if index.respond_to?(:where) && index.where
339
341
  line += attrs.join(', ')
340
342
  line += ' }'
341
343
  line += ',' if i < indexes.length - 1
@@ -62,9 +62,13 @@ module RailsLens
62
62
  def database_encoding
63
63
  case adapter_name
64
64
  when 'PostgreSQL'
65
- connection.select_value('SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()')
65
+ connection.select_value(
66
+ 'SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()'
67
+ )
66
68
  when 'Mysql2'
67
- connection.select_value('SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')
69
+ connection.select_value(
70
+ 'SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()'
71
+ )
68
72
  end
69
73
  rescue StandardError
70
74
  nil
@@ -75,7 +79,9 @@ module RailsLens
75
79
  when 'PostgreSQL'
76
80
  connection.select_value('SELECT datcollate FROM pg_database WHERE datname = current_database()')
77
81
  when 'Mysql2'
78
- connection.select_value('SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')
82
+ connection.select_value(
83
+ 'SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()'
84
+ )
79
85
  end
80
86
  rescue StandardError
81
87
  nil
@@ -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)
@@ -11,20 +11,29 @@ namespace :rails_lens do
11
11
 
12
12
  results = RailsLens::Schema::AnnotationManager.annotate_all(options)
13
13
 
14
- if results[:by_source]&.any?
15
- results[:by_source].each do |source_name, count|
16
- puts "Annotated #{count} #{source_name} models" if count.positive?
14
+ # Only report sources that actually produced annotations; a registered source with
15
+ # nothing to annotate is noise, not news.
16
+ annotated_by_source = (results[:by_source] || {}).select { |_source_name, count| count.positive? }
17
+
18
+ if annotated_by_source.any?
19
+ annotated_by_source.each do |source_name, count|
20
+ puts "Annotated #{count} #{source_name} #{'model'.pluralize(count)}"
17
21
  end
18
22
  else
19
- puts "Annotated #{results[:annotated].length} models"
23
+ puts "Annotated #{results[:annotated].length} #{'model'.pluralize(results[:annotated].length)}"
20
24
  end
21
- puts "Skipped #{results[:skipped].length} models" if results[:skipped].any?
25
+ puts "Skipped #{results[:skipped].length} #{'model'.pluralize(results[:skipped].length)}" if results[:skipped].any?
22
26
  if results[:failed].any?
23
- puts "Failed to annotate #{results[:failed].length} models:"
27
+ puts "Failed to annotate #{results[:failed].length} #{'model'.pluralize(results[:failed].length)}:"
24
28
  results[:failed].each do |failure|
25
29
  puts " - #{failure[:model]}: #{failure[:error]}"
26
30
  end
27
31
  end
32
+ if options[:models] && results.values_at(:annotated, :skipped, :failed).all?(&:empty?)
33
+ warn "No models matched '#{options[:models].join(', ')}'. The task argument is a " \
34
+ 'comma-separated list of model class names, e.g. rails_lens:annotate[User,Admin::Account]. ' \
35
+ 'Run rails_lens:annotate without arguments to annotate everything.'
36
+ end
28
37
  end
29
38
 
30
39
  desc 'Remove all annotations from models'
@@ -33,16 +42,20 @@ namespace :rails_lens do
33
42
 
34
43
  results = RailsLens::Schema::AnnotationManager.remove_all
35
44
 
36
- if results[:by_source]&.any?
37
- results[:by_source].each do |source_name, count|
38
- puts "Removed annotations from #{count} #{source_name} models" if count.positive?
45
+ removed_by_source = (results[:by_source] || {}).select { |_source_name, count| count.positive? }
46
+
47
+ if removed_by_source.any?
48
+ removed_by_source.each do |source_name, count|
49
+ puts "Removed annotations from #{count} #{source_name} #{'model'.pluralize(count)}"
39
50
  end
40
51
  elsif results[:removed].any?
41
- puts "Removed annotations from #{results[:removed].length} models"
52
+ puts "Removed annotations from #{results[:removed].length} #{'model'.pluralize(results[:removed].length)}"
53
+ end
54
+ if results[:skipped].any?
55
+ puts "Skipped #{results[:skipped].length} #{'model'.pluralize(results[:skipped].length)} (no annotations)"
42
56
  end
43
- puts "Skipped #{results[:skipped].length} models (no annotations)" if results[:skipped].any?
44
57
  if results[:failed].any?
45
- puts "Failed to remove annotations from #{results[:failed].length} models:"
58
+ puts "Failed to remove annotations from #{results[:failed].length} #{'model'.pluralize(results[:failed].length)}:"
46
59
  results[:failed].each do |failure|
47
60
  puts " - #{failure[:model]}: #{failure[:error]}"
48
61
  end
@@ -9,7 +9,7 @@ namespace :rails_lens do
9
9
  annotator = RailsLens::Mailer::Annotator.new
10
10
  changed_files = annotator.annotate_all
11
11
 
12
- puts "Annotated #{changed_files.length} mailer files with mailer information"
12
+ puts "Annotated #{changed_files.length} #{'mailer file'.pluralize(changed_files.length)} with mailer information"
13
13
  changed_files.each { |file| puts " - #{file}" }
14
14
  end
15
15
 
@@ -20,7 +20,7 @@ namespace :rails_lens do
20
20
  annotator = RailsLens::Mailer::Annotator.new
21
21
  changed_files = annotator.remove_all
22
22
 
23
- puts "Removed mailer annotations from #{changed_files.length} mailer files"
23
+ puts "Removed mailer annotations from #{changed_files.length} #{'mailer file'.pluralize(changed_files.length)}"
24
24
  changed_files.each { |file| puts " - #{file}" }
25
25
  end
26
26
  end
@@ -9,7 +9,8 @@ namespace :rails_lens do
9
9
  annotator = RailsLens::Route::Annotator.new
10
10
  changed_files = annotator.annotate_all
11
11
 
12
- puts "Annotated #{changed_files.length} controller files with route information"
12
+ puts "Annotated #{changed_files.length} #{'controller file'.pluralize(changed_files.length)} " \
13
+ 'with route information'
13
14
  changed_files.each { |file| puts " - #{file}" }
14
15
  end
15
16
 
@@ -20,7 +21,7 @@ namespace :rails_lens do
20
21
  annotator = RailsLens::Route::Annotator.new
21
22
  changed_files = annotator.remove_all
22
23
 
23
- puts "Removed route annotations from #{changed_files.length} controller files"
24
+ puts "Removed route annotations from #{changed_files.length} #{'controller file'.pluralize(changed_files.length)}"
24
25
  changed_files.each { |file| puts " - #{file}" }
25
26
  end
26
27
  end
@@ -20,14 +20,16 @@ namespace :rails_lens do
20
20
  results = RailsLens.annotate_models(options)
21
21
 
22
22
  if results[:annotated].any?
23
- puts "Annotated #{results[:annotated].length} models"
23
+ puts "Annotated #{results[:annotated].length} #{'model'.pluralize(results[:annotated].length)}"
24
24
  puts '(including abstract classes)' if options[:include_abstract]
25
25
  end
26
26
 
27
- puts "\nSkipped #{results[:skipped].length} models (no changes needed)" if results[:skipped].any?
27
+ if results[:skipped].any?
28
+ puts "\nSkipped #{results[:skipped].length} #{'model'.pluralize(results[:skipped].length)} (no changes needed)"
29
+ end
28
30
 
29
31
  if results[:failed].any?
30
- puts "\nFailed to annotate #{results[:failed].length} models:"
32
+ puts "\nFailed to annotate #{results[:failed].length} #{'model'.pluralize(results[:failed].length)}:"
31
33
  results[:failed].each do |failure|
32
34
  puts " ✗ #{failure[:model]}: #{failure[:error]}"
33
35
  end
@@ -42,14 +44,17 @@ namespace :rails_lens do
42
44
  results = RailsLens.remove_annotations
43
45
 
44
46
  if results[:removed].any?
45
- puts "Removed annotations from #{results[:removed].length} models:"
47
+ puts "Removed annotations from #{results[:removed].length} #{'model'.pluralize(results[:removed].length)}:"
46
48
  results[:removed].each { |model| puts " ✓ #{model}" }
47
49
  end
48
50
 
49
- puts "\nSkipped #{results[:skipped].length} models (no annotations found)" if results[:skipped].any?
51
+ if results[:skipped].any?
52
+ puts "\nSkipped #{results[:skipped].length} #{'model'.pluralize(results[:skipped].length)} " \
53
+ '(no annotations found)'
54
+ end
50
55
 
51
56
  if results[:failed].any?
52
- puts "\nFailed to process #{results[:failed].length} models:"
57
+ puts "\nFailed to process #{results[:failed].length} #{'model'.pluralize(results[:failed].length)}:"
53
58
  results[:failed].each do |failure|
54
59
  puts " ✗ #{failure[:model]}: #{failure[:error]}"
55
60
  end
@@ -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.1'
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.1
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: []