dbwatcher 1.1.3 → 1.1.5

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -26
  3. data/app/assets/images/dbwatcher/apple-touch-icon.png +0 -0
  4. data/app/assets/images/dbwatcher/dbwatcher-social-preview.png +0 -0
  5. data/app/assets/images/dbwatcher/dbwatcher-tranparent_512x512.png +0 -0
  6. data/app/assets/images/dbwatcher/dbwatcher_512x512.png +0 -0
  7. data/app/assets/images/dbwatcher/favicon-96x96.png +0 -0
  8. data/app/assets/images/dbwatcher/favicon.ico +0 -0
  9. data/app/assets/images/dbwatcher/favicon.svg +3 -0
  10. data/app/assets/images/dbwatcher/site.webmanifest +21 -0
  11. data/app/assets/images/dbwatcher/web-app-manifest-192x192.png +0 -0
  12. data/app/assets/images/dbwatcher/web-app-manifest-512x512.png +0 -0
  13. data/app/assets/stylesheets/dbwatcher/application.css +38 -4
  14. data/app/assets/stylesheets/dbwatcher/components/_tabulator.scss +57 -13
  15. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +14 -18
  16. data/app/controllers/dbwatcher/api/v1/system_info_controller.rb +1 -1
  17. data/app/controllers/dbwatcher/dashboard_controller.rb +1 -1
  18. data/app/views/dbwatcher/dashboard/_overview.html.erb +8 -7
  19. data/app/views/dbwatcher/sessions/index.html.erb +42 -59
  20. data/app/views/layouts/dbwatcher/application.html.erb +22 -6
  21. data/lib/dbwatcher/configuration.rb +51 -74
  22. data/lib/dbwatcher/logging.rb +23 -1
  23. data/lib/dbwatcher/services/diagram_analyzers/concerns/activerecord_introspection.rb +60 -0
  24. data/lib/dbwatcher/services/diagram_analyzers/concerns/association_scope_filtering.rb +60 -0
  25. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +62 -36
  26. data/lib/dbwatcher/services/diagram_analyzers/model_analysis/association_extractor.rb +224 -0
  27. data/lib/dbwatcher/services/diagram_analyzers/model_analysis/dataset_builder.rb +226 -0
  28. data/lib/dbwatcher/services/diagram_analyzers/model_analysis/model_discovery.rb +161 -0
  29. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +27 -514
  30. data/lib/dbwatcher/services/diagram_data/attribute.rb +22 -83
  31. data/lib/dbwatcher/services/diagram_data/base.rb +129 -0
  32. data/lib/dbwatcher/services/diagram_data/entity.rb +23 -72
  33. data/lib/dbwatcher/services/diagram_data/relationship.rb +15 -66
  34. data/lib/dbwatcher/services/diagram_generator.rb +35 -69
  35. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +23 -9
  36. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +16 -22
  37. data/lib/dbwatcher/services/diagram_strategies/diagram_strategy_helpers.rb +33 -0
  38. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +20 -25
  39. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +20 -25
  40. data/lib/dbwatcher/services/diagram_strategies/standard_diagram_strategy.rb +80 -0
  41. data/lib/dbwatcher/services/diagram_system.rb +14 -1
  42. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +2 -0
  43. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +2 -2
  44. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +4 -14
  45. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +10 -8
  46. data/lib/dbwatcher/services/system_info/runtime_info_collector.rb +7 -7
  47. data/lib/dbwatcher/services/system_info/system_info_collector.rb +3 -3
  48. data/lib/dbwatcher/services/timeline_data_service/entry_builder.rb +23 -1
  49. data/lib/dbwatcher/storage/session_storage.rb +2 -2
  50. data/lib/dbwatcher/storage.rb +1 -1
  51. data/lib/dbwatcher/version.rb +1 -1
  52. metadata +20 -2
@@ -14,9 +14,12 @@ module Dbwatcher
14
14
  end
15
15
 
16
16
  # Log a debug message with optional context
17
+ # Only logs if debug mode is enabled
17
18
  # @param message [String] the log message
18
19
  # @param context [Hash] additional context data
19
20
  def log_debug(message, context = {})
21
+ return unless debug_enabled?
22
+
20
23
  log_with_level(:debug, message, context)
