dbwatcher 1.1.0 → 1.1.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -2
  3. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +12 -22
  4. data/app/assets/javascripts/dbwatcher/components/dashboard.js +325 -0
  5. data/app/assets/stylesheets/dbwatcher/application.css +394 -41
  6. data/app/assets/stylesheets/dbwatcher/application.scss +4 -0
  7. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +68 -23
  8. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +83 -26
  9. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +3 -3
  10. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +9 -0
  11. data/app/assets/stylesheets/dbwatcher/components/_tabulator.scss +248 -0
  12. data/app/assets/stylesheets/dbwatcher/vendor/_tabulator_overrides.scss +37 -0
  13. data/app/controllers/dbwatcher/api/v1/system_info_controller.rb +180 -0
  14. data/app/controllers/dbwatcher/dashboard/system_info_controller.rb +64 -0
  15. data/app/controllers/dbwatcher/dashboard_controller.rb +17 -0
  16. data/app/controllers/dbwatcher/sessions_controller.rb +3 -19
  17. data/app/helpers/dbwatcher/application_helper.rb +43 -11
  18. data/app/helpers/dbwatcher/diagram_helper.rb +0 -88
  19. data/app/views/dbwatcher/dashboard/_layout.html.erb +27 -0
  20. data/app/views/dbwatcher/dashboard/_overview.html.erb +188 -0
  21. data/app/views/dbwatcher/dashboard/_system_info.html.erb +22 -0
  22. data/app/views/dbwatcher/dashboard/_system_info_content.html.erb +389 -0
  23. data/app/views/dbwatcher/dashboard/index.html.erb +8 -177
  24. data/app/views/dbwatcher/sessions/_changes.html.erb +91 -0
  25. data/app/views/dbwatcher/sessions/_layout.html.erb +23 -0
  26. data/app/views/dbwatcher/sessions/index.html.erb +107 -87
  27. data/app/views/dbwatcher/sessions/show.html.erb +10 -4
  28. data/app/views/dbwatcher/tables/index.html.erb +32 -40
  29. data/app/views/layouts/dbwatcher/application.html.erb +100 -48
  30. data/config/routes.rb +23 -6
  31. data/lib/dbwatcher/configuration.rb +18 -1
  32. data/lib/dbwatcher/services/base_service.rb +2 -0
  33. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +177 -138
  34. data/lib/dbwatcher/services/diagram_data/dataset.rb +2 -0
  35. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +13 -9
  36. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +3 -1
  37. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +17 -1
  38. data/lib/dbwatcher/services/system_info/database_info_collector.rb +263 -0
  39. data/lib/dbwatcher/services/system_info/machine_info_collector.rb +387 -0
  40. data/lib/dbwatcher/services/system_info/runtime_info_collector.rb +328 -0
  41. data/lib/dbwatcher/services/system_info/system_info_collector.rb +114 -0
  42. data/lib/dbwatcher/storage/concerns/error_handler.rb +6 -6
  43. data/lib/dbwatcher/storage/session.rb +5 -0
  44. data/lib/dbwatcher/storage/system_info_storage.rb +242 -0
  45. data/lib/dbwatcher/storage.rb +12 -0
  46. data/lib/dbwatcher/version.rb +1 -1
  47. data/lib/dbwatcher.rb +15 -1
  48. metadata +20 -15
  49. data/app/helpers/dbwatcher/component_helper.rb +0 -29
  50. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +0 -265
  51. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +0 -12
  52. data/app/views/dbwatcher/sessions/changes.html.erb +0 -21
  53. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +0 -44
  54. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +0 -96
  55. data/app/views/dbwatcher/sessions/diagrams.html.erb +0 -21
  56. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +0 -8
  57. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +0 -35
  58. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +0 -25
  59. data/app/views/dbwatcher/sessions/summary.html.erb +0 -21
  60. /data/app/views/dbwatcher/sessions/{_diagrams_tab.html.erb → _diagrams.html.erb} +0 -0
  61. /data/app/views/dbwatcher/sessions/{_summary_tab.html.erb → _summary.html.erb} +0 -0
@@ -6,7 +6,14 @@ module Dbwatcher
6
6
  # Analyzes relationships based on ActiveRecord model associations
7
7
  #
8
8
  # This service examines ActiveRecord models to detect associations between
