lagoon 0.1.0 → 0.2.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/Appraisals +39 -0
  3. data/CHANGELOG.md +14 -2
  4. data/README.md +111 -146
  5. data/Rakefile +17 -3
  6. data/exe/lagoon +3 -3
  7. data/gemfiles/adapters.gemfile +15 -0
  8. data/gemfiles/rails_6.1.gemfile +23 -0
  9. data/gemfiles/rails_7.0.gemfile +23 -0
  10. data/gemfiles/rails_7.1.gemfile +23 -0
  11. data/gemfiles/rails_7.2.gemfile +23 -0
  12. data/gemfiles/rails_8.0.gemfile +23 -0
  13. data/gemfiles/rails_8.1.gemfile +23 -0
  14. data/lib/lagoon/analyzer/action_controller_analyzer.rb +76 -0
  15. data/lib/lagoon/analyzer/active_record_analyzer.rb +202 -0
  16. data/lib/lagoon/analyzer/ast/controller_scope_collector.rb +104 -0
  17. data/lib/lagoon/analyzer/ast/method_reference_visitor.rb +170 -0
  18. data/lib/lagoon/analyzer/ast_model_reference_analyzer.rb +44 -0
  19. data/lib/lagoon/analyzer/database_schema_analyzer.rb +83 -0
  20. data/lib/lagoon/cli.rb +109 -80
  21. data/lib/lagoon/configuration.rb +42 -6
  22. data/lib/lagoon/diagram/base.rb +48 -8
  23. data/lib/lagoon/diagram/controller_diagram.rb +10 -16
  24. data/lib/lagoon/diagram/controller_model_diagram.rb +28 -0
  25. data/lib/lagoon/diagram/er_diagram.rb +10 -16
  26. data/lib/lagoon/diagram/model_diagram.rb +13 -16
  27. data/lib/lagoon/errors.rb +9 -0
  28. data/lib/lagoon/options.rb +182 -0
  29. data/lib/lagoon/parser/application_class_filter.rb +47 -0
  30. data/lib/lagoon/parser/controller_model_parser.rb +196 -0
  31. data/lib/lagoon/parser/controller_parser.rb +37 -54
  32. data/lib/lagoon/parser/model_parser.rb +57 -112
  33. data/lib/lagoon/parser/schema_parser.rb +120 -67
  34. data/lib/lagoon/railtie.rb +1 -1
  35. data/lib/lagoon/renderer/base_renderer.rb +49 -19
  36. data/lib/lagoon/renderer/class_diagram_renderer.rb +37 -31
  37. data/lib/lagoon/renderer/controller_model_er_renderer.rb +47 -0
  38. data/lib/lagoon/renderer/er_diagram_renderer.rb +43 -42
  39. data/lib/lagoon/result.rb +22 -0
  40. data/lib/lagoon/version.rb +1 -1
  41. data/lib/lagoon.rb +70 -21
  42. data/lib/tasks/lagoon.rake +15 -7
  43. data/sig/lagoon.rbs +107 -0
  44. metadata +57 -7
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/string"
3
+ require 'logger'
4
+ require 'active_support'
5
+ require 'active_support/core_ext/string/inflections'
4
6
 
5
7
  module Lagoon
6
8
  module Parser
@@ -8,8 +10,9 @@ module Lagoon
8
10
  attr_reader :options, :config
9
11
 
10
12
  def initialize(options = {})
11
- @options = options
12
- @config = Lagoon.configuration
13
+ @options = options.is_a?(Options) ? options : Options.for(:model, options)
14
+ @analyzer = Lagoon::Analyzer::ActiveRecordAnalyzer.new
15
+ @filter = ApplicationClassFilter.new(directory: 'models', include_all: @options[:all_models])
13
16
  end
14
17
 
15
18
  def parse
@@ -17,144 +20,86 @@ module Lagoon
17
20
  classes = []
