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.
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 +564 -0
  68. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  69. data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -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 +136 -0
  85. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -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 +102 -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,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