lagoon 0.1.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.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string"
4
+
5
+ module Lagoon
6
+ module Parser
7
+ class ModelParser
8
+ attr_reader :options, :config
9
+
10
+ def initialize(options = {})
11
+ @options = options
12
+ @config = Lagoon.configuration
13
+ end
14
+
15
+ def parse
16
+ models = load_models
17
+ classes = []
18
+ relationships = []
19
+
20
+ models.each do |model|
21
+ next if excluded?(model)
22
+
23
+ classes << parse_model(model)
24
+ relationships.concat(extract_associations(model))
25
+ relationships.concat(extract_inheritance(model)) if config.include_inheritance
26
+ end
27
+
28
+ {
29
+ classes: classes,
30
+ relationships: relationships
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def load_models
37
+ # Railsアプリケーションの全モデルをロード
38
+ return [] unless defined?(Rails)
39
+
40
+ Rails.application.eager_load!
41
+ ActiveRecord::Base.descendants.reject(&:abstract_class?)
42
+ end
43
+
44
+ def excluded?(model)
45
+ model_name = model.name
46
+ config.exclude_models.include?(model_name)
47
+ end
48
+
49
+ def parse_model(model)
50
+ {
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) : []
55
+ }
56
+ end
57
+
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)
81
+ end
82
+
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)
95
+ end
96
+
97
+ associations.compact
98
+ end
99
+
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
+ }
140
+ 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
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string"
4
+
5
+ module Lagoon
6
+ module Parser
7
+ class SchemaParser
8
+ attr_reader :options, :config
9
+
10
+ def initialize(options = {})
11
+ @options = options
12
+ @config = Lagoon.configuration
13
+ end
14
+
15
+ def parse
16
+ tables = load_schema
17
+ entities = []
18
+ relationships = []
19
+
20
+ tables.each do |table_name, columns|
21
+ next if excluded?(table_name)
22
+
23
+ entity = parse_table(table_name, columns)
24
+ entities << entity
25
+ relationships.concat(extract_foreign_keys(table_name, columns))
26
+ end
27
+
28
+ {
29
+ entities: entities,
30
+ relationships: relationships
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def load_schema
37
+ return {} unless defined?(ActiveRecord)
38
+
39
+ connection = ActiveRecord::Base.connection
40
+ tables_hash = {}
41
+
42
+ connection.tables.each do |table_name|
43
+ next if internal_table?(table_name)
44
+
45
+ columns = connection.columns(table_name)
46
+ tables_hash[table_name] = columns
47
+ end
48
+
49
+ tables_hash
50
+ end
51
+
52
+ def internal_table?(table_name)
53
+ # Railsの内部テーブルをスキップ
54
+ %w[schema_migrations ar_internal_metadata].include?(table_name)
55
+ end
56
+
57
+ def excluded?(_table_name)
58
+ false # 必要に応じて除外ロジックを追加
59
+ end
60
+
61
+ def parse_table(table_name, columns)
62
+ {
63
+ name: table_name,
64
+ attributes: columns.map { |col| parse_column(col) }
65
+ }
66
+ end
67
+
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
+ }
76
+ end
77
+
78
+ def foreign_key?(column_name)
79
+ column_name.end_with?("_id")
80
+ end
81
+
82
+ def extract_foreign_keys(table_name, columns)
83
+ relationships = []
84
+
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
101
+
102
+ relationships
103
+ end
104
+
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
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :lagoon
6
+
7
+ rake_tasks do
8
+ load "tasks/lagoon.rake"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ module Renderer
5
+ class BaseRenderer
6
+ attr_reader :direction
7
+
8
+ def initialize(direction: "TB")
9
+ @direction = direction
10
+ end
11
+
12
+ def render(parsed_data)
13
+ raise NotImplementedError, "Subclasses must implement #render"
14
+ end
15
+
16
+ protected
17
+
18
+ def escape_class_name(name)
19
+ # Mermaidで特殊文字を含むクラス名をエスケープ
20
+ if name.match?(/[^a-zA-Z0-9_]/)
21
+ "`#{name}`"
22
+ else
23
+ name
24
+ end
25
+ end
26
+
27
+ def type_to_mermaid(type)
28
+ # Ruby/Rails型をMermaid表記に変換
29
+ 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"
40
+ else type.to_s.capitalize
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ module Renderer
5
+ class ClassDiagramRenderer < BaseRenderer
6
+ def render(parsed_data)
7
+ output = ["classDiagram"]
8
+ output << " direction #{@direction}"
9
+ output << ""
10
+
11
+ # クラス定義を追加
12
+ parsed_data[:classes].each do |klass|
13
+ output << render_class(klass)
14
+ end
15
+
16
+ output << ""
17
+
18
+ # 関係性を追加
19
+ parsed_data[:relationships].each do |rel|
20
+ output << render_relationship(rel)
21
+ end
22
+
23
+ output.join("\n")
24
+ end
25
+
26
+ private
27
+
28
+ def render_class(klass)
29
+ lines = []
30
+ class_name = escape_class_name(klass[:name])
31
+
32
+ lines << " class #{class_name} {"
33
+ lines << " <<abstract>>" if klass[:abstract]
34
+
35
+ # 属性を追加
36
+ if klass[:attributes]&.any?
37
+ klass[:attributes].each do |attr|
38
+ visibility = attr[:visibility] || "+"
39
+ type = type_to_mermaid(attr[:type])
40
+ lines << " #{visibility}#{type} #{attr[:name]}"
41
+ end
42
+ end
43
+
44
+ # メソッドを追加
45
+ if klass[:methods]&.any?
46
+ klass[:methods].each do |method|
47
+ visibility = method[:visibility] || "+"
48
+ return_type = method[:return_type] ? " #{type_to_mermaid(method[:return_type])}" : ""
49
+ lines << " #{visibility}#{method[:name]}()#{return_type}"
50
+ end
51
+ end
52
+
53
+ lines << " }"
54
+ lines << ""
55
+ lines.join("\n")
56
+ end
57
+
58
+ def render_relationship(rel)
59
+ source = escape_class_name(rel[:source])
60
+ target = escape_class_name(rel[:target])
61
+ type = rel[:type]
62
+ label = rel[:label]
63
+ source_cardinality = rel[:source_cardinality]
64
+ target_cardinality = rel[:target_cardinality]
65
+
66
+ arrow = case type
67
+ when :inheritance
68
+ "<|--"
69
+ when :composition
70
+ "*--"
71
+ when :aggregation
72
+ "o--"
73
+ when :association
74
+ "-->"
75
+ when :dependency
76
+ "..>"
77
+ else
78
+ "--"
79
+ end
80
+
81
+ line = " #{source}"
82
+ line += " \"#{source_cardinality}\"" if source_cardinality
83
+ line += " #{arrow} "
84
+ line += "\"#{target_cardinality}\" " if target_cardinality
85
+ line += target
86
+ line += " : #{label}" if label
87
+ line
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ module Renderer
5
+ class ErDiagramRenderer < BaseRenderer
6
+ def render(parsed_data)
7
+ output = ["erDiagram"]
8
+
9
+ # リレーションシップを先に追加
10
+ if parsed_data[:relationships]&.any?
11
+ parsed_data[:relationships].each do |rel|
12
+ output << render_relationship(rel)
13
+ end
14
+ output << ""
15
+ end
16
+
17
+ # エンティティ定義を追加
18
+ parsed_data[:entities].each do |entity|
19
+ output << render_entity(entity)
20
+ end
21
+
22
+ output.join("\n")
23
+ end
24
+
25
+ private
26
+
27
+ def render_entity(entity)
28
+ lines = []
29
+ entity_name = entity[:name].upcase
30
+
31
+ lines << " #{entity_name} {"
32
+ entity[:attributes].each do |attr|
33
+ type = type_to_er_type(attr[:type])
34
+ constraints = []
35
+ constraints << "PK" if attr[:primary_key]
36
+ constraints << "FK" if attr[:foreign_key]
37
+ constraints << "UK" if attr[:unique]
38
+
39
+ line = " #{type} #{attr[:name]}"
40
+ line += " #{constraints.join(" ")}" if constraints.any?
41
+ lines << line
42
+ end
43
+ lines << " }"
44
+ lines << ""
45
+
46
+ lines.join("\n")
47
+ end
48
+
49
+ def render_relationship(rel)
50
+ source = rel[:source].upcase
51
+ target = rel[:target].upcase
52
+ label = rel[:label]
53
+
54
+ # カーディナリティを決定
55
+ source_card = cardinality_symbol(rel[:source_cardinality])
56
+ target_card = cardinality_symbol(rel[:target_cardinality])
57
+
58
+ # 関係の種類(識別 or 非識別)
59
+ line_type = rel[:identifying] ? "--" : ".."
60
+
61
+ " #{source} #{source_card}#{line_type}#{target_card} #{target} : \"#{label}\""
62
+ end
63
+
64
+ def cardinality_symbol(cardinality)
65
+ case cardinality
66
+ when "1", "one"
67
+ "||"
68
+ when "0..1", "zero_or_one"
69
+ "|o"
70
+ when "1..*", "one_or_more"
71
+ "}|"
72
+ when "*", "0..*", "zero_or_more", "many"
73
+ "}o"
74
+ else
75
+ "||" # デフォルト
76
+ end
77
+ end
78
+
79
+ def type_to_er_type(type)
80
+ # Ruby/Rails型をERダイアグラム用の型に変換
81
+ case type.to_s.downcase
82
+ when /integer/, /bigint/
83
+ "int"
84
+ when /string/, /varchar/
85
+ "string"
86
+ when /text/
87
+ "text"
88
+ when /boolean/
89
+ "boolean"
90
+ when /datetime/, /timestamp/
91
+ "datetime"
92
+ when /date/
93
+ "date"
94
+ when /time/
95
+ "time"
96
+ when /decimal/, /numeric/
97
+ "decimal"
98
+ when /float/, /double/
99
+ "float"
100
+ when /json/, /jsonb/
101
+ "json"
102
+ else
103
+ type.to_s.downcase
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ VERSION = "0.1.0"
5
+ end
data/lib/lagoon.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lagoon/version"
4
+ require_relative "lagoon/configuration"
5
+ require_relative "lagoon/diagram/base"
6
+ require_relative "lagoon/diagram/model_diagram"
7
+ require_relative "lagoon/diagram/controller_diagram"
8
+ require_relative "lagoon/diagram/er_diagram"
9
+ require_relative "lagoon/parser/model_parser"
10
+ require_relative "lagoon/parser/controller_parser"
11
+ require_relative "lagoon/parser/schema_parser"
12
+ require_relative "lagoon/renderer/base_renderer"
13
+ require_relative "lagoon/renderer/class_diagram_renderer"
14
+ require_relative "lagoon/renderer/er_diagram_renderer"
15
+ require_relative "lagoon/railtie" if defined?(Rails::Railtie)
16
+
17
+ module Lagoon
18
+ class Error < StandardError; end
19
+
20
+ class << self
21
+ attr_writer :configuration
22
+
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield(configuration)
29
+ end
30
+
31
+ def generate_model_diagram(options = {})
32
+ Diagram::ModelDiagram.new(options).generate
33
+ end
34
+
35
+ def generate_controller_diagram(options = {})
36
+ Diagram::ControllerDiagram.new(options).generate
37
+ end
38
+
39
+ def generate_er_diagram(options = {})
40
+ Diagram::ErDiagram.new(options).generate
41
+ end
42
+
43
+ def generate_all(options = {})
44
+ {
45
+ models: generate_model_diagram(options),
46
+ controllers: generate_controller_diagram(options),
47
+ er: generate_er_diagram(options)
48
+ }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :mermaid do
4
+ desc "Generate all Mermaid diagrams"
5
+ task all: :environment do
6
+ results = Lagoon.generate_all
7
+ puts "All diagrams generated:"
8
+ puts " Models: #{results[:models]}"
9
+ puts " Controllers: #{results[:controllers]}"
10
+ puts " ER: #{results[:er]}"
11
+ end
12
+
13
+ desc "Generate Mermaid model diagram"
14
+ task models: :environment do
15
+ output_file = Lagoon.generate_model_diagram
16
+ puts "Model diagram generated: #{output_file}"
17
+ end
18
+
19
+ desc "Generate Mermaid controller diagram"
20
+ task controllers: :environment do
21
+ output_file = Lagoon.generate_controller_diagram
22
+ puts "Controller diagram generated: #{output_file}"
23
+ end
24
+
25
+ desc "Generate Mermaid ER diagram"
26
+ task er: :environment do
27
+ output_file = Lagoon.generate_er_diagram
28
+ puts "ER diagram generated: #{output_file}"
29
+ end
30
+
31
+ desc "Generate brief diagrams (no attributes/methods)"
32
+ task brief: :environment do
33
+ results = Lagoon.generate_all(brief: true)
34
+ puts "Brief diagrams generated:"
35
+ puts " Models: #{results[:models]}"
36
+ puts " Controllers: #{results[:controllers]}"
37
+ puts " ER: #{results[:er]}"
38
+ end
39
+ end