18
21
  relationships = []
19
22
 
20
- models.each do |model|
23
+ warnings = []
24
+
25
+ models.sort_by { |model| model.name.to_s }.each do |model|
21
26
  next if excluded?(model)
22
27
 
23
- classes << parse_model(model)
24
- relationships.concat(extract_associations(model))
25
- relationships.concat(extract_inheritance(model)) if config.include_inheritance
28
+ analyze_model(model, classes, relationships)
29
+ rescue StandardError => e
30
+ raise if options[:strict]
31
+
32
+ warnings << "Failed to analyze model #{model.name || '(anonymous)'}: #{e.message}"
26
33
  end
27
34
 
35
+ normalized_relationships = deduplicate_relationships(relationships)
28
36
  {
29
- classes: classes,
30
- relationships: relationships
37
+ classes: classes.sort_by { |model| model[:name] },
38
+ relationships: normalized_relationships,
39
+ warnings: warnings,
40
+ counts: { classes: classes.size, relationships: normalized_relationships.size, skipped: warnings.size }
31
41
  }
32
42
  end
33
43
 
34
44
  private
35
45
 
36
46
  def load_models
37
- # Railsアプリケーションの全モデルをロード
38
- return [] unless defined?(Rails)
47
+ return [] unless defined?(ActiveRecord::Base)
39
48
 
40
- Rails.application.eager_load!
41
- ActiveRecord::Base.descendants.reject(&:abstract_class?)
49
+ Rails.application.eager_load! if options[:eager_load] && defined?(Rails)
50
+ ActiveRecord::Base.descendants
42
51
  end
43
52
 
44
53
  def excluded?(model)
45
54
  model_name = model.name
46
- config.exclude_models.include?(model_name)
55
+ return true unless model_name
56
+ return true unless @filter.include?(model)
57
+ return true if options[:exclude].include?(model_name)
58
+ return !options[:specify].include?(model_name) if options[:specify].any?
59
+
60
+ false
47
61
  end
48
62
 
49
- def parse_model(model)
63
+ def analysis_options
50
64
  {
51
- name: model.name,
52
- abstract: model.abstract_class?,
53
- attributes: config.show_attributes ? extract_columns(model) : [],
54
- methods: config.show_methods ? extract_methods(model) : []
65
+ show_attributes: options[:show_attributes],
66
+ show_methods: options[:show_methods],
67
+ all_columns: options[:all_columns],
68
+ hide_magic: options[:hide_magic],
69
+ hide_through: options[:hide_through],
70
+ show_belongs_to: options[:show_belongs_to],
71
+ duplicate_sti_attributes: options[:duplicate_sti_attributes]
55
72
  }
56
73
  end
57
74
 
58
- def extract_columns(model)
59
- return [] unless model.table_exists?
60
-
61
- columns = if options[:all_columns]
62
- model.columns
63
- else
64
- model.columns.reject { |col| magic_field?(col.name) }
65
- end
66
-
67
- columns.map do |column|
68
- {
69
- name: column.name,
70
- type: column.type,
71
- visibility: "+"
72
- }
73
- end
74
- end
75
-
76
- def magic_field?(field_name)
77
- # マジックフィールド(created_at, updated_at等)を判定
78
- return false if options[:all_columns]
79
-
80
- %w[id created_at updated_at].include?(field_name)
75
+ def analyze_model(model, classes, relationships)
76
+ classes << @analyzer.analyze_model(model, analysis_options)
77
+ relationships.concat(@analyzer.extract_associations(model, analysis_options))
78
+ return unless options[:include_inheritance]
79
+
80
+ relationships.concat(
81
+ @analyzer.extract_inheritance_with_options(
82
+ model,
83
+ include_framework_base: options[:include_framework_bases]
84
+ )
85
+ )
81
86
  end
82
87
 
