dbwatcher 1.0.0 → 1.1.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/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 +603 -0
- data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
- data/lib/dbwatcher/services/diagram_data/dataset.rb +280 -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 +140 -0
- data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +48 -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 +118 -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,603 @@
|
|
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. It uses direct model enumeration
|
10
|
+
# from ActiveRecord::Base.descendants to ensure reliable model discovery.
|
11
|
+
#
|
12
|
+
# Supported model scenarios:
|
13
|
+
# - Regular models with standard table names
|
14
|
+
# - Namespaced models (e.g., Admin::User)
|
15
|
+
# - Models with custom table names (using self.table_name)
|
16
|
+
# - Models from external gems and complex inheritance hierarchies
|
17
|
+
#
|
18
|
+
# @example
|
19
|
+
# analyzer = ModelAssociationAnalyzer.new(session)
|
20
|
+
# dataset = analyzer.call
|
21
|
+
class ModelAssociationAnalyzer < BaseAnalyzer
|
22
|
+
# Initialize with session
|
23
|
+
#
|
24
|
+
# @param session [Session] session to analyze (optional for global analysis)
|
25
|
+
def initialize(session = nil)
|
26
|
+
@session = session
|
27
|
+
@session_tables = session ? extract_session_tables : []
|
28
|
+
@models = discover_session_models
|
29
|
+
super()
|
30
|
+
end
|
31
|
+
|
32
|
+
# Analyze model associations
|
33
|
+
#
|
34
|
+
# @param context [Hash] analysis context
|
35
|
+
# @return [Array<Hash>] array of association data
|
36
|
+
def analyze(_context)
|
37
|
+
return [] unless models_available?
|
38
|
+
|
39
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Starting analysis with #{models.length} models"
|
40
|
+
associations = extract_model_associations
|
41
|
+
associations = generate_placeholder_associations if associations.empty? && models.any?
|
42
|
+
|
43
|
+
log_analysis_results(associations)
|
44
|
+
associations
|
45
|
+
end
|
46
|
+
|
47
|
+
# Transform raw association data to Dataset
|
48
|
+
#
|
49
|
+
# @param raw_data [Array<Hash>] raw association data
|
50
|
+
# @return [DiagramData::Dataset] standardized dataset
|
51
|
+
def transform_to_dataset(raw_data)
|
52
|
+
dataset = create_empty_dataset
|
53
|
+
dataset.metadata.merge!({
|
54
|
+
total_relationships: raw_data.count { |a| a[:target_model] },
|
55
|
+
total_models: models.length,
|
56
|
+
model_names: models.map(&:name)
|
57
|
+
})
|
58
|
+
|
59
|
+
# Create entities from actual discovered models (no inference)
|
60
|
+
create_entities_from_models(dataset)
|
61
|
+
|
62
|
+
# Create relationships from association data
|
63
|
+
create_relationships_from_associations(dataset, raw_data)
|
64
|
+
|
65
|
+
dataset
|
66
|
+
end
|
67
|
+
|
68
|
+
# Create entities from discovered model classes
|
69
|
+
#
|
70
|
+
# @param dataset [DiagramData::Dataset] dataset to add entities to
|
71
|
+
# @return [void]
|
72
|
+
def create_entities_from_models(dataset)
|
73
|
+
models.each do |model_class|
|
74
|
+
attributes = extract_model_attributes(model_class)
|
75
|
+
methods = extract_model_methods(model_class)
|
76
|
+
|
77
|
+
entity = create_entity(
|
78
|
+
id: model_class.table_name,
|
79
|
+
name: model_class.name,
|
80
|
+
type: "model",
|
81
|
+
attributes: attributes,
|
82
|
+
metadata: {
|
83
|
+
table_name: model_class.table_name,
|
84
|
+
model_class: model_class.name,
|
85
|
+
methods: methods
|
86
|
+
}
|
87
|
+
)
|
88
|
+
dataset.add_entity(entity)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Create relationships from association data
|
93
|
+
#
|
94
|
+
# @param dataset [DiagramData::Dataset] dataset to add relationships to
|
95
|
+
# @param raw_data [Array<Hash>] raw association data
|
96
|
+
# @return [void]
|
97
|
+
def create_relationships_from_associations(dataset, raw_data)
|
98
|
+
raw_data.each do |association|
|
99
|
+
next if association[:type] == "node_only" || !association[:target_model]
|
100
|
+
|
101
|
+
source_id = association[:source_table]
|
102
|
+
target_id = association[:target_table]
|
103
|
+
|
104
|
+
# Skip if we don't have valid table IDs
|
105
|
+
next unless source_id && target_id
|
106
|
+
|
107
|
+
# Determine cardinality based on relationship type
|
108
|
+
cardinality = determine_cardinality(association[:type])
|
109
|
+
|
110
|
+
relationship = create_relationship({
|
111
|
+
source_id: source_id,
|
112
|
+
target_id: target_id,
|
113
|
+
type: association[:type],
|
114
|
+
label: association[:association_name],
|
115
|
+
cardinality: cardinality,
|
116
|
+
metadata: {
|
117
|
+
association_name: association[:association_name],
|
118
|
+
source_model: association[:source_model],
|
119
|
+
target_model: association[:target_model],
|
120
|
+
original_type: association[:type],
|
121
|
+
self_referential: source_id == target_id
|
122
|
+
}
|
123
|
+
})
|
124
|
+
|
125
|
+
dataset.add_relationship(relationship)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Get analyzer type
|
130
|
+
#
|
131
|
+
# @return [String] analyzer type identifier
|
132
|
+
def analyzer_type
|
133
|
+
"model_association"
|
134
|
+
end
|
135
|
+
|
136
|
+
protected
|
137
|
+
|
138
|
+
# Build analysis context for this analyzer
|
139
|
+
#
|
140
|
+
# @return [Hash] analysis context
|
141
|
+
def analysis_context
|
142
|
+
{
|
143
|
+
session: session,
|
144
|
+
session_tables: session_tables,
|
145
|
+
models: models
|
146
|
+
}
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
attr_reader :session, :session_tables, :models
|
152
|
+
|
153
|
+
# Eagerly load all models including those from gems
|
154
|
+
#
|
155
|
+
# @return [void]
|
156
|
+
def eager_load_models
|
157
|
+
return unless defined?(Rails) && Rails.respond_to?(:application)
|
158
|
+
|
159
|
+
begin
|
160
|
+
# Force eager loading of application models
|
161
|
+
Rails.application.eager_load!
|
162
|
+
|
163
|
+
# Also load models from engines/gems if any are configured
|
164
|
+
Rails::Engine.descendants.each do |engine|
|
165
|
+
engine.eager_load! if engine.respond_to?(:eager_load!)
|
166
|
+
rescue StandardError => e
|
167
|
+
error_message = "ModelAssociationAnalyzer: Could not eager load engine #{engine.class.name}: #{e.message}"
|
168
|
+
Rails.logger.debug error_message
|
169
|
+
end
|
170
|
+
rescue StandardError => e
|
171
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Could not eager load models: #{e.message}"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Generate placeholder associations for models without associations
|
176
|
+
#
|
177
|
+
# @return [Array<Hash>] placeholder associations
|
178
|
+
def generate_placeholder_associations
|
179
|
+
# Create nodes for each model
|
180
|
+
result = models.map do |model|
|
181
|
+
{
|
182
|
+
type: "node_only",
|
183
|
+
source_model: model.name,
|
184
|
+
source_table: model.table_name,
|
185
|
+
target_model: nil,
|
186
|
+
target_table: nil,
|
187
|
+
association_name: nil
|
188
|
+
}
|
189
|
+
end
|
190
|
+
|
191
|
+
Rails.logger.info "ModelAssociationAnalyzer: Generated #{result.size} placeholder nodes"
|
192
|
+
result
|
193
|
+
end
|
194
|
+
|
195
|
+
# Check if model analysis is available
|
196
|
+
#
|
197
|
+
# @return [Boolean] true if models can be analyzed
|
198
|
+
def models_available?
|
199
|
+
unless activerecord_available?
|
200
|
+
Rails.logger.warn "ModelAssociationAnalyzer: ActiveRecord not available"
|
201
|
+
return false
|
202
|
+
end
|
203
|
+
|
204
|
+
if models.empty?
|
205
|
+
Rails.logger.warn "ModelAssociationAnalyzer: No models available for analysis"
|
206
|
+
return false
|
207
|
+
end
|
208
|
+
|
209
|
+
true
|
210
|
+
end
|
211
|
+
|
212
|
+
# Extract tables that were involved in the session
|
213
|
+
#
|
214
|
+
# @return [Array<String>] unique table names
|
215
|
+
def extract_session_tables
|
216
|
+
return [] unless session&.changes
|
217
|
+
|
218
|
+
session.changes.map do |change|
|
219
|
+
change[:table_name] || change["table_name"]
|
220
|
+
end.compact.uniq
|
221
|
+
end
|
222
|
+
|
223
|
+
# Discover models that correspond to session tables
|
224
|
+
#
|
225
|
+
# @return [Array<Class>] ActiveRecord model classes
|
226
|
+
def discover_session_models
|
227
|
+
return [] unless activerecord_available?
|
228
|
+
|
229
|
+
begin
|
230
|
+
all_models = load_all_models
|
231
|
+
discovered_models = filter_models_by_session(all_models)
|
232
|
+
|
233
|
+
# Log the discovered models and their table names for debugging
|
234
|
+
if discovered_models.any?
|
235
|
+
model_table_info = discovered_models.map { |m| "#{m.name} (#{m.table_name})" }
|
236
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Discovered models: #{model_table_info.join(", ")}"
|
237
|
+
end
|
238
|
+
|
239
|
+
discovered_models
|
240
|
+
rescue StandardError => e
|
241
|
+
Rails.logger.error "ModelAssociationAnalyzer: Error discovering models: #{e.message}"
|
242
|
+
[]
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Log analysis results for debugging
|
247
|
+
#
|
248
|
+
# @param associations [Array<Hash>] found associations
|
249
|
+
# @return [void]
|
250
|
+
def log_analysis_results(associations)
|
251
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Found #{associations.length} associations"
|
252
|
+
|
253
|
+
if associations.any?
|
254
|
+
sample_association = associations.first
|
255
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Sample association - " \
|
256
|
+
"source_model: #{sample_association[:source_model]}, " \
|
257
|
+
"target_model: #{sample_association[:target_model]}, " \
|
258
|
+
"type: #{sample_association[:type]}"
|
259
|
+
else
|
260
|
+
Rails.logger.info "ModelAssociationAnalyzer: No associations found"
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
# === Model Discovery Methods ===
|
265
|
+
|
266
|
+
# Check if ActiveRecord is available
|
267
|
+
#
|
268
|
+
# @return [Boolean]
|
269
|
+
def activerecord_available?
|
270
|
+
defined?(ActiveRecord::Base)
|
271
|
+
end
|
272
|
+
|
273
|
+
# Load all available ActiveRecord models including from gems
|
274
|
+
#
|
275
|
+
# @return [Array<Class>] ActiveRecord model classes
|
276
|
+
def load_all_models
|
277
|
+
eager_load_models
|
278
|
+
|
279
|
+
# Get all model classes directly from ActiveRecord descendants
|
280
|
+
all_models = ActiveRecord::Base.descendants
|
281
|
+
.select { |model| valid_model_class?(model) }
|
282
|
+
.uniq
|
283
|
+
|
284
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Found #{all_models.size} total ActiveRecord models"
|
285
|
+
all_models
|
286
|
+
end
|
287
|
+
|
288
|
+
# Check if a model class is valid for analysis
|
289
|
+
#
|
290
|
+
# @param model [Class] ActiveRecord model class
|
291
|
+
# @return [Boolean] true if model is valid
|
292
|
+
def valid_model_class?(model)
|
293
|
+
# Must be a proper class with a name (not anonymous)
|
294
|
+
return false unless model.name
|
295
|
+
|
296
|
+
# Must have a table that exists
|
297
|
+
return false unless model_has_table?(model)
|
298
|
+
|
299
|
+
# Skip abstract models
|
300
|
+
return false if model.abstract_class?
|
301
|
+
|
302
|
+
true
|
303
|
+
rescue StandardError => e
|
304
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Error validating model #{model}: #{e.message}"
|
305
|
+
false
|
306
|
+
end
|
307
|
+
|
308
|
+
# Check if model has a database table
|
309
|
+
#
|
310
|
+
# @param model [Class] ActiveRecord model class
|
311
|
+
# @return [Boolean] true if model has a table
|
312
|
+
def model_has_table?(model)
|
313
|
+
model.table_exists?
|
314
|
+
rescue StandardError
|
315
|
+
false
|
316
|
+
end
|
317
|
+
|
318
|
+
# Filter models based on session tables
|
319
|
+
#
|
320
|
+
# @param all_models [Array<Class>] all available models
|
321
|
+
# @return [Array<Class>] filtered models
|
322
|
+
def filter_models_by_session(all_models)
|
323
|
+
return all_models if session_tables.empty?
|
324
|
+
|
325
|
+
# Build a hash of table_name -> model for efficient lookup
|
326
|
+
table_to_models = {}
|
327
|
+
all_models.each do |model|
|
328
|
+
table_name = model.table_name
|
329
|
+
table_to_models[table_name] ||= []
|
330
|
+
table_to_models[table_name] << model
|
331
|
+
rescue StandardError => e
|
332
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Error checking table_name for " \
|
333
|
+
"#{model.name}: #{e.message}"
|
334
|
+
end
|
335
|
+
|
336
|
+
# Select models whose tables are in the session
|
337
|
+
filtered_models = []
|
338
|
+
session_tables.each do |table_name|
|
339
|
+
models_for_table = table_to_models[table_name]
|
340
|
+
filtered_models.concat(models_for_table) if models_for_table
|
341
|
+
end
|
342
|
+
|
343
|
+
Rails.logger.debug "ModelAssociationAnalyzer: Filtered to #{filtered_models.size} " \
|
344
|
+
"models matching session tables (from #{session_tables.size} tables)"
|
345
|
+
filtered_models
|
346
|
+
end
|
347
|
+
|
348
|
+
# === Association Analysis Methods ===
|
349
|
+
|
350
|
+
# Extract associations from all models
|
351
|
+
#
|
352
|
+
# @return [Array<Hash>] associations array
|
353
|
+
def extract_model_associations
|
354
|
+
associations = []
|
355
|
+
|
356
|
+
models.each do |model|
|
357
|
+
model_associations = get_model_associations(model)
|
358
|
+
|
359
|
+
model_associations.each do |association|
|
360
|
+
# Skip polymorphic associations for now
|
361
|
+
next if association.options[:polymorphic]
|
362
|
+
|
363
|
+
# Skip if target model is not in scope
|
364
|
+
next unless target_model_in_scope?(association)
|
365
|
+
|
366
|
+
# Build relationship based on association type
|
367
|
+
relationship = build_association_relationship(model, association)
|
368
|
+
associations << relationship if relationship
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
associations
|
373
|
+
end
|
374
|
+
|
375
|
+
# Get associations for a model
|
376
|
+
#
|
377
|
+
# @param model [Class] ActiveRecord model class
|
378
|
+
# @return [Array] association objects
|
379
|
+
def get_model_associations(model)
|
380
|
+
model.reflect_on_all_associations
|
381
|
+
rescue StandardError => e
|
382
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not get associations for #{model.name}: #{e.message}"
|
383
|
+
[]
|
384
|
+
end
|
385
|
+
|
386
|
+
# Check if target model is in analysis scope
|
387
|
+
#
|
388
|
+
# @param association [Object] association object
|
389
|
+
# @return [Boolean] true if target model should be included
|
390
|
+
def target_model_in_scope?(association)
|
391
|
+
target_table = get_association_table_name(association)
|
392
|
+
|
393
|
+
# If analyzing session, both tables must be in session
|
394
|
+
# If analyzing globally, include all
|
395
|
+
return true if session_tables.empty?
|
396
|
+
|
397
|
+
# Skip if target table is not in session
|
398
|
+
return false if target_table && !session_tables.include?(target_table)
|
399
|
+
|
400
|
+
true
|
401
|
+
end
|
402
|
+
|
403
|
+
# Build relationship hash based on association type
|
404
|
+
#
|
405
|
+
# @param model [Class] source model class
|
406
|
+
# @param association [Object] association object
|
407
|
+
# @return [Hash] relationship data
|
408
|
+
def build_association_relationship(model, association)
|
409
|
+
case association.macro
|
410
|
+
when :belongs_to
|
411
|
+
build_belongs_to_relationship(model, association)
|
412
|
+
when :has_one
|
413
|
+
build_has_one_relationship(model, association)
|
414
|
+
when :has_many
|
415
|
+
if association.options[:through]
|
416
|
+
build_has_many_through_relationship(model, association)
|
417
|
+
else
|
418
|
+
build_has_many_relationship(model, association)
|
419
|
+
end
|
420
|
+
when :has_and_belongs_to_many
|
421
|
+
build_habtm_relationship(model, association)
|
422
|
+
else
|
423
|
+
# Handle special cases like has_one_attached from Active Storage
|
424
|
+
if association.name.to_s.end_with?("_attachment") || association.name.to_s.end_with?("_attachments")
|
425
|
+
build_active_storage_relationship(model, association)
|
426
|
+
else
|
427
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Unknown association type: " \
|
428
|
+
"#{association.macro} for #{model.name}.#{association.name}"
|
429
|
+
nil
|
430
|
+
end
|
431
|
+
end
|
432
|
+
end
|
433
|
+
|
434
|
+
# Build a standardized relationship hash
|
435
|
+
#
|
436
|
+
# @param model [Class] source model class
|
437
|
+
# @param association [Object] association object
|
438
|
+
# @param type [String] relationship type
|
439
|
+
# @return [Hash] relationship data
|
440
|
+
def build_relationship_hash(model, association, type)
|
441
|
+
return nil unless association&.class_name
|
442
|
+
|
443
|
+
{
|
444
|
+
source_model: model.name,
|
445
|
+
source_table: model.table_name,
|
446
|
+
target_model: association.class_name,
|
447
|
+
target_table: get_association_table_name(association),
|
448
|
+
type: type,
|
449
|
+
association_name: association.name.to_s
|
450
|
+
}
|
451
|
+
end
|
452
|
+
|
453
|
+
# Build belongs_to relationship
|
454
|
+
#
|
455
|
+
# @param model [Class] source model class
|
456
|
+
# @param association [Object] association object
|
457
|
+
# @return [Hash] relationship data
|
458
|
+
def build_belongs_to_relationship(model, association)
|
459
|
+
build_relationship_hash(model, association, "belongs_to")
|
460
|
+
end
|
461
|
+
|
462
|
+
# Build has_one relationship
|
463
|
+
#
|
464
|
+
# @param model [Class] source model class
|
465
|
+
# @param association [Object] association object
|
466
|
+
# @return [Hash] relationship data
|
467
|
+
def build_has_one_relationship(model, association)
|
468
|
+
build_relationship_hash(model, association, "has_one")
|
469
|
+
end
|
470
|
+
|
471
|
+
# Build has_many relationship
|
472
|
+
#
|
473
|
+
# @param model [Class] source model class
|
474
|
+
# @param association [Object] association object
|
475
|
+
# @return [Hash] relationship data
|
476
|
+
def build_has_many_relationship(model, association)
|
477
|
+
build_relationship_hash(model, association, "has_many")
|
478
|
+
end
|
479
|
+
|
480
|
+
# Build has_many :through relationship
|
481
|
+
#
|
482
|
+
# @param model [Class] source model class
|
483
|
+
# @param association [Object] association object
|
484
|
+
# @return [Hash] relationship data
|
485
|
+
def build_has_many_through_relationship(model, association)
|
486
|
+
relationship = build_relationship_hash(model, association, "has_many_through")
|
487
|
+
relationship[:association_name] = "#{association.name} (through #{association.options[:through]})"
|
488
|
+
relationship
|
489
|
+
end
|
490
|
+
|
491
|
+
# Build has_and_belongs_to_many relationship
|
492
|
+
#
|
493
|
+
# @param model [Class] source model class
|
494
|
+
# @param association [Object] association object
|
495
|
+
# @return [Hash] relationship data
|
496
|
+
def build_habtm_relationship(model, association)
|
497
|
+
build_relationship_hash(model, association, "has_and_belongs_to_many")
|
498
|
+
end
|
499
|
+
|
500
|
+
# Build Active Storage relationship
|
501
|
+
#
|
502
|
+
# @param model [Class] source model class
|
503
|
+
# @param association [Object] association object
|
504
|
+
# @return [Hash] relationship data
|
505
|
+
def build_active_storage_relationship(model, association)
|
506
|
+
{
|
507
|
+
source_model: model.name,
|
508
|
+
source_table: model.table_name,
|
509
|
+
target_model: "ActiveStorage::Attachment",
|
510
|
+
target_table: "active_storage_attachments",
|
511
|
+
type: "has_one",
|
512
|
+
association_name: association.name.to_s
|
513
|
+
}
|
514
|
+
end
|
515
|
+
|
516
|
+
# Get table name for association target
|
517
|
+
#
|
518
|
+
# @param association [Object] association object
|
519
|
+
# @return [String, nil] table name
|
520
|
+
def get_association_table_name(association)
|
521
|
+
association.table_name
|
522
|
+
rescue StandardError => e
|
523
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not get table name for #{association.name}: #{e.message}"
|
524
|
+
nil
|
525
|
+
end
|
526
|
+
|
527
|
+
# Extract attributes from model class
|
528
|
+
#
|
529
|
+
# @param model_class [Class, nil] ActiveRecord model class
|
530
|
+
# @return [Array<Attribute>] model attributes
|
531
|
+
def extract_model_attributes(model_class)
|
532
|
+
return [] unless model_class.respond_to?(:columns)
|
533
|
+
|
534
|
+
begin
|
535
|
+
model_class.columns.map do |column|
|
536
|
+
create_attribute(
|
537
|
+
name: column.name,
|
538
|
+
type: column.type.to_s,
|
539
|
+
nullable: column.null,
|
540
|
+
default: column.default,
|
541
|
+
metadata: {
|
542
|
+
primary_key: column.name == model_class.primary_key,
|
543
|
+
foreign_key: column.name.end_with?("_id"),
|
544
|
+
visibility: "+" # Public visibility for all columns
|
545
|
+
}
|
546
|
+
)
|
547
|
+
end
|
548
|
+
rescue StandardError => e
|
549
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not extract attributes for " \
|
550
|
+
"#{model_class.name}: #{e.message}"
|
551
|
+
[]
|
552
|
+
end
|
553
|
+
end
|
554
|
+
|
555
|
+
# Extract methods from model class
|
556
|
+
#
|
557
|
+
# @param model_class [Class, nil] ActiveRecord model class
|
558
|
+
# @return [Array<Hash>] model methods
|
559
|
+
def extract_model_methods(model_class)
|
560
|
+
return [] unless model_class && Dbwatcher.configuration.diagram_show_methods
|
561
|
+
|
562
|
+
methods = []
|
563
|
+
|
564
|
+
begin
|
565
|
+
# Get instance methods defined in the model (not inherited from ActiveRecord::Base)
|
566
|
+
model_methods = model_class.instance_methods - ActiveRecord::Base.instance_methods
|
567
|
+
model_methods.each do |method_name|
|
568
|
+
# Skip association methods and attribute methods
|
569
|
+
next if method_name.to_s.end_with?("=") || model_class.column_names.include?(method_name.to_s)
|
570
|
+
|
571
|
+
methods << {
|
572
|
+
name: method_name.to_s,
|
573
|
+
visibility: "+" # Public visibility for all methods
|
574
|
+
}
|
575
|
+
end
|
576
|
+
rescue StandardError => e
|
577
|
+
Rails.logger.warn "ModelAssociationAnalyzer: Could not extract methods for " \
|
578
|
+
"#{model_class.name}: #{e.message}"
|
579
|
+
end
|
580
|
+
|
581
|
+
methods
|
582
|
+
end
|
583
|
+
|
584
|
+
# Determine cardinality based on relationship type
|
585
|
+
#
|
586
|
+
# @param relationship_type [String] relationship type
|
587
|
+
# @return [String, nil] cardinality type
|
588
|
+
def determine_cardinality(relationship_type)
|
589
|
+
case relationship_type
|
590
|
+
when "has_many"
|
591
|
+
"one_to_many"
|
592
|
+
when "belongs_to"
|
593
|
+
"many_to_one"
|
594
|
+
when "has_one"
|
595
|
+
"one_to_one"
|
596
|
+
when "has_and_belongs_to_many", "has_many_through"
|
597
|
+
"many_to_many"
|
598
|
+
end
|
599
|
+
end
|
600
|
+
end
|
601
|
+
end
|
602
|
+
end
|
603
|
+
end
|