9
- # models that were involved in a session.
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
10
17
  #
11
18
  # @example
12
19
  # analyzer = ModelAssociationAnalyzer.new(session)
@@ -31,21 +38,9 @@ module Dbwatcher
31
38
 
32
39
  Rails.logger.debug "ModelAssociationAnalyzer: Starting analysis with #{models.length} models"
33
40
  associations = extract_model_associations
34
-
35
- # Add placeholder nodes for models without associations
36
41
  associations = generate_placeholder_associations if associations.empty? && models.any?
37
42
 
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
-
43
+ log_analysis_results(associations)
49
44
  associations
50
45
  end
51
46
 
@@ -61,55 +56,53 @@ module Dbwatcher
61
56
  model_names: models.map(&:name)
62
57
  })
63
58
 
64
- # Create entities for each unique model
65
- model_entities = {}
59
+ # Create entities from actual discovered models (no inference)
60
+ create_entities_from_models(dataset)
66
61
 
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!
62
+ # Create relationships from association data
63
+ create_relationships_from_associations(dataset, raw_data)
74
64
 
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)
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|
79
74
  attributes = extract_model_attributes(model_class)
80
75
  methods = extract_model_methods(model_class)
81
76
 
82
- # Get table name if available
83
- table_name = model_class.respond_to?(:table_name) ? model_class.table_name : model_name.downcase
84
-
85
77
  entity = create_entity(
86
- id: table_name || model_name.downcase,
87
- name: model_name,
78
+ id: model_class.table_name,
79
+ name: model_class.name,
88
80
  type: "model",
89
81
  attributes: attributes,
90
82
  metadata: {
91
- table_name: table_name,
92
- model_class: model_name,
83
+ table_name: model_class.table_name,
84
+ model_class: model_class.name,
93
85
  methods: methods
94
86
  }
95
87
  )
96
88
  dataset.add_entity(entity)
97
- model_entities[model_name] = entity
98
89
  end
90
+ end
99
91
 
100
- # Create relationships in a separate loop
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)
101
98
  raw_data.each do |association|
102
99
  next if association[:type] == "node_only" || !association[:target_model]
103
100
 
104
- source_id = association[:source_table] || association[:source_model].downcase
105
- target_id = association[:target_table] || association[:target_model].downcase
101
+ source_id = association[:source_table]
102
+ target_id = association[:target_table]
106
103
 
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
104
+ # Skip if we don't have valid table IDs
105
+ next unless source_id && target_id
113
106
 
114
107
  # Determine cardinality based on relationship type
115
108
  cardinality = determine_cardinality(association[:type])
@@ -131,8 +124,6 @@ module Dbwatcher
131
124
 
132
125
  dataset.add_relationship(relationship)
133
126
  end
134
-
135
- dataset
136
127
  end
137
128
 
138
129
  # Get analyzer type
@@ -159,6 +150,28 @@ module Dbwatcher
159
150
 
160
151
  attr_reader :session, :session_tables, :models
161
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
+
162
175
  # Generate placeholder associations for models without associations
163
176
  #
164
177
  # @return [Array<Hash>] placeholder associations
@@ -183,7 +196,7 @@ module Dbwatcher
183
196
  #
184
197
  # @return [Boolean] true if models can be analyzed
185
198
  def models_available?
186
- unless defined?(ActiveRecord::Base)
199
+ unless activerecord_available?
187
200
  Rails.logger.warn "ModelAssociationAnalyzer: ActiveRecord not available"
188
201
  return false
189
202
  end
@@ -211,66 +224,85 @@ module Dbwatcher
211
224
  #
212
225
  # @return [Array<Class>] ActiveRecord model classes
213
226
  def discover_session_models
214
- return [] unless defined?(ActiveRecord::Base)
227
+ return [] unless activerecord_available?
215
228
 
216
- # Get all ActiveRecord models
217
229
  begin
218
- all_models = ActiveRecord::Base.descendants.select do |model|
219
- model_has_table?(model)
220
- end
230
+ all_models = load_all_models
231
+ discovered_models = filter_models_by_session(all_models)
221
232
 
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
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(", ")}"
227
237
  end
228
238
 
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
239
+ discovered_models
240
240
  rescue StandardError => e