83
- def extract_methods(_model)
84
- # publicメソッドを抽出(必要に応じて実装)
85
- []
86
- end
87
-
88
- def extract_associations(model)
89
- associations = []
90
-
91
- model.reflect_on_all_associations.each do |assoc|
92
- next if assoc.options[:through] && options[:hide_through]
93
-
94
- associations << build_association(model, assoc)
88
+ def deduplicate_relationships(relationships)
89
+ grouped = relationships.group_by { |relationship| relationship_key(relationship) }
90
+ deduplicated = grouped.values.map do |duplicates|
91
+ duplicates.min_by { |relationship| relationship[:macro] == :belongs_to ? 1 : 0 }
95
92
  end
96
93
 
97
- associations.compact
94
+ deduplicated.sort_by { |relationship| relationship_key(relationship) }
98
95
  end
99
96
 
100
- def build_association(model, assoc)
101
- case assoc.macro
102
- when :belongs_to
103
- return nil unless options[:show_belongs_to]
104
-
105
- {
106
- source: model.name,
107
- target: assoc.class_name,
108
- type: :association,
109
- label: "belongs_to #{assoc.name}",
110
- source_cardinality: "1",
111
- target_cardinality: "0..1"
112
- }
113
- when :has_one
114
- {
115
- source: model.name,
116
- target: assoc.class_name,
117
- type: :association,
118
- label: "has_one #{assoc.name}",
119
- source_cardinality: "1",
120
- target_cardinality: "0..1"
121
- }
122
- when :has_many
123
- {
124
- source: model.name,
125
- target: assoc.class_name,
126
- type: :association,
127
- label: "has_many #{assoc.name}",
128
- source_cardinality: "1",
129
- target_cardinality: "*"
130
- }
131
- when :has_and_belongs_to_many
132
- {
133
- source: model.name,
134
- target: assoc.class_name,
135
- type: :association,
136
- label: "has_and_belongs_to_many #{assoc.name}",
137
- source_cardinality: "*",
138
- target_cardinality: "*"
139
- }
97
+ def relationship_key(relationship)
98
+ if relationship[:type] == :association && !relationship[:polymorphic]
99
+ [relationship[:type].to_s, *[relationship[:source], relationship[:target]].sort]
100
+ else
101
+ [relationship[:type].to_s, relationship[:source].to_s, relationship[:target].to_s]
140
102
  end
141
- rescue NameError
142
- # アソシエーション先のクラスが見つからない場合はスキップ
143
- nil
144
- end
145
-
146
- def extract_inheritance(model)
147
- return [] if model.superclass == ActiveRecord::Base
148
- return [] if model.superclass.abstract_class?
149
-
150
- [{
151
- source: model.superclass.name,
152
- target: model.name,
153
- type: :inheritance,
154
- label: nil
155
- }]
156
- rescue StandardError
157
- []
158
103
  end
159
104
  end
160
105
  end
@@ -1,113 +1,166 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/string"
4
-
5
3
  module Lagoon
6
4
  module Parser
7
5
  class SchemaParser
8
- attr_reader :options, :config
6
+ ConnectionEntry = Data.define(:name, :connection)
7
+ TableMetadata = Data.define(:name, :raw_name, :prefix, :columns, :primary_keys, :foreign_keys, :indexes)
8
+
9
+ attr_reader :options
9
10
 
10
11
  def initialize(options = {})
11
- @options = options
12
- @config = Lagoon.configuration
12
+ @options = options.is_a?(Options) ? options : Options.for(:er, options)
13
+ @analyzer = Lagoon::Analyzer::DatabaseSchemaAnalyzer.new
13
14
  end
14
15
 
15
16
  def parse
16
- tables = load_schema
17
17
  entities = []
18
18
  relationships = []
19
+ warnings = []
20
+
21
+ load_schema(warnings).each do |table|
22
+ next if excluded?(table)
19
23
 
