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.
- checksums.yaml +4 -4
- data/README.md +79 -26
- data/app/assets/images/dbwatcher/apple-touch-icon.png +0 -0
- data/app/assets/images/dbwatcher/dbwatcher-social-preview.png +0 -0
- data/app/assets/images/dbwatcher/dbwatcher-tranparent_512x512.png +0 -0
- data/app/assets/images/dbwatcher/dbwatcher_512x512.png +0 -0
- data/app/assets/images/dbwatcher/favicon-96x96.png +0 -0
- data/app/assets/images/dbwatcher/favicon.ico +0 -0
- data/app/assets/images/dbwatcher/favicon.svg +3 -0
- data/app/assets/images/dbwatcher/site.webmanifest +21 -0
- data/app/assets/images/dbwatcher/web-app-manifest-192x192.png +0 -0
- data/app/assets/images/dbwatcher/web-app-manifest-512x512.png +0 -0
- data/app/assets/stylesheets/dbwatcher/application.css +38 -4
- data/app/assets/stylesheets/dbwatcher/components/_tabulator.scss +57 -13
- data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +14 -18
- data/app/controllers/dbwatcher/api/v1/system_info_controller.rb +1 -1
- data/app/controllers/dbwatcher/dashboard_controller.rb +1 -1
- data/app/views/dbwatcher/dashboard/_overview.html.erb +8 -7
- data/app/views/dbwatcher/sessions/index.html.erb +42 -59
- data/app/views/layouts/dbwatcher/application.html.erb +22 -6
- data/lib/dbwatcher/configuration.rb +51 -74
- data/lib/dbwatcher/logging.rb +23 -1
- data/lib/dbwatcher/services/diagram_analyzers/concerns/activerecord_introspection.rb +60 -0
- data/lib/dbwatcher/services/diagram_analyzers/concerns/association_scope_filtering.rb +60 -0
- data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +62 -36
- data/lib/dbwatcher/services/diagram_analyzers/model_analysis/association_extractor.rb +224 -0
- data/lib/dbwatcher/services/diagram_analyzers/model_analysis/dataset_builder.rb +226 -0
- data/lib/dbwatcher/services/diagram_analyzers/model_analysis/model_discovery.rb +161 -0
- data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +27 -514
- data/lib/dbwatcher/services/diagram_data/attribute.rb +22 -83
- data/lib/dbwatcher/services/diagram_data/base.rb +129 -0
- data/lib/dbwatcher/services/diagram_data/entity.rb +23 -72
- data/lib/dbwatcher/services/diagram_data/relationship.rb +15 -66
- data/lib/dbwatcher/services/diagram_generator.rb +35 -69
- data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +23 -9
- data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +16 -22
- data/lib/dbwatcher/services/diagram_strategies/diagram_strategy_helpers.rb +33 -0
- data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +20 -25
- data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +20 -25
- data/lib/dbwatcher/services/diagram_strategies/standard_diagram_strategy.rb +80 -0
- data/lib/dbwatcher/services/diagram_system.rb +14 -1
- data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +2 -0
- data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +2 -2
- data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +4 -14
- data/lib/dbwatcher/services/mermaid_syntax_builder.rb +10 -8
- data/lib/dbwatcher/services/system_info/runtime_info_collector.rb +7 -7
- data/lib/dbwatcher/services/system_info/system_info_collector.rb +3 -3
- data/lib/dbwatcher/services/timeline_data_service/entry_builder.rb +23 -1
- data/lib/dbwatcher/storage/session_storage.rb +2 -2
- data/lib/dbwatcher/storage.rb +1 -1
- data/lib/dbwatcher/version.rb +1 -1
- metadata +20 -2
@@ -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
|