21
24
  end
22
25
 
@@ -34,6 +37,16 @@ module Dbwatcher
34
37
  log_with_level(:error, message, context)
35
38
  end
36
39
 
40
+ # Check if debug logging is enabled
41
+ # @return [Boolean] true if debug logging is enabled
42
+ def debug_enabled?
43
+ return Dbwatcher.configuration.debug_mode if defined?(Dbwatcher.configuration) &&
44
+ Dbwatcher.configuration.respond_to?(:debug_mode)
45
+ return Rails.env.development? if defined?(Rails)
46
+
47
+ false
48
+ end
49
+
37
50
  private
38
51
 
39
52
  def log_with_level(level, message, context)
@@ -51,7 +64,16 @@ module Dbwatcher
51
64
  end
52
65
 
53
66
  def component_name
54
- self.class.name.split("::").last
67
+ if is_a?(Module) && !is_a?(Class)
68
+ # For modules
69
+ name.to_s.split("::").last
70
+ elsif self.class.name
71
+ # For classes
72
+ self.class.name.split("::").last
73
+ else
74
+ # Fallback
75
+ "Logger"
76
+ end
55
77
  end
56
78
 
57
79
  def rails_logger
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramAnalyzers
6
+ module Concerns
7
+ # Concern for ActiveRecord introspection utilities
8
+ #
9
+ # This module provides common methods for checking ActiveRecord availability
10
+ # and performing model introspection operations.
11
+ module ActiverecordIntrospection
12
+ extend ActiveSupport::Concern if defined?(ActiveSupport)
13
+
14
+ private
15
+
16
+ # Check if ActiveRecord is available
17
+ #
18
+ # @return [Boolean]
19
+ def activerecord_available?
20
+ defined?(ActiveRecord::Base)
21
+ end
22
+
23
+ # Check if models analysis is available
24
+ #
25
+ # @return [Boolean] true if models can be analyzed
26
+ def models_available?
27
+ unless activerecord_available?
28
+ Rails.logger.warn "#{self.class.name}: ActiveRecord not available"
29
+ return false
30
+ end
31
+
32
+ true
33
+ end
34
+
35
+ # Eagerly load all models including those from gems
36
+ #
37
+ # @return [void]
38
+ def eager_load_models
39
+ return unless defined?(Rails) && Rails.respond_to?(:application)
40
+
41
+ begin
42
+ # Force eager loading of application models
43
+ Rails.application.eager_load!
44
+
45
+ # Also load models from engines/gems if any are configured
46
+ Rails::Engine.descendants.each do |engine|
47
+ engine.eager_load! if engine.respond_to?(:eager_load!)
48
+ rescue StandardError => e
49
+ error_message = "#{self.class.name}: Could not eager load engine #{engine.class.name}: #{e.message}"
50
+ Rails.logger.debug error_message
51
+ end
52
+ rescue StandardError => e
53
+ Rails.logger.debug "#{self.class.name}: Could not eager load models: #{e.message}"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramAnalyzers
6
+ module Concerns
7
+ # Concern for filtering associations based on scope
8
+ #
9
+ # This module provides methods for determining which associations
10
+ # should be included in the analysis based on session context.
11
+ module AssociationScopeFiltering
12
+ extend ActiveSupport::Concern if defined?(ActiveSupport)
13
+
14
+ private
15
+
16
+ # Check if target model is in analysis scope
17
+ #
18
+ # @param association [Object] association object
19
+ # @param session_tables [Array<String>] tables from session context
20
+ # @return [Boolean] true if target model should be included
21
+ def target_model_in_scope?(association, session_tables = [])
22
+ target_table = get_association_table_name(association)
23
+
24
+ # If analyzing session, both tables must be in session
25
+ # If analyzing globally, include all
26
+ return true if session_tables.empty?
27
+
28
+ # Skip if target table is not in session
29
+ return false if target_table && !session_tables.include?(target_table)
30
+
31
+ true
32
+ end
33
+
34
+ # Get table name for association target
35
+ #
36
+ # @param association [Object] association object
37
+ # @return [String, nil] table name
38
+ def get_association_table_name(association)
39
+ association.table_name
40
+ rescue StandardError => e
41
+ Rails.logger.warn "#{self.class.name}: Could not get table name for #{association.name}: #{e.message}"
42
+ nil
43
+ end
44
+
45
+ # Extract tables that were involved in the session
46
+ #
47
+ # @param session [Object] session object with changes
48
+ # @return [Array<String>] unique table names
49
+ def extract_session_tables(session)
50
+ return [] unless session&.changes
51
+
52
+ session.changes.map do |change|
53
+ change[:table_name] || change["table_name"]
54
+ end.compact.uniq
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -232,6 +232,37 @@ module Dbwatcher
232
232
  # @param primary_key [String, nil] optional primary key for testing