20
- tables.each do |table_name, columns|
21
- next if excluded?(table_name)
24
+ analyze_table(table, entities, relationships)
25
+ rescue StandardError => e
26
+ raise if options[:strict]
22
27
 
23
- entity = parse_table(table_name, columns)
24
- entities << entity
25
- relationships.concat(extract_foreign_keys(table_name, columns))
28
+ warnings << "Failed to analyze table #{table.name}: #{e.message}"
26
29
  end
27
30
 
28
31
  {
29
- entities: entities,
30
- relationships: relationships
32
+ entities: entities.sort_by { |entity| entity[:name] },
33
+ relationships: relationships.sort_by { |relationship| [relationship[:source], relationship[:target]] },
34
+ warnings: warnings,
35
+ counts: { entities: entities.size, relationships: relationships.size, skipped: warnings.size }
31
36
  }
32
37
  end
33
38
 
34
39
  private
35
40
 
36
- def load_schema
37
- return {} unless defined?(ActiveRecord)
41
+ def load_schema(warnings)
42
+ entries = connection_entries
43
+ multiple_connections = entries.size > 1
38
44
 
39
- connection = ActiveRecord::Base.connection
40
- tables_hash = {}
45
+ entries.flat_map do |entry|
46
+ load_connection_schema(entry, multiple_connections, warnings)
47
+ end
48
+ end
41
49
 
42
- connection.tables.each do |table_name|
43
- next if internal_table?(table_name)
50
+ def connection_entries
51
+ configured = options[:connections]
52
+ return normalize_configured_connections(configured) if configured
53
+ return [] unless defined?(ActiveRecord::Base)
44
54
 
45
- columns = connection.columns(table_name)
46
- tables_hash[table_name] = columns
55
+ pools = all_connection_pools
56
+ connections = pools.filter_map do |pool|
57
+ ConnectionEntry.new(name: connection_name(pool), connection: pool.connection)
58
+ rescue ActiveRecord::ConnectionNotEstablished
59
+ nil
47
60
  end
61
+ base = ConnectionEntry.new(name: 'primary', connection: ActiveRecord::Base.connection)
62
+ connections.unshift(base)
63
+ connections.uniq { |entry| entry.connection.object_id }
64
+ rescue NoMethodError
65
+ [ConnectionEntry.new(name: 'primary', connection: ActiveRecord::Base.connection)]
66
+ end
48
67
 
49
- tables_hash
68
+ def all_connection_pools
69
+ handler = ActiveRecord::Base.connection_handler
70
+ return handler.all_connection_pools if handler.respond_to?(:all_connection_pools)
71
+
72
+ handler.connection_pool_list
50
73
  end
51
74
 
52
- def internal_table?(table_name)
53
- # Railsの内部テーブルをスキップ
54
- %w[schema_migrations ar_internal_metadata].include?(table_name)
75
+ def normalize_configured_connections(configured)
76
+ pairs = configured.respond_to?(:to_h) ? configured.to_h : Array(configured).to_h
77
+ pairs.map { |name, connection| ConnectionEntry.new(name: name.to_s, connection: connection) }
78
+ rescue TypeError
79
+ Array(configured).each_with_index.map do |connection, index|
80
+ ConnectionEntry.new(name: "database_#{index + 1}", connection: connection)
81
+ end
82
+ end
83
+
84
+ def load_connection_schema(entry, multiple_connections, warnings)
85
+ connection = entry.connection
86
+ prefix = multiple_connections ? entry.name : nil
87
+
88
+ connection.tables.sort.filter_map do |table_name|
89
+ next if @analyzer.internal_table?(table_name, internal_tables: options[:internal_tables])
90
+
91
+ load_table_metadata(connection, table_name, prefix)
92
+ rescue StandardError => e
93
+ raise if options[:strict]
94
+
95
+ warnings << "Failed to inspect table #{qualify(table_name, prefix)}: #{e.message}"
96
+ nil
97
+ end
55
98
  end
