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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -210
  3. data/app/assets/config/dbwatcher_manifest.js +15 -0
  4. data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
  5. data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
  6. data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
  7. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
  8. data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
  9. data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
  10. data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
  11. data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
  12. data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
  13. data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
  14. data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
  15. data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
  16. data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
  17. data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
  18. data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
  19. data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
  20. data/app/assets/stylesheets/dbwatcher/application.css +423 -0
  21. data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
  22. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
  23. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
  24. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
  25. data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
  26. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
  27. data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
  28. data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
  29. data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
  30. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
  31. data/app/controllers/dbwatcher/base_controller.rb +8 -2
  32. data/app/controllers/dbwatcher/dashboard_controller.rb +8 -0
  33. data/app/controllers/dbwatcher/sessions_controller.rb +25 -10
  34. data/app/helpers/dbwatcher/component_helper.rb +29 -0
  35. data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
  36. data/app/helpers/dbwatcher/session_helper.rb +3 -2
  37. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
  38. data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
  39. data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
  40. data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
  41. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
  42. data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
  43. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
  44. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
  45. data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
  46. data/app/views/dbwatcher/sessions/index.html.erb +14 -10
  47. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
  48. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
  49. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
  50. data/app/views/dbwatcher/sessions/show.html.erb +3 -346
  51. data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
  52. data/app/views/layouts/dbwatcher/application.html.erb +125 -247
  53. data/bin/compile_scss +49 -0
  54. data/config/routes.rb +26 -0
  55. data/lib/dbwatcher/configuration.rb +102 -8
  56. data/lib/dbwatcher/engine.rb +17 -7
  57. data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
  58. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
  59. data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
  60. data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
  61. data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
  62. data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
  63. data/lib/dbwatcher/services/base_service.rb +64 -0
  64. data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
  65. data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
  66. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
  67. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +603 -0
  68. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  69. data/lib/dbwatcher/services/diagram_data/dataset.rb +280 -0
  70. data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
  71. data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
  72. data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
  73. data/lib/dbwatcher/services/diagram_data.rb +65 -0
  74. data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
  75. data/lib/dbwatcher/services/diagram_generator.rb +154 -0
  76. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
  77. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
  78. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
  79. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
  80. data/lib/dbwatcher/services/diagram_system.rb +69 -0
  81. data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
  82. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
  83. data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
  84. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +140 -0
  85. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +48 -0
  86. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
  87. data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
  88. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +118 -0
  89. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
  90. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +15 -128
  91. data/lib/dbwatcher/storage/api/session_api.rb +47 -0
  92. data/lib/dbwatcher/storage/base_storage.rb +7 -0
  93. data/lib/dbwatcher/version.rb +1 -1
  94. data/lib/dbwatcher.rb +58 -1
  95. 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