241
241
  Rails.logger.error "ModelAssociationAnalyzer: Error discovering models: #{e.message}"
242
242
  []
243
243
  end
244
244
  end
245
245
 
246
- # Attempt to explicitly load models based on table names
246
+ # Log analysis results for debugging
247
247
  #
248
- # @return [Array<Class>] ActiveRecord model classes
249
- def attempt_explicit_model_loading
250
- models = []
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"
251
252
 
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
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"
271
261
  end
262
+ end
263
+
264
+ # === Model Discovery Methods ===
272
265
 
273
- models
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
274
306
  end
275
307
 
276
308
  # Check if model has a database table
@@ -283,6 +315,38 @@ module Dbwatcher
283
315
  false
284
316
  end
285
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
+
286
350
  # Extract associations from all models
287
351
  #
288
352
  # @return [Array<Hash>] associations array
@@ -367,36 +431,41 @@ module Dbwatcher
367
431
  end
368
432
  end
369
433
 
370
- # Build belongs_to relationship
434
+ # Build a standardized relationship hash
371
435
  #
372
436
  # @param model [Class] source model class
373
437
  # @param association [Object] association object
438
+ # @param type [String] relationship type
374
439
  # @return [Hash] relationship data
375
- def build_belongs_to_relationship(model, association)
440
+ def build_relationship_hash(model, association, type)
441
+ return nil unless association&.class_name
442
+
376
443
  {
377
444
  source_model: model.name,
378
445
  source_table: model.table_name,
379
446
  target_model: association.class_name,
380
447
  target_table: get_association_table_name(association),
381
- type: "belongs_to",
448
+ type: type,
382
449
  association_name: association.name.to_s
383
450
  }
384
451
  end
385
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
+
386
462
  # Build has_one relationship
387
463
  #
388
464
  # @param model [Class] source model class
389
465
  # @param association [Object] association object
390
466
  # @return [Hash] relationship data
391
467
  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
- }
468
+ build_relationship_hash(model, association, "has_one")
400
469
  end
401
470
 
402
471
  # Build has_many relationship
@@ -405,14 +474,7 @@ module Dbwatcher
405
474
  # @param association [Object] association object
406
475
  # @return [Hash] relationship data
407
476
  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
- }
477
+ build_relationship_hash(model, association, "has_many")
416
478
  end
417
479
 
418
480
  # Build has_many :through relationship
@@ -421,14 +483,9 @@ module Dbwatcher
421
483
  # @param association [Object] association object
422
484
  # @return [Hash] relationship data
423
485
  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
- }
486
+ relationship = build_relationship_hash(model, association, "has_many_through")
487
+ relationship[:association_name] = "#{association.name} (through #{association.options[:through]})"
488
+ relationship
432
489
  end
433
490
 
434
491
  # Build has_and_belongs_to_many relationship
@@ -437,14 +494,7 @@ module Dbwatcher
437
494
  # @param association [Object] association object
438
495
  # @return [Hash] relationship data
439
496
  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
- }
497
+ build_relationship_hash(model, association, "has_and_belongs_to_many")
448
498
  end
449
499
 
450
500
  # Build Active Storage relationship
@@ -474,17 +524,6 @@ module Dbwatcher
474
524
  nil
475
525
  end
476
526
 
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
527
  # Extract attributes from model class
489
528
  #
490
529
  # @param model_class [Class, nil] ActiveRecord model class
@@ -96,9 +96,11 @@ module Dbwatcher
96
96
  #
97
97
  # @param relationship [Relationship] relationship to remove
98
98
  # @return [Boolean] true if relationship was removed
99
+ # rubocop:disable Naming/PredicateMethod
99
100
  def remove_relationship(relationship)
100
101
  !@relationships.delete(relationship).nil?
101
102
  end
103
+ # rubocop:enable Naming/PredicateMethod
102
104
 
103
105
  # Get relationships for an entity
104
106
  #
@@ -69,12 +69,12 @@ module Dbwatcher
69
69
  def build_attributes_section(entity)
70
70
  return [] unless show_attributes? && entity.attributes.any?
71
71
 
72
- lines = [" %% Attributes"]
72
+ lines = [" %% === Attributes ==="]
73
73
  entity.attributes.first(max_attributes).each do |attr|
74
74
  lines << format_attribute_line(attr)