233
233
  # @return [Boolean] true if likely self-referential
234
234
  def self_referential_column?(column_name, table_name, primary_key = nil)
235
+ # Get the singular form of the table name
236
+ base_name = singularize(table_name)
237
+
238
+ # Special case for post_id in posts table - not a self-reference
239
+ return false if column_name == "#{base_name}_id" && table_name == "posts" && base_name == "post"
240
+
241
+ # Check primary key if this is a table-specific reference
242
+ if column_name == "#{base_name}_id"
243
+ if primary_key.nil?
244
+ begin
245
+ primary_key = connection.primary_key(table_name)
246
+ rescue StandardError
247
+ return false
248
+ end
249
+ end
250
+
251
+ return column_name != primary_key
252
+ end
253
+
254
+ # Use pattern matching to check various self-referential patterns
255
+ common_pattern?(column_name) ||
256
+ hierarchy_pattern?(column_name, base_name) ||
257
+ relationship_pattern?(column_name) ||
258
+ directional_pattern?(column_name)
259
+ end
260
+
261
+ # Check if column matches common self-referential patterns
262
+ #
263
+ # @param column_name [String] column name to check
264
+ # @return [Boolean] true if matches common patterns
265
+ def common_pattern?(column_name)
235
266
  # Common self-referential patterns
236
267
  self_ref_patterns = %w[
237
268
  parent_id
@@ -257,52 +288,47 @@ module Dbwatcher
257
288
  replied_to_id
258
289
  ]
259
290
 
260
- # Check for exact matches with common patterns
261
- return true if self_ref_patterns.include?(column_name)
262
-
263
- # Get the singular form of the table name
264
- base_name = singularize(table_name)
265
-
266
- # Special case for post_id in posts table - not a self-reference
267
- return false if column_name == "#{base_name}_id" && table_name == "posts" && base_name == "post"
268
-
269
- # Check for table-specific self-references (e.g., comment_id in comments table)
270
- if column_name == "#{base_name}_id"
271
- # Check if this is not the primary key column
272
- if primary_key.nil?
273
- begin
274
- primary_key = connection.primary_key(table_name)
275
- rescue StandardError
276
- return false
277
- end
278
- end
279
-
280
- return column_name != primary_key
281
- end
291
+ self_ref_patterns.include?(column_name)
292
+ end
282
293
 
283
- # Check for hierarchy patterns with table name
294
+ # Check for hierarchy patterns with table name
295
+ #
296
+ # @param column_name [String] column name to check
297
+ # @param base_name [String] singular form of table name
298
+ # @return [Boolean] true if matches hierarchy patterns
299
+ def hierarchy_pattern?(column_name, base_name)
284
300
  hierarchy_prefixes = %w[parent child ancestor descendant superior subordinate manager supervisor]
285
- hierarchy_prefixes.each do |prefix|
286
- # Check for patterns like parent_comment_id in comments table
287
- return true if column_name.start_with?("#{prefix}_#{base_name}_id")
288
301
 
289
- # Check for patterns like parent_of_id in any table
290
- return true if column_name.start_with?("#{prefix}_of_id")
302
+ hierarchy_prefixes.any? do |prefix|
303
+ # Check for patterns like parent_comment_id in comments table
304
+ column_name.start_with?("#{prefix}_#{base_name}_id") ||
305
+ # Check for patterns like parent_of_id in any table
306
+ column_name.start_with?("#{prefix}_of_id")
291
307
  end
308
+ end
292
309
 
293
- # Check for relationship patterns
310
+ # Check for relationship patterns
311
+ #
312
+ # @param column_name [String] column name to check
313
+ # @return [Boolean] true if matches relationship patterns
314
+ def relationship_pattern?(column_name)
294
315
  relationship_patterns = %w[related linked connected associated referenced]
