dbwatcher 1.1.4 → 1.1.6

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -26
  3. data/app/assets/config/dbwatcher_manifest.js +9 -0
  4. data/app/assets/images/dbwatcher/README.md +24 -0
  5. data/app/assets/images/dbwatcher/apple-touch-icon.png +0 -0
  6. data/app/assets/images/dbwatcher/dbwatcher-tranparent_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/site.webmanifest +21 -0
  10. data/app/assets/images/dbwatcher/unused-assets.zip +0 -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/system_info_controller.rb +1 -1
  16. data/app/controllers/dbwatcher/dashboard_controller.rb +1 -1
  17. data/app/views/dbwatcher/dashboard/_overview.html.erb +8 -7
  18. data/app/views/dbwatcher/sessions/index.html.erb +42 -59
  19. data/app/views/layouts/dbwatcher/application.html.erb +22 -6
  20. data/lib/dbwatcher/configuration.rb +49 -83
  21. data/lib/dbwatcher/logging.rb +2 -2
  22. data/lib/dbwatcher/services/diagram_analyzers/concerns/activerecord_introspection.rb +60 -0
  23. data/lib/dbwatcher/services/diagram_analyzers/concerns/association_scope_filtering.rb +60 -0
  24. data/lib/dbwatcher/services/diagram_analyzers/model_analysis/association_extractor.rb +224 -0
  25. data/lib/dbwatcher/services/diagram_analyzers/model_analysis/dataset_builder.rb +226 -0
  26. data/lib/dbwatcher/services/diagram_analyzers/model_analysis/model_discovery.rb +161 -0
  27. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +27 -514
  28. data/lib/dbwatcher/services/diagram_data/attribute.rb +22 -83
  29. data/lib/dbwatcher/services/diagram_data/base.rb +129 -0
  30. data/lib/dbwatcher/services/diagram_data/entity.rb +23 -72
  31. data/lib/dbwatcher/services/diagram_data/relationship.rb +15 -66
  32. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +2 -2
  33. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +4 -14
  34. data/lib/dbwatcher/services/system_info/runtime_info_collector.rb +7 -7
  35. data/lib/dbwatcher/services/system_info/system_info_collector.rb +3 -3
  36. data/lib/dbwatcher/services/timeline_data_service/entry_builder.rb +23 -1
  37. data/lib/dbwatcher/storage/session_storage.rb +2 -2
  38. data/lib/dbwatcher/storage.rb +1 -1
  39. data/lib/dbwatcher/version.rb +1 -1
  40. metadata +17 -2