56
99
 
57
- def excluded?(_table_name)
58
- false # 必要に応じて除外ロジックを追加
100
+ def load_table_metadata(connection, table_name, prefix)
101
+ TableMetadata.new(
102
+ name: qualify(table_name, prefix),
103
+ raw_name: table_name,
104
+ prefix: prefix,
105
+ columns: cached_columns(connection, table_name),
106
+ primary_keys: primary_keys(connection, table_name),
107
+ foreign_keys: connection.foreign_keys(table_name),
108
+ indexes: connection.indexes(table_name)
109
+ )
59
110
  end
60
111
 
61
- def parse_table(table_name, columns)
62
- {
63
- name: table_name,
64
- attributes: columns.map { |col| parse_column(col) }
65
- }
112
+ def cached_columns(connection, table_name)
113
+ cache = connection.schema_cache
114
+ cache.columns(table_name)
115
+ rescue ArgumentError
116
+ cache.columns(connection.pool, table_name)
117
+ rescue NoMethodError
118
+ connection.columns(table_name)
66
119
  end
67
120
 
68
- def parse_column(column)
69
- {
70
- name: column.name,
71
- type: column.type,
72
- primary_key: column.name == "id",
73
- foreign_key: foreign_key?(column.name),
74
- unique: false # 必要に応じて実装
75
- }
121
+ def primary_keys(connection, table_name)
122
+ return Array(connection.primary_keys(table_name)) if connection.respond_to?(:primary_keys)
123
+
124
+ Array(connection.primary_key(table_name))
76
125
  end
77
126
 
78
- def foreign_key?(column_name)
79
- column_name.end_with?("_id")
127
+ def analyze_table(table, entities, relationships)
128
+ entities << @analyzer.analyze_table(
129
+ table.name,
130
+ table.columns,
131
+ primary_keys: table.primary_keys,
132
+ foreign_keys: table.foreign_keys,
133
+ indexes: table.indexes
134
+ )
135
+ relationships.concat(
136
+ @analyzer.extract_foreign_keys(
137
+ table.name,
138
+ table.columns,
139
+ foreign_keys: table.foreign_keys,
140
+ indexes: table.indexes,
141
+ primary_keys: table.primary_keys,
142
+ table_prefix: table.prefix
143
+ )
144
+ )
80
145
  end
81
146
 
82
- def extract_foreign_keys(table_name, columns)
83
- relationships = []
147
+ def excluded?(table)
148
+ return true if options[:exclude].include?(table.name) || options[:exclude].include?(table.raw_name)
149
+ return false if options[:specify].empty?
84
150
 
85
- columns.each do |column|
86
- next unless foreign_key?(column.name)
87
-
88
- # column_nameから参照先テーブルを推測 (例: user_id -> users)
89
- target_table = infer_target_table(column.name)
90
- next unless target_table
91
-
92
- relationships << {
93
- source: table_name,
94
- target: target_table,
95
- label: "has many",
96
- source_cardinality: "||",
97
- target_cardinality: "}o",
98
- identifying: true
99
- }
100
- end
151
+ !options[:specify].include?(table.name) && !options[:specify].include?(table.raw_name)
152
+ end
101
153
 
102
- relationships
154
+ def qualify(table_name, prefix)
155
+ prefix ? "#{prefix}.#{table_name}" : table_name.to_s
103
156
  end
104
157
 
105
- def infer_target_table(foreign_key_name)
106
- # user_id -> users のように推測
107
- base_name = foreign_key_name.sub(/_id$/, "")
108
- base_name.pluralize
109
- rescue StandardError
110
- nil
158
+ def connection_name(pool)
159
+ config = pool.respond_to?(:db_config) ? pool.db_config : nil
160
+ parts = [config&.name]
161
+ parts << pool.role if pool.respond_to?(:role)
162
+ parts << pool.shard if pool.respond_to?(:shard)
163
+ parts.compact.map(&:to_s).reject(&:empty?).join('_').then { |name| name.empty? ? 'database' : name }
111
164
  end