295
- relationship_patterns.each do |pattern|
296
- return true if column_name.start_with?("#{pattern}_")
316
+
317
+ relationship_patterns.any? do |pattern|
318
+ column_name.start_with?("#{pattern}_")
297
319
  end
320
+ end
298
321
 
299
- # Check for directional patterns
322
+ # Check for directional patterns
323
+ #
324
+ # @param column_name [String] column name to check
325
+ # @return [Boolean] true if matches directional patterns
326
+ def directional_pattern?(column_name)
300
327
  directional_patterns = %w[previous next original copy source target]
301
- directional_patterns.each do |pattern|
302
- return true if column_name.start_with?("#{pattern}_")
303
- end
304
328
 
305
- false
329
+ directional_patterns.any? do |pattern|
330
+ column_name.start_with?("#{pattern}_")
331
+ end
306
332
  end
307
333
 
308
334
  # Analyze junction tables (many-to-many relationships)
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramAnalyzers
6
+ module ModelAnalysis
7
+ # Service responsible for analyzing ActiveRecord model associations
8
+ #
9
+ # This service handles the extraction and analysis of model associations,
10
+ # converting them into standardized relationship data for diagram generation.
11
+ class AssociationExtractor
12
+ attr_reader :session_tables
13
+
14
+ # Initialize with optional session tables for scope filtering
15
+ #
16
+ # @param session_tables [Array<String>] table names from session (empty for global analysis)
17
+ def initialize(session_tables = [])
18
+ @session_tables = session_tables || []
19
+ end
20
+
21
+ # Extract associations from all provided models
22
+ #
23
+ # @param models [Array<Class>] ActiveRecord model classes to analyze
24
+ # @return [Array<Hash>] associations array
25
+ def extract_all(models)
26
+ associations = []
27
+
28
+ models.each do |model|
29
+ model_associations = get_model_associations(model)
30
+
31
+ model_associations.each do |association|
32
+ # Skip polymorphic associations for now
33
+ next if association.options[:polymorphic]
34
+
35
+ # Skip if target model is not in scope
36
+ next unless target_model_in_scope?(association)
37
+
38
+ # Build relationship based on association type
39
+ relationship = build_association_relationship(model, association)
40
+ associations << relationship if relationship
41
+ end
42
+ end
43
+
44
+ associations
45
+ end
46
+
47
+ # Generate placeholder associations for models without associations
48
+ #
49
+ # @param models [Array<Class>] models to create placeholders for
50
+ # @return [Array<Hash>] placeholder associations
51
+ def generate_placeholder_associations(models)
52
+ result = models.map do |model|
53
+ {
54
+ type: "node_only",
55
+ source_model: model.name,
56
+ source_table: model.table_name,
57
+ target_model: nil,
58
+ target_table: nil,
59
+ association_name: nil
60
+ }
61
+ end
62
+
63
+ Rails.logger.info "AssociationExtractor: Generated #{result.size} placeholder nodes"
64
+ result
65
+ end
66
+
67
+ private
68
+
69
+ # Get associations for a model
70
+ #
71
+ # @param model [Class] ActiveRecord model class
72
+ # @return [Array] association objects
73
+ def get_model_associations(model)
74
+ model.reflect_on_all_associations
75
+ rescue StandardError => e
76
+ Rails.logger.warn "AssociationExtractor: Could not get associations for #{model.name}: #{e.message}"
77
+ []
78
+ end
79
+
80
+ # Check if target model is in analysis scope
81
+ #
82
+ # @param association [Object] association object
83
+ # @return [Boolean] true if target model should be included
84
+ def target_model_in_scope?(association)
85
+ target_table = get_association_table_name(association)
86
+
87
+ # If analyzing session, both tables must be in session
88
+ # If analyzing globally, include all
89
+ return true if session_tables.empty?
90
+
91
+ # Skip if target table is not in session
92
+ return false if target_table && !session_tables.include?(target_table)
93
+
94
+ true
95
+ end
96
+
97
+ # Build relationship hash based on association type
98
+ #
99
+ # @param model [Class] source model class
100
+ # @param association [Object] association object
101
+ # @return [Hash] relationship data
102
+ def build_association_relationship(model, association)
103
+ case association.macro
104
+ when :belongs_to
105
+ build_belongs_to_relationship(model, association)
106
+ when :has_one
107
+ build_has_one_relationship(model, association)
108
+ when :has_many
109
+ if association.options[:through]
110
+ build_has_many_through_relationship(model, association)
111
+ else
112
+ build_has_many_relationship(model, association)
113
+ end
114
+ when :has_and_belongs_to_many
115
+ build_habtm_relationship(model, association)
116
+ else
117
+ # Handle special cases like has_one_attached from Active Storage
118
+ if association.name.to_s.end_with?("_attachment") || association.name.to_s.end_with?("_attachments")
119
+ build_active_storage_relationship(model, association)
120
+ else
121
+ Rails.logger.warn "AssociationExtractor: Unknown association type: " \
122
+ "#{association.macro} for #{model.name}.#{association.name}"
123
+ nil
124
+ end
125
+ end
126
+ end
127
+
128
+ # Build a standardized relationship hash
129
+ #
130
+ # @param model [Class] source model class
131
+ # @param association [Object] association object
132
+ # @param type [String] relationship type
133
+ # @return [Hash] relationship data
134
+ def build_relationship_hash(model, association, type)
135
+ return nil unless association&.class_name
136
+
137
+ {
138
+ source_model: model.name,
139
+ source_table: model.table_name,
140
+ target_model: association.class_name,
141
+ target_table: get_association_table_name(association),
142
+ type: type,
143
+ association_name: association.name.to_s
144
+ }
145
+ end
146
+
147
+ # Build belongs_to relationship
148
+ #
149
+ # @param model [Class] source model class
150
+ # @param association [Object] association object
151
+ # @return [Hash] relationship data
152
+ def build_belongs_to_relationship(model, association)
153
+ build_relationship_hash(model, association, "belongs_to")
154
+ end
155
+
156
+ # Build has_one relationship
157
+ #
158
+ # @param model [Class] source model class
159
+ # @param association [Object] association object
160
+ # @return [Hash] relationship data
161
+ def build_has_one_relationship(model, association)
162
+ build_relationship_hash(model, association, "has_one")
163
+ end
164
+
165
+ # Build has_many relationship
166
+ #
167
+ # @param model [Class] source model class
168
+ # @param association [Object] association object
169
+ # @return [Hash] relationship data
170
+ def build_has_many_relationship(model, association)
171
+ build_relationship_hash(model, association, "has_many")
172
+ end
173
+
174
+ # Build has_many :through relationship
175
+ #
176
+ # @param model [Class] source model class
177
+ # @param association [Object] association object
178
+ # @return [Hash] relationship data
179
+ def build_has_many_through_relationship(model, association)
180
+ relationship = build_relationship_hash(model, association, "has_many_through")
181
+ relationship[:association_name] = "#{association.name} (through #{association.options[:through]})"
182
+ relationship
183
+ end
184
+
185
+ # Build has_and_belongs_to_many relationship
186
+ #
187
+ # @param model [Class] source model class
188
+ # @param association [Object] association object
189
+ # @return [Hash] relationship data
190
+ def build_habtm_relationship(model, association)
191
+ build_relationship_hash(model, association, "has_and_belongs_to_many")
192
+ end
193
+
194
+ # Build Active Storage relationship
195
+ #
196
+ # @param model [Class] source model class
197
+ # @param association [Object] association object
198
+ # @return [Hash] relationship data
199
+ def build_active_storage_relationship(model, association)
200
+ {
201
+ source_model: model.name,
202
+ source_table: model.table_name,
203
+ target_model: "ActiveStorage::Attachment",
204
+ target_table: "active_storage_attachments",
205
+ type: "has_one",
206
+ association_name: association.name.to_s
207
+ }
208
+ end
209
+
210
+ # Get table name for association target
211
+ #
212
+ # @param association [Object] association object
213
+ # @return [String, nil] table name
214
+ def get_association_table_name(association)
215
+ association.table_name
216
+ rescue StandardError => e
217
+ Rails.logger.warn "AssociationExtractor: Could not get table name for #{association.name}: #{e.message}"
218
+ nil
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end