dbwatcher 1.0.0 → 1.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.
- checksums.yaml +4 -4
- data/README.md +81 -210
- data/app/assets/config/dbwatcher_manifest.js +15 -0
- data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
- data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
- data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
- data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
- data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
- data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
- data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
- data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
- data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
- data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
- data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
- data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
- data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
- data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
- data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
- data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
- data/app/assets/stylesheets/dbwatcher/application.css +423 -0
- data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
- data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
- data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
- data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
- data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
- data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
- data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
- data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
- data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
- data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
- data/app/controllers/dbwatcher/base_controller.rb +8 -2
- data/app/controllers/dbwatcher/dashboard_controller.rb +8 -0
- data/app/controllers/dbwatcher/sessions_controller.rb +25 -10
- data/app/helpers/dbwatcher/component_helper.rb +29 -0
- data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
- data/app/helpers/dbwatcher/session_helper.rb +3 -2
- data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
- data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
- data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
- data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
- data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
- data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
- data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
- data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
- data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
- data/app/views/dbwatcher/sessions/index.html.erb +14 -10
- data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
- data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
- data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
- data/app/views/dbwatcher/sessions/show.html.erb +3 -346
- data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
- data/app/views/layouts/dbwatcher/application.html.erb +125 -247
- data/bin/compile_scss +49 -0
- data/config/routes.rb +26 -0
- data/lib/dbwatcher/configuration.rb +102 -8
- data/lib/dbwatcher/engine.rb +17 -7
- data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
- data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
- data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
- data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
- data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
- data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
- data/lib/dbwatcher/services/base_service.rb +64 -0
- data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
- data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
- data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
- data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +564 -0
- data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
- data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -0
- data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
- data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
- data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
- data/lib/dbwatcher/services/diagram_data.rb +65 -0
- data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
- data/lib/dbwatcher/services/diagram_generator.rb +154 -0
- data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
- data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
- data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
- data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
- data/lib/dbwatcher/services/diagram_system.rb +69 -0
- data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
- data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
- data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
- data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +136 -0
- data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -0
- data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
- data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
- data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +102 -0
- data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
- data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +15 -128
- data/lib/dbwatcher/storage/api/session_api.rb +47 -0
- data/lib/dbwatcher/storage/base_storage.rb +7 -0
- data/lib/dbwatcher/version.rb +1 -1
- data/lib/dbwatcher.rb +58 -1
- metadata +94 -2
@@ -0,0 +1,564 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbwatcher
|
4
|
+
module Services
|
5
|
+
module DiagramAnalyzers
|
6
|
+
# Analyzes relationships based on ActiveRecord model associations
|
7
|
+
#
|
8
|
+
# This service examines ActiveRecord models to detect associations between
|
9
|
+
# models that were involved in a session.
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# analyzer = ModelAssociationAnalyzer.new(session)
|
13
|
+
# dataset = analyzer.call
|
14
|
+
class ModelAssociationAnalyzer < BaseAnalyzer
|
15
|
+
# Initialize with session
|
16
|
+
#
|
17
|
+
# @param session [Session] session to analyze (optional for global analysis)
|
18
|
+
def initialize(session = nil)
|
19
|
+
@session = session
|
20
|
+
@session_tables = session ? extract_session_tables : []
|
21
|
+
@models = discover_session_models
|
22
|
+
super()
|
23
|
+
end
|
24
|
+
|
25
|
+
# Analyze model associations
|
26
|
+
#
|
27
|
+
# @param context [Hash] analysis context
|
28
|
+
# @return [Array<Hash>] array of association data
|
29
|
+
def analyze(_context)
|
30
|
+
return [] unless models_available?
|
31
|
+
|
32
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Starting analysis with #{models.length} models"
|
33
|
+
associations = extract_model_associations
|
34
|
+
|
35
|
+
# Add placeholder nodes for models without associations
|
36
|
+
associations = generate_placeholder_associations if associations.empty? && models.any?
|
37
|
+
|
38
|
+
# Log some sample data to help with debugging
|
39
|
+
if associations.any?
|
40
|
+
sample_association = associations.first
|
41
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Sample association - " \
|
42
|
+
"source_model: #{sample_association[:source_model]}, " \
|
43
|
+
"target_model: #{sample_association[:target_model]}, " \
|
44
|
+
"type: #{sample_association[:type]}"
|
45
|
+
else
|
46
|
+
Rails.logger.info "ModelAssociationAnalyzer: No associations found"
|
47
|
+
end
|
48
|
+
|
49
|
+
associations
|
50
|
+
end
|
51
|
+
|
52
|
+
# Transform raw association data to Dataset
|
53
|
+
#
|
54
|
+
# @param raw_data [Array<Hash>] raw association data
|
55
|
+
# @return [DiagramData::Dataset] standardized dataset
|
56
|
+
def transform_to_dataset(raw_data)
|
57
|
+
dataset = create_empty_dataset
|
58
|
+
dataset.metadata.merge!({
|
59
|
+
total_relationships: raw_data.count { |a| a[:target_model] },
|
60
|
+
total_models: models.length,
|
61
|
+
model_names: models.map(&:name)
|
62
|
+
})
|
63
|
+
|
64
|
+
# Create entities for each unique model
|
65
|
+
model_entities = {}
|
66
|
+
|
67
|
+
# First, collect all unique models from the associations
|
68
|
+
models_to_process = []
|
69
|
+
raw_data.each do |association|
|
70
|
+
models_to_process << association[:source_model] if association[:source_model]
|
71
|
+
models_to_process << association[:target_model] if association[:target_model]
|
72
|
+
end
|
73
|
+
models_to_process.uniq!
|
74
|
+
|
75
|
+
# Create entities for all models
|
76
|
+
models_to_process.each do |model_name|
|
77
|
+
# Find the model class to extract attributes
|
78
|
+
model_class = find_model_class(model_name)
|
79
|
+
attributes = extract_model_attributes(model_class)
|
80
|
+
methods = extract_model_methods(model_class)
|
81
|
+
|
82
|
+
# Get table name if available
|
83
|
+
table_name = model_class.respond_to?(:table_name) ? model_class.table_name : model_name.downcase
|
84
|
+
|
85
|
+
entity = create_entity(
|
86
|
+
id: table_name || model_name.downcase,
|
87
|
+
name: model_name,
|
88
|
+
type: "model",
|
89
|
+
attributes: attributes,
|
90
|
+
metadata: {
|
91
|
+
table_name: table_name,
|
92
|
+
model_class: model_name,
|
93
|
+
methods: methods
|
94
|
+
}
|
95
|
+
)
|
96
|
+
dataset.add_entity(entity)
|
97
|
+
model_entities[model_name] = entity
|
98
|
+
end
|
99
|
+
|
100
|
+
# Create relationships in a separate loop
|
101
|
+
raw_data.each do |association|
|
102
|
+
next if association[:type] == "node_only" || !association[:target_model]
|
103
|
+
|
104
|
+
source_id = association[:source_table] || association[:source_model].downcase
|
105
|
+
target_id = association[:target_table] || association[:target_model].downcase
|
106
|
+
|
107
|
+
# Include self-referential relationships (source and target are the same)
|
108
|
+
# but log them for debugging
|
109
|
+
if source_id == target_id
|
110
|
+
Rails.logger.info "ModelAssociationAnalyzer: Including self-referential relationship for " \
|
111
|
+
"#{source_id} (#{association[:association_name]})"
|
112
|
+
end
|
113
|
+
|
114
|
+
# Determine cardinality based on relationship type
|
115
|
+
cardinality = determine_cardinality(association[:type])
|
116
|
+
|
117
|
+
relationship = create_relationship({
|
118
|
+
source_id: source_id,
|
119
|
+
target_id: target_id,
|
120
|
+
type: association[:type],
|
121
|
+
label: association[:association_name],
|
122
|
+
cardinality: cardinality,
|
123
|
+
metadata: {
|
124
|
+
association_name: association[:association_name],
|
125
|
+
source_model: association[:source_model],
|
126
|
+
target_model: association[:target_model],
|
127
|
+
original_type: association[:type],
|
128
|
+
self_referential: source_id == target_id
|
129
|
+
}
|
130
|
+
})
|
131
|
+
|
132
|
+
dataset.add_relationship(relationship)
|
133
|
+
end
|
134
|
+
|
135
|
+
dataset
|
136
|
+
end
|
137
|
+
|
138
|
+
# Get analyzer type
|
139
|
+
#
|
140
|
+
# @return [String] analyzer type identifier
|
141
|
+
def analyzer_type
|
142
|
+
"model_association"
|
143
|
+
end
|
144
|
+
|
145
|
+
protected
|
146
|
+
|
147
|
+
# Build analysis context for this analyzer
|
148
|
+
#
|
149
|
+
# @return [Hash] analysis context
|
150
|
+
def analysis_context
|
151
|
+
{
|
152
|
+
session: session,
|
153
|
+
session_tables: session_tables,
|
154
|
+
models: models
|
155
|
+
}
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
attr_reader :session, :session_tables, :models
|
161
|
+
|
162
|
+
# Generate placeholder associations for models without associations
|
163
|
+
#
|
164
|
+
# @return [Array<Hash>] placeholder associations
|
165
|
+
def generate_placeholder_associations
|
166
|
+
# Create nodes for each model
|
167
|
+
result = models.map do |model|
|
168
|
+
{
|
169
|
+
type: "node_only",
|
170
|
+
source_model: model.name,
|
171
|
+
source_table: model.table_name,
|
172
|
+
target_model: nil,
|
173
|
+
target_table: nil,
|
174
|
+
association_name: nil
|
175
|
+
}
|
176
|
+
end
|
177
|
+
|
178
|
+
Rails.logger.info "ModelAssociationAnalyzer: Generated #{result.size} placeholder nodes"
|
179
|
+
result
|
180
|
+
end
|
181
|
+
|
182
|
+
# Check if model analysis is available
|
183
|
+
#
|
184
|
+
# @return [Boolean] true if models can be analyzed
|
185
|
+
def models_available?
|
186
|
+
unless defined?(ActiveRecord::Base)
|
187
|
+
Rails.logger.warn "ModelAssociationAnalyzer: ActiveRecord not available"
|
188
|
+
return false
|
189
|
+
end
|
190
|
+
|
191
|
+
if models.empty?
|
192
|
+
Rails.logger.warn "ModelAssociationAnalyzer: No models available for analysis"
|
193
|
+
return false
|
194
|
+
end
|
195
|
+
|
196
|
+
true
|
197
|
+
end
|
198
|
+
|
199
|
+
# Extract tables that were involved in the session
|
200
|
+
#
|
201
|
+
# @return [Array<String>] unique table names
|
202
|
+
def extract_session_tables
|
203
|
+
return [] unless session&.changes
|
204
|
+
|
205
|
+
session.changes.map do |change|
|
206
|
+
change[:table_name] || change["table_name"]
|
207
|
+
end.compact.uniq
|
208
|
+
end
|
209
|
+
|
210
|
+
# Discover models that correspond to session tables
|
211
|
+
#
|
212
|
+
# @return [Array<Class>] ActiveRecord model classes
|
213
|
+
def discover_session_models
|
214
|
+
return [] unless defined?(ActiveRecord::Base)
|
215
|
+
|
216
|
+
# Get all ActiveRecord models
|
217
|
+
begin
|
218
|
+
all_models = ActiveRecord::Base.descendants.select do |model|
|
219
|
+
model_has_table?(model)
|
220
|
+
end
|
221
|
+
|
222
|
+
# If no models found (e.g., in test environment), try to load them explicitly
|
223
|
+
if all_models.empty? && session_tables.any?
|
224
|
+
Rails.logger.debug "ModelAssociationAnalyzer: No models found via descendants, " \
|
225
|
+
"attempting explicit loading"
|
226
|
+
all_models = attempt_explicit_model_loading
|
227
|
+
end
|
228
|
+
|
229
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Found #{all_models.size} total ActiveRecord models"
|
230
|
+
|
231
|
+
# Filter to models whose tables are in session (if session provided)
|
232
|
+
if session_tables.any?
|
233
|
+
filtered_models = all_models.select { |model| session_tables.include?(model.table_name) }
|
234
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Filtered to #{filtered_models.size} " \
|
235
|
+
"models matching session tables"
|
236
|
+
filtered_models
|
237
|
+
else
|
238
|
+
all_models
|
239
|
+
end
|
240
|
+
rescue StandardError => e
|
241
|
+
Rails.logger.error "ModelAssociationAnalyzer: Error discovering models: #{e.message}"
|
242
|
+
[]
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Attempt to explicitly load models based on table names
|
247
|
+
#
|
248
|
+
# @return [Array<Class>] ActiveRecord model classes
|
249
|
+
def attempt_explicit_model_loading
|
250
|
+
models = []
|
251
|
+
|
252
|
+
session_tables.each do |table_name|
|
253
|
+
# Try to infer model name from table name
|
254
|
+
model_names = [
|
255
|
+
table_name.classify,
|
256
|
+
table_name.singularize.classify,
|
257
|
+
table_name.pluralize.classify
|
258
|
+
].uniq
|
259
|
+
|
260
|
+
model_names.each do |model_name|
|
261
|
+
model_class = model_name.constantize
|
262
|
+
if model_class.respond_to?(:table_name) && model_class.table_name == table_name
|
263
|
+
models << model_class
|
264
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Loaded model #{model_name} for table #{table_name}"
|
265
|
+
break
|
266
|
+
end
|
267
|
+
rescue NameError
|
268
|
+
# Model doesn't exist, try the next one
|
269
|
+
next
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
models
|
274
|
+
end
|
275
|
+
|
276
|
+
# Check if model has a database table
|
277
|
+
#
|
278
|
+
# @param model [Class] ActiveRecord model class
|
279
|
+
# @return [Boolean] true if model has a table
|
280
|
+
def model_has_table?(model)
|
281
|
+
model.table_exists?
|
282
|
+
rescue StandardError
|
283
|
+
false
|
284
|
+
end
|
285
|
+
|
286
|
+
# Extract associations from all models
|
287
|
+
#
|
288
|
+
# @return [Array<Hash>] associations array
|
289
|
+
def extract_model_associations
|
290
|
+
associations = []
|
291
|
+
|
292
|
+
models.each do |model|
|
293
|
+
model_associations = get_model_associations(model)
|
294
|
+
|
295
|
+
model_associations.each do |association|
|
296
|
+
# Skip polymorphic associations for now
|
297
|
+
next if association.options[:polymorphic]
|
298
|
+
|
299
|
+
# Skip if target model is not in scope
|
300
|
+
next unless target_model_in_scope?(association)
|
301
|
+
|
302
|
+
# Build relationship based on association type
|
303
|
+
relationship = build_association_relationship(model, association)
|
304
|
+
associations << relationship if relationship
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
associations
|
309
|
+
end
|
310
|
+
|
311
|
+
# Get associations for a model
|
312
|
+
#
|
313
|
+
# @param model [Class] ActiveRecord model class
|
314
|
+
# @return [Array] association objects
|
315
|
+
def get_model_associations(model)
|
316
|
+
model.reflect_on_all_associations
|
317
|
+
rescue StandardError => e
|
318
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not get associations for #{model.name}: #{e.message}"
|
319
|
+
[]
|
320
|
+
end
|
321
|
+
|
322
|
+
# Check if target model is in analysis scope
|
323
|
+
#
|
324
|
+
# @param association [Object] association object
|
325
|
+
# @return [Boolean] true if target model should be included
|
326
|
+
def target_model_in_scope?(association)
|
327
|
+
target_table = get_association_table_name(association)
|
328
|
+
|
329
|
+
# If analyzing session, both tables must be in session
|
330
|
+
# If analyzing globally, include all
|
331
|
+
return true if session_tables.empty?
|
332
|
+
|
333
|
+
# Skip if target table is not in session
|
334
|
+
return false if target_table && !session_tables.include?(target_table)
|
335
|
+
|
336
|
+
true
|
337
|
+
end
|
338
|
+
|
339
|
+
# Build relationship hash based on association type
|
340
|
+
#
|
341
|
+
# @param model [Class] source model class
|
342
|
+
# @param association [Object] association object
|
343
|
+
# @return [Hash] relationship data
|
344
|
+
def build_association_relationship(model, association)
|
345
|
+
case association.macro
|
346
|
+
when :belongs_to
|
347
|
+
build_belongs_to_relationship(model, association)
|
348
|
+
when :has_one
|
349
|
+
build_has_one_relationship(model, association)
|
350
|
+
when :has_many
|
351
|
+
if association.options[:through]
|
352
|
+
build_has_many_through_relationship(model, association)
|
353
|
+
else
|
354
|
+
build_has_many_relationship(model, association)
|
355
|
+
end
|
356
|
+
when :has_and_belongs_to_many
|
357
|
+
build_habtm_relationship(model, association)
|
358
|
+
else
|
359
|
+
# Handle special cases like has_one_attached from Active Storage
|
360
|
+
if association.name.to_s.end_with?("_attachment") || association.name.to_s.end_with?("_attachments")
|
361
|
+
build_active_storage_relationship(model, association)
|
362
|
+
else
|
363
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Unknown association type: " \
|
364
|
+
"#{association.macro} for #{model.name}.#{association.name}"
|
365
|
+
nil
|
366
|
+
end
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
# Build belongs_to relationship
|
371
|
+
#
|
372
|
+
# @param model [Class] source model class
|
373
|
+
# @param association [Object] association object
|
374
|
+
# @return [Hash] relationship data
|
375
|
+
def build_belongs_to_relationship(model, association)
|
376
|
+
{
|
377
|
+
source_model: model.name,
|
378
|
+
source_table: model.table_name,
|
379
|
+
target_model: association.class_name,
|
380
|
+
target_table: get_association_table_name(association),
|
381
|
+
type: "belongs_to",
|
382
|
+
association_name: association.name.to_s
|
383
|
+
}
|
384
|
+
end
|
385
|
+
|
386
|
+
# Build has_one relationship
|
387
|
+
#
|
388
|
+
# @param model [Class] source model class
|
389
|
+
# @param association [Object] association object
|
390
|
+
# @return [Hash] relationship data
|
391
|
+
def build_has_one_relationship(model, association)
|
392
|
+
{
|
393
|
+
source_model: model.name,
|
394
|
+
source_table: model.table_name,
|
395
|
+
target_model: association.class_name,
|
396
|
+
target_table: get_association_table_name(association),
|
397
|
+
type: "has_one",
|
398
|
+
association_name: association.name.to_s
|
399
|
+
}
|
400
|
+
end
|
401
|
+
|
402
|
+
# Build has_many relationship
|
403
|
+
#
|
404
|
+
# @param model [Class] source model class
|
405
|
+
# @param association [Object] association object
|
406
|
+
# @return [Hash] relationship data
|
407
|
+
def build_has_many_relationship(model, association)
|
408
|
+
{
|
409
|
+
source_model: model.name,
|
410
|
+
source_table: model.table_name,
|
411
|
+
target_model: association.class_name,
|
412
|
+
target_table: get_association_table_name(association),
|
413
|
+
type: "has_many",
|
414
|
+
association_name: association.name.to_s
|
415
|
+
}
|
416
|
+
end
|
417
|
+
|
418
|
+
# Build has_many :through relationship
|
419
|
+
#
|
420
|
+
# @param model [Class] source model class
|
421
|
+
# @param association [Object] association object
|
422
|
+
# @return [Hash] relationship data
|
423
|
+
def build_has_many_through_relationship(model, association)
|
424
|
+
{
|
425
|
+
source_model: model.name,
|
426
|
+
source_table: model.table_name,
|
427
|
+
target_model: association.class_name,
|
428
|
+
target_table: get_association_table_name(association),
|
429
|
+
type: "has_many_through",
|
430
|
+
association_name: "#{association.name} (through #{association.options[:through]})"
|
431
|
+
}
|
432
|
+
end
|
433
|
+
|
434
|
+
# Build has_and_belongs_to_many relationship
|
435
|
+
#
|
436
|
+
# @param model [Class] source model class
|
437
|
+
# @param association [Object] association object
|
438
|
+
# @return [Hash] relationship data
|
439
|
+
def build_habtm_relationship(model, association)
|
440
|
+
{
|
441
|
+
source_model: model.name,
|
442
|
+
source_table: model.table_name,
|
443
|
+
target_model: association.class_name,
|
444
|
+
target_table: get_association_table_name(association),
|
445
|
+
type: "has_and_belongs_to_many",
|
446
|
+
association_name: association.name.to_s
|
447
|
+
}
|
448
|
+
end
|
449
|
+
|
450
|
+
# Build Active Storage relationship
|
451
|
+
#
|
452
|
+
# @param model [Class] source model class
|
453
|
+
# @param association [Object] association object
|
454
|
+
# @return [Hash] relationship data
|
455
|
+
def build_active_storage_relationship(model, association)
|
456
|
+
{
|
457
|
+
source_model: model.name,
|
458
|
+
source_table: model.table_name,
|
459
|
+
target_model: "ActiveStorage::Attachment",
|
460
|
+
target_table: "active_storage_attachments",
|
461
|
+
type: "has_one",
|
462
|
+
association_name: association.name.to_s
|
463
|
+
}
|
464
|
+
end
|
465
|
+
|
466
|
+
# Get table name for association target
|
467
|
+
#
|
468
|
+
# @param association [Object] association object
|
469
|
+
# @return [String, nil] table name
|
470
|
+
def get_association_table_name(association)
|
471
|
+
association.table_name
|
472
|
+
rescue StandardError => e
|
473
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not get table name for #{association.name}: #{e.message}"
|
474
|
+
nil
|
475
|
+
end
|
476
|
+
|
477
|
+
# Find model class by name
|
478
|
+
#
|
479
|
+
# @param model_name [String] model class name
|
480
|
+
# @return [Class, nil] model class
|
481
|
+
def find_model_class(model_name)
|
482
|
+
model_name.constantize
|
483
|
+
rescue StandardError => e
|
484
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not find model class #{model_name}: #{e.message}"
|
485
|
+
nil
|
486
|
+
end
|
487
|
+
|
488
|
+
# Extract attributes from model class
|
489
|
+
#
|
490
|
+
# @param model_class [Class, nil] ActiveRecord model class
|
491
|
+
# @return [Array<Attribute>] model attributes
|
492
|
+
def extract_model_attributes(model_class)
|
493
|
+
return [] unless model_class.respond_to?(:columns)
|
494
|
+
|
495
|
+
begin
|
496
|
+
model_class.columns.map do |column|
|
497
|
+
create_attribute(
|
498
|
+
name: column.name,
|
499
|
+
type: column.type.to_s,
|
500
|
+
nullable: column.null,
|
501
|
+
default: column.default,
|
502
|
+
metadata: {
|
503
|
+
primary_key: column.name == model_class.primary_key,
|
504
|
+
foreign_key: column.name.end_with?("_id"),
|
505
|
+
visibility: "+" # Public visibility for all columns
|
506
|
+
}
|
507
|
+
)
|
508
|
+
end
|
509
|
+
rescue StandardError => e
|
510
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not extract attributes for " \
|
511
|
+
"#{model_class.name}: #{e.message}"
|
512
|
+
[]
|
513
|
+
end
|
514
|
+
end
|
515
|
+
|
516
|
+
# Extract methods from model class
|
517
|
+
#
|
518
|
+
# @param model_class [Class, nil] ActiveRecord model class
|
519
|
+
# @return [Array<Hash>] model methods
|
520
|
+
def extract_model_methods(model_class)
|
521
|
+
return [] unless model_class && Dbwatcher.configuration.diagram_show_methods
|
522
|
+
|
523
|
+
methods = []
|
524
|
+
|
525
|
+
begin
|
526
|
+
# Get instance methods defined in the model (not inherited from ActiveRecord::Base)
|
527
|
+
model_methods = model_class.instance_methods - ActiveRecord::Base.instance_methods
|
528
|
+
model_methods.each do |method_name|
|
529
|
+
# Skip association methods and attribute methods
|
530
|
+
next if method_name.to_s.end_with?("=") || model_class.column_names.include?(method_name.to_s)
|
531
|
+
|
532
|
+
methods << {
|
533
|
+
name: method_name.to_s,
|
534
|
+
visibility: "+" # Public visibility for all methods
|
535
|
+
}
|
536
|
+
end
|
537
|
+
rescue StandardError => e
|
538
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not extract methods for " \
|
539
|
+
"#{model_class.name}: #{e.message}"
|
540
|
+
end
|
541
|
+
|
542
|
+
methods
|
543
|
+
end
|
544
|
+
|
545
|
+
# Determine cardinality based on relationship type
|
546
|
+
#
|
547
|
+
# @param relationship_type [String] relationship type
|
548
|
+
# @return [String, nil] cardinality type
|
549
|
+
def determine_cardinality(relationship_type)
|
550
|
+
case relationship_type
|
551
|
+
when "has_many"
|
552
|
+
"one_to_many"
|
553
|
+
when "belongs_to"
|
554
|
+
"many_to_one"
|
555
|
+
when "has_one"
|
556
|
+
"one_to_one"
|
557
|
+
when "has_and_belongs_to_many", "has_many_through"
|
558
|
+
"many_to_many"
|
559
|
+
end
|
560
|
+
end
|
561
|
+
end
|
562
|
+
end
|
563
|
+
end
|
564
|
+
end
|