112
165
  end
113
166
  end
@@ -5,7 +5,7 @@ module Lagoon
5
5
  railtie_name :lagoon
6
6
 
7
7
  rake_tasks do
8
- load "tasks/lagoon.rake"
8
+ load 'tasks/lagoon.rake'
9
9
  end
10
10
  end
11
11
  end
@@ -1,42 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+ require 'json'
5
+
3
6
  module Lagoon
4
7
  module Renderer
5
8
  class BaseRenderer
6
9
  attr_reader :direction
7
10
 
8
- def initialize(direction: "TB")
9
- @direction = direction
11
+ def initialize(direction: 'TB', **_options)
12
+ @direction = Configuration.validate_direction!(direction)
10
13
  end
11
14
 
12
15
  def render(parsed_data)
13
- raise NotImplementedError, "Subclasses must implement #render"
16
+ raise NotImplementedError, 'Subclasses must implement #render'
14
17
  end
15
18
 
16
19
  protected
17
20
 
18
21
  def escape_class_name(name)
19
- # Mermaidで特殊文字を含むクラス名をエスケープ
20
- if name.match?(/[^a-zA-Z0-9_]/)
21
- "`#{name}`"
22
- else
23
- name
24
- end
22
+ safe_identifier(name)
23
+ end
24
+
25
+ def safe_identifier(value, uppercase: false)
26
+ original = value.to_s
27
+ identifier = original.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_')
28
+ identifier = "_#{identifier}" unless identifier.match?(/\A[a-zA-Z_]/)
29
+ identifier = "entity_#{Digest::SHA256.hexdigest(original)[0, 12]}" if identifier.empty? || identifier == '_'
30
+ uppercase ? identifier.upcase : identifier
31
+ end
32
+
33
+ def aliased_identifier(value, uppercase: false)
34
+ identifier = safe_identifier(value, uppercase: uppercase)
35
+ display = uppercase ? value.to_s.upcase : value.to_s
36
+ return identifier if identifier == display
37
+
38
+ "#{identifier}[#{mermaid_string(display)}]"
39
+ end
40
+
41
+ def mermaid_string(value)
42
+ JSON.generate(value.to_s.gsub(/[\r\n]+/, ' '))
43
+ end
44
+
45
+ def escape_member(value)
46
+ value.to_s.gsub(/[\r\n]+/, ' ')
47
+ .gsub('&', '&amp;')
48
+ .gsub('"', '&quot;')
49
+ .gsub('{', '&#123;')
50
+ .gsub('}', '&#125;')
51
+ end
52
+
53
+ def escape_label(value)
54
+ escape_member(value).gsub(':', '&#58;')
25
55
  end
26
56
 
27
57
  def type_to_mermaid(type)
28
58
  # Ruby/Rails型をMermaid表記に変換
29
59
  case type.to_s
30
- when /integer/i then "Integer"
31
- when /string/i then "String"
32
- when /text/i then "Text"
33
- when /boolean/i then "Boolean"
34
- when /datetime/i then "DateTime"
35
- when /date/i then "Date"
36
- when /time/i then "Time"
37
- when /decimal/i then "Decimal"
38
- when /float/i then "Float"
39
- when /json/i then "JSON"
60
+ when /integer/i then 'Integer'
61
+ when /string/i then 'String'
62
+ when /text/i then 'Text'
63
+ when /boolean/i then 'Boolean'
64
+ when /datetime/i then 'DateTime'
65
+ when /date/i then 'Date'
66
+ when /time/i then 'Time'
67
+ when /decimal/i then 'Decimal'
68
+ when /float/i then 'Float'
69
+ when /json/i then 'JSON'
40
70
  else type.to_s.capitalize
41
71
  end
42
72
  end