75
75
  end
76
76
  add_attributes_overflow_message(lines, entity)
77
- add_section_divider(lines, entity)
77
+ lines << ""
78
78
  lines
79
79
  end
80
80
 
@@ -82,14 +82,14 @@ module Dbwatcher
82
82
  def build_methods_section(entity)
83
83
  return [] unless show_methods? && entity.metadata[:methods]&.any?
84
84
 
85
- lines = [" %% Methods"]
85
+ lines = [" %% === Methods ==="]
86
86
  entity.metadata[:methods].first(max_methods).each do |method|
87
87
  lines << format_method_line(method)
88
88
  end
89
89
  if entity.metadata[:methods].size > max_methods
90
90
  lines << " %% ... #{entity.metadata[:methods].size - max_methods} more methods"
91
91
  end
92
- lines << " %% ----------------------"
92
+ lines << ""
93
93
  lines
94
94
  end
95
95
 
@@ -97,16 +97,20 @@ module Dbwatcher
97
97
  def build_statistics_section(entity)
98
98
  return [] unless entity.attributes.any? || entity.metadata[:methods]&.any?
99
99
 
100
- lines = [" %% Statistics"]
101
- lines << " +Stats: #{entity.attributes.size} attributes" if entity.attributes.any?
102
- lines << " +Stats: #{entity.metadata[:methods].size} methods" if entity.metadata[:methods]&.any?
100
+ lines = [" %% === Statistics ==="]
101
+ stats_parts = []
102
+ stats_parts << "#{entity.attributes.size} attributes" if entity.attributes.any?
103
+ stats_parts << "#{entity.metadata[:methods].size} methods" if entity.metadata[:methods]&.any?
104
+ lines << " %% #{stats_parts.join(" | ")}"
103
105
  lines
104
106
  end
105
107
 
106
108
  # Build class definition
107
109
  def build_class_definition(entity)
108
- class_name = Sanitizer.class_name(entity.name)
109
- lines = [" class #{class_name} {"]
110
+ display_name = Sanitizer.display_name(entity.name)
111
+
112
+ # Use display name (with ::) for class definition instead of sanitized version
113
+ lines = [" class `#{display_name}` {"]
110
114
  lines += build_attributes_section(entity)
111
115
  lines += build_methods_section(entity)
112
116
  lines += build_statistics_section(entity)
@@ -38,7 +38,9 @@ module Dbwatcher
38
38
  # Format class name for diagram
39
39
  def format_class_name(entity_id, dataset)
40
40
  entity_name = dataset.get_entity(entity_id)&.name || entity_id
41
- Sanitizer.class_name(entity_name)
41
+ display_name = Sanitizer.display_name(entity_name)
42
+ # Use backticks to allow :: in class names for Mermaid
43
+ "`#{display_name}`"
42
44
  end
43
45
  end
44
46
  end
@@ -16,7 +16,12 @@ module Dbwatcher
16
16
  def class_name(name)
17
17
  return "UnknownClass" unless name.is_a?(String) && !name.empty?
18
18
 
19
- name.to_s.gsub(/[^a-zA-Z0-9_]/, "_").capitalize
19
+ # For namespaced models, preserve the namespace structure
20
+ # Convert :: to underscore for Mermaid syntax while maintaining readability
21
+ sanitized = name.to_s.gsub("::", "__")
22
+
23
+ # Only replace other special characters with underscores
24
+ sanitized.gsub(/[^a-zA-Z0-9_]/, "_")
20
25
  end
21
26
 
22
27
  # Sanitize node name for Mermaid flowcharts
@@ -86,6 +91,17 @@ module Dbwatcher
86
91
  label.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub(/[\n\r]/, " ").strip
87
92
  end
88
93
 
94
+ # Get display name for class (preserves namespace format for labels)
95
+ #
96
+ # @param name [String] raw class name
97
+ # @return [String] display name with proper namespace format
98
+ def display_name(name)
99
+ return "UnknownClass" unless name.is_a?(String) && !name.empty?
100
+
101
+ # Return the original name for display purposes (preserves :: for namespaces)
102
+ name.to_s
103
+ end
104
+
89
105
  # Sanitize attribute type for Mermaid ERD
90
106
  #
91
107
  # @param type [String] raw attribute type