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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +1 -1
- data/lib/rails_lens/analyzers/composite_keys.rb +3 -37
- data/lib/rails_lens/analyzers/inheritance.rb +4 -5
- data/lib/rails_lens/analyzers/notes.rb +4 -2
- data/lib/rails_lens/cli.rb +2 -1
- data/lib/rails_lens/commands.rb +29 -20
- data/lib/rails_lens/erd/column_type_formatter.rb +6 -1
- data/lib/rails_lens/erd/visualizer.rb +41 -39
- data/lib/rails_lens/errors.rb +3 -3
- data/lib/rails_lens/mailer/annotator.rb +1 -0
- data/lib/rails_lens/model_detector.rb +3 -3
- data/lib/rails_lens/providers/extension_notes_provider.rb +2 -1
- data/lib/rails_lens/route/annotator.rb +1 -0
- data/lib/rails_lens/schema/adapters/base.rb +5 -3
- data/lib/rails_lens/schema/adapters/database_info.rb +9 -3
- data/lib/rails_lens/schema/adapters/postgresql.rb +3 -3
- data/lib/rails_lens/schema/annotation_manager.rb +16 -2
- data/lib/rails_lens/tasks/annotate.rake +25 -12
- data/lib/rails_lens/tasks/mailers.rake +2 -2
- data/lib/rails_lens/tasks/routes.rake +3 -2
- data/lib/rails_lens/tasks/schema.rake +11 -6
- data/lib/rails_lens/version.rb +1 -1
- data/lib/rails_lens.rb +4 -0
- metadata +9 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3be7f4e3942405ae7fcd4d50998f62db251786490ebf01cf71637dd3715783a
|
|
4
|
+
data.tar.gz: 712440cb4e800e14b4c10739228144bcbf8308a79fb3b73593df8ccbb7e52b67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -4,22 +4,10 @@ module RailsLens
|
|
|
4
4
|
module Analyzers
|
|
5
5
|
class CompositeKeys < Base
|
|
6
6
|
def analyze
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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) &&
|
|
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
|
-
|
|
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}" }
|
data/lib/rails_lens/cli.rb
CHANGED
|
@@ -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,
|
|
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'
|
data/lib/rails_lens/commands.rb
CHANGED
|
@@ -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]
|
|
18
|
-
report_skipped_and_failed(results, '
|
|
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]
|
|
33
|
-
report_skipped_and_failed(results, 'abstract
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
63
|
-
output.say "Entity Relationship Diagram generated at #{filename}", :green
|
|
64
|
-
|
|
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]
|
|
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]
|
|
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,
|
|
216
|
+
def annotate_files(annotator, singular, options, suffix: nil)
|
|
210
217
|
changed_files = annotator.annotate_all
|
|
211
|
-
output.say "Annotated #{changed_files
|
|
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,
|
|
223
|
+
def remove_files(annotator, kind, singular)
|
|
217
224
|
changed_files = annotator.remove_all
|
|
218
|
-
output.say "Removed #{kind} annotations from #{changed_files
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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:
|
|
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
|
-
#
|
|
78
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
next unless
|
|
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, "
|
|
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}" }
|
data/lib/rails_lens/errors.rb
CHANGED
|
@@ -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
|
|
@@ -32,7 +32,9 @@ module RailsLens
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def sti_child_models
|
|
35
|
-
concrete_models.select
|
|
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) &&
|
|
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)
|
|
@@ -164,11 +164,12 @@ module RailsLens
|
|
|
164
164
|
end
|
|
165
165
|
|
|
166
166
|
def primary_key?(column)
|
|
167
|
-
column.name
|
|
167
|
+
primary_key_names.include?(column.name)
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
116
|
-
@
|
|
117
|
-
connection.
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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}
|
|
23
|
+
puts "Annotated #{results[:annotated].length} #{'model'.pluralize(results[:annotated].length)}"
|
|
20
24
|
end
|
|
21
|
-
puts "Skipped #{results[:skipped].length}
|
|
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}
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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}
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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}
|
|
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
|
data/lib/rails_lens/version.rb
CHANGED
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
276
|
+
rubygems_version: 4.0.10
|
|
277
277
|
specification_version: 4
|
|
278
278
|
summary: Comprehensive Rails application visualization and annotation
|
|
279
279
|
test_files: []
|