@@ -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
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramAnalyzers
6
+ module ModelAnalysis
7
+ # Service responsible for building datasets from model associations
8
+ #
9
+ # This service handles the transformation of model association data into
10
+ # standardized Dataset format with entities and relationships.
11
+ class DatasetBuilder
12
+ # Create entities from discovered model classes
13
+ #
14
+ # @param dataset [DiagramData::Dataset] dataset to add entities to
15
+ # @param models [Array<Class>] ActiveRecord model classes
16
+ # @return [void]
17
+ def create_entities_from_models(dataset, models)
18
+ models.each do |model_class|
19
+ attributes = extract_model_attributes(model_class)
20
+ methods = extract_model_methods(model_class)
21
+
22
+ entity = create_entity(
23
+ id: model_class.table_name,
24
+ name: model_class.name,
25
+ type: "model",
26
+ attributes: attributes,
27
+ metadata: {
28
+ table_name: model_class.table_name,
29
+ model_class: model_class.name,
30
+ methods: methods
31
+ }
32
+ )
33
+ dataset.add_entity(entity)
34
+ end
35
+ end
36
+
37
+ # Create relationships from association data
38
+ #
39
+ # @param dataset [DiagramData::Dataset] dataset to add relationships to
40
+ # @param raw_data [Array<Hash>] raw association data
41
+ # @return [void]
42
+ def create_relationships_from_associations(dataset, raw_data)
43
+ raw_data.each do |association|
44
+ next if association[:type] == "node_only" || !association[:target_model]
45
+
46
+ source_id = association[:source_table]
47
+ target_id = association[:target_table]
48
+
49
+ # Skip if we don't have valid table IDs
50
+ next unless source_id && target_id
51
+
52
+ # Determine cardinality based on relationship type
53
+ cardinality = determine_cardinality(association[:type])
54
+
55
+ relationship = create_relationship({
56
+ source_id: source_id,
57
+ target_id: target_id,
58
+ type: association[:type],
59
+ label: association[:association_name],
60
+ cardinality: cardinality,
61
+ metadata: {
62
+ association_name: association[:association_name],
63
+ source_model: association[:source_model],
64
+ target_model: association[:target_model],
65
+ original_type: association[:type],
66
+ self_referential: source_id == target_id
67
+ }
68
+ })
69
+
70
+ dataset.add_relationship(relationship)
71
+ end
72
+ end
73
+
74
+ # Build complete dataset from associations and models
75
+ #
76
+ # @param raw_associations [Array<Hash>] raw association data
77
+ # @param models [Array<Class>] ActiveRecord model classes
78
+ # @return [DiagramData::Dataset] standardized dataset
79
+ def build_from_associations(raw_associations, models)
80
+ dataset = create_empty_dataset
81
+ dataset.metadata.merge!({
82
+ total_relationships: raw_associations.count { |a| a[:target_model] },
83
+ total_models: models.length,
84
+ model_names: models.map(&:name)
85
+ })
86
+
87
+ # Create entities from actual discovered models (no inference)
88
+ create_entities_from_models(dataset, models)
89
+
90
+ # Create relationships from association data
91
+ create_relationships_from_associations(dataset, raw_associations)
92
+
93
+ dataset
94
+ end
95
+
96
+ private
97
+
98
+ # Extract attributes from model class
99
+ #
100
+ # @param model_class [Class, nil] ActiveRecord model class
101
+ # @return [Array<Attribute>] model attributes
102
+ def extract_model_attributes(model_class)
103
+ return [] unless model_class.respond_to?(:columns)
104
+
105
+ begin
106
+ model_class.columns.map do |column|
107
+ create_attribute(
108
+ name: column.name,
109
+ type: column.type.to_s,
110
+ nullable: column.null,
111
+ default: column.default,
112
+ metadata: {
113
+ primary_key: column.name == model_class.primary_key,
114
+ foreign_key: column.name.end_with?("_id"),
115
+ visibility: "+" # Public visibility for all columns
116
+ }
117
+ )
118
+ end
119
+ rescue StandardError => e
120
+ Rails.logger.warn "DatasetBuilder: Could not extract attributes for " \
121
+ "#{model_class.name}: #{e.message}"
122
+ []
123
+ end
124
+ end
125
+
126
+ # Extract methods from model class
127
+ #
128
+ # @param model_class [Class, nil] ActiveRecord model class
129
+ # @return [Array<Hash>] model methods
130
+ def extract_model_methods(model_class)
131
+ return [] unless model_class && Dbwatcher.configuration.diagram_show_methods
132
+
133
+ methods = []
134
+
135
+ begin
136
+ # Get instance methods defined in the model (not inherited from ActiveRecord::Base)
137
+ model_methods = model_class.instance_methods - ActiveRecord::Base.instance_methods
138
+ model_methods.each do |method_name|
139
+ # Skip association methods and attribute methods
140
+ next if method_name.to_s.end_with?("=") || model_class.column_names.include?(method_name.to_s)
141
+
142
+ methods << {
143
+ name: method_name.to_s,
144
+ visibility: "+" # Public visibility for all methods
145
+ }
146
+ end
147
+ rescue StandardError => e
148
+ Rails.logger.warn "DatasetBuilder: Could not extract methods for " \
149
+ "#{model_class.name}: #{e.message}"
150
+ end
151
+
152
+ methods
153
+ end
154
+
155
+ # Determine cardinality based on relationship type
156
+ #
157
+ # @param relationship_type [String] relationship type
158
+ # @return [String, nil] cardinality type
159
+ def determine_cardinality(relationship_type)
160
+ case relationship_type
161
+ when "has_many"
162
+ "one_to_many"
163
+ when "belongs_to"
164
+ "many_to_one"
165
+ when "has_one"
166
+ "one_to_one"
167
+ when "has_and_belongs_to_many", "has_many_through"
168
+ "many_to_many"
169
+ end
170
+ end
171
+
172
+ # Helper method to create entities (delegated to base analyzer)
173
+ #
174
+ # @param params [Hash] entity parameters
175
+ # @return [DiagramData::Entity] new entity
176
+ def create_entity(**params)
177
+ Dbwatcher::Services::DiagramData::Entity.new(
178
+ id: params[:id],
179
+ name: params[:name],
180
+ type: params[:type] || "default",
181
+ attributes: params[:attributes] || [],
182
+ metadata: params[:metadata] || {}
183
+ )
184
+ end
185
+
186
+ # Helper method to create relationships (delegated to base analyzer)
187
+ #
188
+ # @param params [Hash] relationship parameters
189
+ # @return [DiagramData::Relationship] new relationship
190
+ def create_relationship(params)
191
+ params_obj = Dbwatcher::Services::DiagramData::RelationshipParams.new(params)
192
+ Dbwatcher::Services::DiagramData::Relationship.new(params_obj)
193
+ end
194
+
195
+ # Helper method to create attributes (delegated to base analyzer)
196
+ #
197
+ # @param params [Hash] attribute parameters
198
+ # @return [DiagramData::Attribute] new attribute
199
+ def create_attribute(**params)
200
+ Dbwatcher::Services::DiagramData::Attribute.new(
201
+ name: params[:name],
202
+ type: params[:type],
203
+ nullable: params[:nullable] || true,
204
+ default: params[:default],
205
+ metadata: params[:metadata] || {}
206
+ )
207
+ end
208
+
209
+ # Helper method to create empty dataset (delegated to base analyzer)
210
+ #
211
+ # @return [DiagramData::Dataset] empty dataset
212
+ def create_empty_dataset
213
+ Dbwatcher::Services::DiagramData::Dataset.new(
214
+ metadata: {
215
+ analyzer: "ModelAssociationAnalyzer",
216
+ analyzer_type: "model_association",
217
+ empty_reason: "No data found or analysis failed",
218
+ generated_at: Time.current.iso8601
219
+ }
220
+ )
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramAnalyzers
6
+ module ModelAnalysis
7
+ # Service responsible for discovering and filtering ActiveRecord models
8
+ #
9
+ # This service handles the complex logic of finding ActiveRecord models
10
+ # that are relevant for diagram analysis, including models from gems,
11
+ # namespaced models, and models with custom table names.
12
+ class ModelDiscovery
13
+ attr_reader :session_tables, :discovered_models
14
+
15
+ # Initialize with optional session tables for filtering
16
+ #
17
+ # @param session_tables [Array<String>] table names from session (empty for global analysis)
18
+ def initialize(session_tables = [])
19
+ @session_tables = session_tables || []
20
+ @discovered_models = []
21
+ end
22
+
23
+ # Discover models that correspond to session tables or all models if no session
24
+ #
25
+ # @return [Array<Class>] ActiveRecord model classes
26
+ def discover
27
+ return [] unless activerecord_available?
28
+
29
+ begin
30
+ all_models = load_all_models
31
+ @discovered_models = filter_models_by_session(all_models)
32
+
33
+ log_discovery_results
34
+ @discovered_models
35
+ rescue StandardError => e
36
+ Rails.logger.error "ModelDiscovery: Error discovering models: #{e.message}"
37
+ []
38
+ end
39
+ end
40
+
41
+ # Load all available ActiveRecord models including from gems
42
+ #
43
+ # @return [Array<Class>] ActiveRecord model classes
44
+ def load_all_models
45
+ eager_load_models
46
+
47
+ # Get all model classes directly from ActiveRecord descendants
48
+ all_models = ActiveRecord::Base.descendants
49
+ .select { |model| valid_model_class?(model) }
50
+ .uniq
51
+
52
+ Rails.logger.debug "ModelDiscovery: Found #{all_models.size} total ActiveRecord models"
53
+ all_models
54
+ end
55
+
56
+ # Check if a model class is valid for analysis
57
+ #
58
+ # @param model [Class] ActiveRecord model class
59
+ # @return [Boolean] true if model is valid
60
+ def valid_model_class?(model)
61
+ # Must be a proper class with a name (not anonymous)
62
+ return false unless model.name
63
+
64
+ # Must have a table that exists
65
+ return false unless model_has_table?(model)
66
+
67
+ # Skip abstract models
68
+ return false if model.abstract_class?
69
+
70
+ true
71
+ rescue StandardError => e
72
+ Rails.logger.debug "ModelDiscovery: Error validating model #{model}: #{e.message}"
73
+ false
74
+ end
75
+
76
+ # Check if model has a database table
77
+ #
78
+ # @param model [Class] ActiveRecord model class
79
+ # @return [Boolean] true if model has a table
80
+ def model_has_table?(model)
81
+ model.table_exists?
82
+ rescue StandardError
83
+ false
84
+ end
85
+
86
+ private
87
+
88
+ # Check if ActiveRecord is available
89
+ #
90
+ # @return [Boolean]
91
+ def activerecord_available?
92
+ defined?(ActiveRecord::Base)
93
+ end
94
+
95
+ # Eagerly load all models including those from gems
96
+ #
97
+ # @return [void]
98
+ def eager_load_models
99
+ return unless defined?(Rails) && Rails.respond_to?(:application)
100
+
101
+ begin
102
+ # Force eager loading of application models
103
+ Rails.application.eager_load!
104
+
105
+ # Also load models from engines/gems if any are configured
106
+ Rails::Engine.descendants.each do |engine|
107
+ engine.eager_load! if engine.respond_to?(:eager_load!)
108
+ rescue StandardError => e
109
+ error_message = "ModelDiscovery: Could not eager load engine #{engine.class.name}: #{e.message}"
110
+ Rails.logger.debug error_message
111
+ end
112
+ rescue StandardError => e
113
+ Rails.logger.debug "ModelDiscovery: Could not eager load models: #{e.message}"
114
+ end
115
+ end
116
+
117
+ # Filter models based on session tables
118
+ #
119
+ # @param all_models [Array<Class>] all available models
120
+ # @return [Array<Class>] filtered models
121
+ def filter_models_by_session(all_models)
122
+ return all_models if session_tables.empty?
123
+
124
+ # Build a hash of table_name -> model for efficient lookup
125
+ table_to_models = {}
126
+ all_models.each do |model|
127
+ table_name = model.table_name
128
+ table_to_models[table_name] ||= []
129
+ table_to_models[table_name] << model
130
+ rescue StandardError => e
131
+ Rails.logger.warn "ModelDiscovery: Error checking table_name for " \
132
+ "#{model.name}: #{e.message}"
133
+ end
134
+
135
+ # Select models whose tables are in the session
136
+ filtered_models = []
137
+ session_tables.each do |table_name|
138
+ models_for_table = table_to_models[table_name]
139
+ filtered_models.concat(models_for_table) if models_for_table
140
+ end
141
+
142
+ filtered_models
143
+ end
144
+
145
+ # Log discovery results for debugging
146
+ #
147
+ # @return [void]
148
+ def log_discovery_results
149
+ if discovered_models.any?
150
+ model_table_info = discovered_models.map { |m| "#{m.name} (#{m.table_name})" }
151
+ Rails.logger.debug "ModelDiscovery: Discovered models: #{model_table_info.join(", ")}"
152
+ end
153
+
154
+ Rails.logger.debug "ModelDiscovery: Filtered to #{discovered_models.size} " \
155
+ "models matching session tables (from #{session_tables.size} tables)"
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end